From ce3b8ed4ad11e2c8c9e9e3727b21f911d5271057 Mon Sep 17 00:00:00 2001 From: Joe Portner Date: Fri, 10 Dec 2021 10:00:52 -0500 Subject: [PATCH 1/8] Refactor unit tests Change some mocks for better readability. --- .../import/import_saved_objects.test.mock.ts | 52 ++++++ .../import/import_saved_objects.test.ts | 125 ++++++------- .../import/lib/__mocks__/index.ts | 14 -- .../import/lib/check_conflicts.test.ts | 20 +-- .../import/lib/check_origin_conflicts.test.ts | 14 +- .../import/lib/create_saved_objects.ts | 4 +- .../import/lib/execute_import_hooks.ts | 2 +- .../import/lib/regenerate_ids.test.ts | 38 ++-- .../import/resolve_import_errors.test.mock.ts | 70 ++++++++ .../import/resolve_import_errors.test.ts | 164 ++++++++---------- .../routes/integration_tests/import.test.ts | 10 +- .../resolve_import_errors.test.ts | 9 +- 12 files changed, 293 insertions(+), 229 deletions(-) create mode 100644 src/core/server/saved_objects/import/import_saved_objects.test.mock.ts delete mode 100644 src/core/server/saved_objects/import/lib/__mocks__/index.ts create mode 100644 src/core/server/saved_objects/import/resolve_import_errors.test.mock.ts diff --git a/src/core/server/saved_objects/import/import_saved_objects.test.mock.ts b/src/core/server/saved_objects/import/import_saved_objects.test.mock.ts new file mode 100644 index 0000000000000..d152bf3039121 --- /dev/null +++ b/src/core/server/saved_objects/import/import_saved_objects.test.mock.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { collectSavedObjects } from './lib/collect_saved_objects'; +import type { regenerateIds } from './lib/regenerate_ids'; +import type { validateReferences } from './lib/validate_references'; +import type { checkConflicts } from './lib/check_conflicts'; +import type { checkOriginConflicts } from './lib/check_origin_conflicts'; +import type { createSavedObjects } from './lib/create_saved_objects'; +import type { executeImportHooks } from './lib/execute_import_hooks'; + +export const mockCollectSavedObjects = jest.fn() as jest.MockedFunction; +jest.mock('./lib/collect_saved_objects', () => ({ + collectSavedObjects: mockCollectSavedObjects, +})); + +export const mockRegenerateIds = jest.fn() as jest.MockedFunction; +jest.mock('./lib/regenerate_ids', () => ({ + regenerateIds: mockRegenerateIds, +})); + +export const mockValidateReferences = jest.fn() as jest.MockedFunction; +jest.mock('./lib/validate_references', () => ({ + validateReferences: mockValidateReferences, +})); + +export const mockCheckConflicts = jest.fn() as jest.MockedFunction; +jest.mock('./lib/check_conflicts', () => ({ + checkConflicts: mockCheckConflicts, +})); + +export const mockCheckOriginConflicts = jest.fn() as jest.MockedFunction< + typeof checkOriginConflicts +>; +jest.mock('./lib/check_origin_conflicts', () => ({ + checkOriginConflicts: mockCheckOriginConflicts, +})); + +export const mockCreateSavedObjects = jest.fn() as jest.MockedFunction; +jest.mock('./lib/create_saved_objects', () => ({ + createSavedObjects: mockCreateSavedObjects, +})); + +export const mockExecuteImportHooks = jest.fn() as jest.MockedFunction; +jest.mock('./lib/execute_import_hooks', () => ({ + executeImportHooks: mockExecuteImportHooks, +})); diff --git a/src/core/server/saved_objects/import/import_saved_objects.test.ts b/src/core/server/saved_objects/import/import_saved_objects.test.ts index cf30d6c803933..5a597045f3b07 100644 --- a/src/core/server/saved_objects/import/import_saved_objects.test.ts +++ b/src/core/server/saved_objects/import/import_saved_objects.test.ts @@ -6,6 +6,16 @@ * Side Public License, v 1. */ +import { + mockCollectSavedObjects, + mockRegenerateIds, + mockValidateReferences, + mockCheckConflicts, + mockCheckOriginConflicts, + mockCreateSavedObjects, + mockExecuteImportHooks, +} from './import_saved_objects.test.mock'; + import { Readable } from 'stream'; import { v4 as uuidv4 } from 'uuid'; import { @@ -20,51 +30,30 @@ import { typeRegistryMock } from '../saved_objects_type_registry.mock'; import { importSavedObjectsFromStream, ImportSavedObjectsOptions } from './import_saved_objects'; import { SavedObjectsImportHook, SavedObjectsImportWarning } from './types'; -import { - collectSavedObjects, - regenerateIds, - validateReferences, - checkConflicts, - checkOriginConflicts, - createSavedObjects, - executeImportHooks, -} from './lib'; - -jest.mock('./lib/collect_saved_objects'); -jest.mock('./lib/regenerate_ids'); -jest.mock('./lib/validate_references'); -jest.mock('./lib/check_conflicts'); -jest.mock('./lib/check_origin_conflicts'); -jest.mock('./lib/create_saved_objects'); -jest.mock('./lib/execute_import_hooks'); - -const getMockFn = any, U>(fn: (...args: Parameters) => U) => - fn as jest.MockedFunction<(...args: Parameters) => U>; - describe('#importSavedObjectsFromStream', () => { beforeEach(() => { jest.clearAllMocks(); // mock empty output of each of these mocked modules so the import doesn't throw an error - getMockFn(collectSavedObjects).mockResolvedValue({ + mockCollectSavedObjects.mockResolvedValue({ errors: [], collectedObjects: [], importIdMap: new Map(), }); - getMockFn(regenerateIds).mockReturnValue(new Map()); - getMockFn(validateReferences).mockResolvedValue([]); - getMockFn(checkConflicts).mockResolvedValue({ + mockRegenerateIds.mockReturnValue(new Map()); + mockValidateReferences.mockResolvedValue([]); + mockCheckConflicts.mockResolvedValue({ errors: [], filteredObjects: [], importIdMap: new Map(), pendingOverwrites: new Set(), }); - getMockFn(checkOriginConflicts).mockResolvedValue({ + mockCheckOriginConflicts.mockResolvedValue({ errors: [], importIdMap: new Map(), pendingOverwrites: new Set(), }); - getMockFn(createSavedObjects).mockResolvedValue({ errors: [], createdObjects: [] }); - getMockFn(executeImportHooks).mockResolvedValue([]); + mockCreateSavedObjects.mockResolvedValue({ errors: [], createdObjects: [] }); + mockExecuteImportHooks.mockResolvedValue([]); }); let readStream: Readable; @@ -143,20 +132,20 @@ describe('#importSavedObjectsFromStream', () => { await importSavedObjectsFromStream(options); expect(typeRegistry.getImportableAndExportableTypes).toHaveBeenCalled(); const collectSavedObjectsOptions = { readStream, objectLimit, supportedTypes }; - expect(collectSavedObjects).toHaveBeenCalledWith(collectSavedObjectsOptions); + expect(mockCollectSavedObjects).toHaveBeenCalledWith(collectSavedObjectsOptions); }); test('validates references', async () => { const options = setupOptions(); const collectedObjects = [createObject()]; - getMockFn(collectSavedObjects).mockResolvedValue({ + mockCollectSavedObjects.mockResolvedValue({ errors: [], collectedObjects, importIdMap: new Map(), }); await importSavedObjectsFromStream(options); - expect(validateReferences).toHaveBeenCalledWith( + expect(mockValidateReferences).toHaveBeenCalledWith( collectedObjects, savedObjectsClient, namespace @@ -170,19 +159,19 @@ describe('#importSavedObjectsFromStream', () => { const options = setupOptions({ importHooks }); const collectedObjects = [createObject()]; - getMockFn(collectSavedObjects).mockResolvedValue({ + mockCollectSavedObjects.mockResolvedValue({ errors: [], collectedObjects, importIdMap: new Map(), }); - getMockFn(createSavedObjects).mockResolvedValue({ + mockCreateSavedObjects.mockResolvedValue({ errors: [], createdObjects: collectedObjects, }); await importSavedObjectsFromStream(options); - expect(executeImportHooks).toHaveBeenCalledWith({ + expect(mockExecuteImportHooks).toHaveBeenCalledWith({ objects: collectedObjects, importHooks, }); @@ -192,20 +181,20 @@ describe('#importSavedObjectsFromStream', () => { test('does not regenerate object IDs', async () => { const options = setupOptions(); const collectedObjects = [createObject()]; - getMockFn(collectSavedObjects).mockResolvedValue({ + mockCollectSavedObjects.mockResolvedValue({ errors: [], collectedObjects, importIdMap: new Map(), }); await importSavedObjectsFromStream(options); - expect(regenerateIds).not.toHaveBeenCalled(); + expect(mockRegenerateIds).not.toHaveBeenCalled(); }); test('checks conflicts', async () => { const options = setupOptions(); const collectedObjects = [createObject()]; - getMockFn(collectSavedObjects).mockResolvedValue({ + mockCollectSavedObjects.mockResolvedValue({ errors: [], collectedObjects, importIdMap: new Map(), @@ -218,14 +207,14 @@ describe('#importSavedObjectsFromStream', () => { namespace, ignoreRegularConflicts: overwrite, }; - expect(checkConflicts).toHaveBeenCalledWith(checkConflictsParams); + expect(mockCheckConflicts).toHaveBeenCalledWith(checkConflictsParams); }); test('checks origin conflicts', async () => { const options = setupOptions(); const filteredObjects = [createObject()]; const importIdMap = new Map(); - getMockFn(checkConflicts).mockResolvedValue({ + mockCheckConflicts.mockResolvedValue({ errors: [], filteredObjects, importIdMap, @@ -241,7 +230,7 @@ describe('#importSavedObjectsFromStream', () => { ignoreRegularConflicts: overwrite, importIdMap, }; - expect(checkOriginConflicts).toHaveBeenCalledWith(checkOriginConflictsParams); + expect(mockCheckOriginConflicts).toHaveBeenCalledWith(checkOriginConflictsParams); }); test('creates saved objects', async () => { @@ -249,7 +238,7 @@ describe('#importSavedObjectsFromStream', () => { const collectedObjects = [createObject()]; const filteredObjects = [createObject()]; const errors = [createError(), createError(), createError(), createError()]; - getMockFn(collectSavedObjects).mockResolvedValue({ + mockCollectSavedObjects.mockResolvedValue({ errors: [errors[0]], collectedObjects, importIdMap: new Map([ @@ -258,14 +247,14 @@ describe('#importSavedObjectsFromStream', () => { ['baz', {}], ]), }); - getMockFn(validateReferences).mockResolvedValue([errors[1]]); - getMockFn(checkConflicts).mockResolvedValue({ + mockValidateReferences.mockResolvedValue([errors[1]]); + mockCheckConflicts.mockResolvedValue({ errors: [errors[2]], filteredObjects, importIdMap: new Map([['bar', { id: 'newId1' }]]), pendingOverwrites: new Set(), }); - getMockFn(checkOriginConflicts).mockResolvedValue({ + mockCheckOriginConflicts.mockResolvedValue({ errors: [errors[3]], importIdMap: new Map([['baz', { id: 'newId2' }]]), pendingOverwrites: new Set(), @@ -285,7 +274,7 @@ describe('#importSavedObjectsFromStream', () => { overwrite, namespace, }; - expect(createSavedObjects).toHaveBeenCalledWith(createSavedObjectsParams); + expect(mockCreateSavedObjects).toHaveBeenCalledWith(createSavedObjectsParams); }); }); @@ -293,30 +282,30 @@ describe('#importSavedObjectsFromStream', () => { test('regenerates object IDs', async () => { const options = setupOptions({ createNewCopies: true }); const collectedObjects = [createObject()]; - getMockFn(collectSavedObjects).mockResolvedValue({ + mockCollectSavedObjects.mockResolvedValue({ errors: [], collectedObjects, importIdMap: new Map(), // doesn't matter }); await importSavedObjectsFromStream(options); - expect(regenerateIds).toHaveBeenCalledWith(collectedObjects); + expect(mockRegenerateIds).toHaveBeenCalledWith(collectedObjects); }); test('does not check conflicts or check origin conflicts', async () => { const options = setupOptions({ createNewCopies: true }); - getMockFn(validateReferences).mockResolvedValue([]); + mockValidateReferences.mockResolvedValue([]); await importSavedObjectsFromStream(options); - expect(checkConflicts).not.toHaveBeenCalled(); - expect(checkOriginConflicts).not.toHaveBeenCalled(); + expect(mockCheckConflicts).not.toHaveBeenCalled(); + expect(mockCheckOriginConflicts).not.toHaveBeenCalled(); }); test('creates saved objects', async () => { const options = setupOptions({ createNewCopies: true }); const collectedObjects = [createObject()]; const errors = [createError(), createError()]; - getMockFn(collectSavedObjects).mockResolvedValue({ + mockCollectSavedObjects.mockResolvedValue({ errors: [errors[0]], collectedObjects, importIdMap: new Map([ @@ -324,10 +313,10 @@ describe('#importSavedObjectsFromStream', () => { ['bar', {}], ]), }); - getMockFn(validateReferences).mockResolvedValue([errors[1]]); + mockValidateReferences.mockResolvedValue([errors[1]]); // this importIdMap is not composed with the one obtained from `collectSavedObjects` const importIdMap = new Map().set(`id1`, { id: `newId1` }); - getMockFn(regenerateIds).mockReturnValue(importIdMap); + mockRegenerateIds.mockReturnValue(importIdMap); await importSavedObjectsFromStream(options); const createSavedObjectsParams = { @@ -338,7 +327,7 @@ describe('#importSavedObjectsFromStream', () => { overwrite, namespace, }; - expect(createSavedObjects).toHaveBeenCalledWith(createSavedObjectsParams); + expect(mockCreateSavedObjects).toHaveBeenCalledWith(createSavedObjectsParams); }); }); }); @@ -353,7 +342,7 @@ describe('#importSavedObjectsFromStream', () => { test('returns success=false if an error occurred', async () => { const options = setupOptions(); - getMockFn(collectSavedObjects).mockResolvedValue({ + mockCollectSavedObjects.mockResolvedValue({ errors: [createError()], collectedObjects: [], importIdMap: new Map(), // doesn't matter @@ -371,18 +360,18 @@ describe('#importSavedObjectsFromStream', () => { test('returns warnings from the import hooks', async () => { const options = setupOptions(); const collectedObjects = [createObject()]; - getMockFn(collectSavedObjects).mockResolvedValue({ + mockCollectSavedObjects.mockResolvedValue({ errors: [], collectedObjects, importIdMap: new Map(), }); - getMockFn(createSavedObjects).mockResolvedValue({ + mockCreateSavedObjects.mockResolvedValue({ errors: [], createdObjects: collectedObjects, }); const warnings: SavedObjectsImportWarning[] = [{ type: 'simple', message: 'foo' }]; - getMockFn(executeImportHooks).mockResolvedValue(warnings); + mockExecuteImportHooks.mockResolvedValue(warnings); const result = await importSavedObjectsFromStream(options); @@ -419,7 +408,7 @@ describe('#importSavedObjectsFromStream', () => { test('with createNewCopies disabled', async () => { const options = setupOptions(); - getMockFn(checkConflicts).mockResolvedValue({ + mockCheckConflicts.mockResolvedValue({ errors: [], filteredObjects: [], importIdMap: new Map(), @@ -428,7 +417,7 @@ describe('#importSavedObjectsFromStream', () => { `${error2.type}:${error2.id}`, // an attempt was made to overwrite the error2 object ]), }); - getMockFn(createSavedObjects).mockResolvedValue({ errors, createdObjects }); + mockCreateSavedObjects.mockResolvedValue({ errors, createdObjects }); const result = await importSavedObjectsFromStream(options); // successResults only includes the imported object's type, id, and destinationId (if a new one was generated) @@ -457,7 +446,7 @@ describe('#importSavedObjectsFromStream', () => { test('with createNewCopies enabled', async () => { // however, we include it here for posterity const options = setupOptions({ createNewCopies: true }); - getMockFn(createSavedObjects).mockResolvedValue({ errors, createdObjects }); + mockCreateSavedObjects.mockResolvedValue({ errors, createdObjects }); const result = await importSavedObjectsFromStream(options); // successResults only includes the imported object's type, id, and destinationId (if a new one was generated) @@ -495,13 +484,13 @@ describe('#importSavedObjectsFromStream', () => { }, }); - getMockFn(checkConflicts).mockResolvedValue({ + mockCheckConflicts.mockResolvedValue({ errors: [], filteredObjects: [], importIdMap: new Map(), pendingOverwrites: new Set(), }); - getMockFn(createSavedObjects).mockResolvedValue({ errors: [], createdObjects: [obj1, obj2] }); + mockCreateSavedObjects.mockResolvedValue({ errors: [], createdObjects: [obj1, obj2] }); const result = await importSavedObjectsFromStream(options); // successResults only includes the imported object's type, id, and destinationId (if a new one was generated) @@ -529,24 +518,24 @@ describe('#importSavedObjectsFromStream', () => { test('accumulates multiple errors', async () => { const options = setupOptions(); const errors = [createError(), createError(), createError(), createError(), createError()]; - getMockFn(collectSavedObjects).mockResolvedValue({ + mockCollectSavedObjects.mockResolvedValue({ errors: [errors[0]], collectedObjects: [], importIdMap: new Map(), // doesn't matter }); - getMockFn(validateReferences).mockResolvedValue([errors[1]]); - getMockFn(checkConflicts).mockResolvedValue({ + mockValidateReferences.mockResolvedValue([errors[1]]); + mockCheckConflicts.mockResolvedValue({ errors: [errors[2]], filteredObjects: [], importIdMap: new Map(), // doesn't matter pendingOverwrites: new Set(), }); - getMockFn(checkOriginConflicts).mockResolvedValue({ + mockCheckOriginConflicts.mockResolvedValue({ errors: [errors[3]], importIdMap: new Map(), // doesn't matter pendingOverwrites: new Set(), }); - getMockFn(createSavedObjects).mockResolvedValue({ errors: [errors[4]], createdObjects: [] }); + mockCreateSavedObjects.mockResolvedValue({ errors: [errors[4]], createdObjects: [] }); const result = await importSavedObjectsFromStream(options); const expectedErrors = errors.map(({ type, id }) => expect.objectContaining({ type, id })); diff --git a/src/core/server/saved_objects/import/lib/__mocks__/index.ts b/src/core/server/saved_objects/import/lib/__mocks__/index.ts deleted file mode 100644 index c53fc78c8e887..0000000000000 --- a/src/core/server/saved_objects/import/lib/__mocks__/index.ts +++ /dev/null @@ -1,14 +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. - */ - -const mockUuidv4 = jest.fn().mockReturnValue('uuidv4'); -jest.mock('uuid', () => ({ - v4: mockUuidv4, -})); - -export { mockUuidv4 }; diff --git a/src/core/server/saved_objects/import/lib/check_conflicts.test.ts b/src/core/server/saved_objects/import/lib/check_conflicts.test.ts index 6ab37b0122e4b..b1adf0f574db6 100644 --- a/src/core/server/saved_objects/import/lib/check_conflicts.test.ts +++ b/src/core/server/saved_objects/import/lib/check_conflicts.test.ts @@ -6,13 +6,16 @@ * Side Public License, v 1. */ -import { mockUuidv4 } from './__mocks__'; import { savedObjectsClientMock } from '../../../mocks'; import { SavedObjectReference, SavedObjectsImportRetry } from 'kibana/public'; import { SavedObjectsClientContract, SavedObject } from '../../types'; import { SavedObjectsErrorHelpers } from '../../service'; import { checkConflicts } from './check_conflicts'; +jest.mock('uuid', () => ({ + v4: () => 'uuidv4', +})); + type SavedObjectType = SavedObject<{ title?: string }>; type CheckConflictsParams = Parameters[0]; @@ -71,11 +74,6 @@ describe('#checkConflicts', () => { return { ...partial, savedObjectsClient }; }; - beforeEach(() => { - mockUuidv4.mockReset(); - mockUuidv4.mockReturnValueOnce(`new-object-id`); - }); - it('exits early if there are no objects to check', async () => { const namespace = 'foo-namespace'; const params = setupParams({ objects: [], namespace }); @@ -121,7 +119,7 @@ describe('#checkConflicts', () => { error: { ...obj4Error.error, type: 'unknown' }, }, ], - importIdMap: new Map([[`${obj3.type}:${obj3.id}`, { id: `new-object-id` }]]), + importIdMap: new Map([[`${obj3.type}:${obj3.id}`, { id: 'uuidv4' }]]), pendingOverwrites: new Set(), }); }); @@ -187,9 +185,7 @@ describe('#checkConflicts', () => { error: { ...obj4Error.error, type: 'unknown' }, }, ], - importIdMap: new Map([ - [`${obj3.type}:${obj3.id}`, { id: `new-object-id`, omitOriginId: true }], - ]), + importIdMap: new Map([[`${obj3.type}:${obj3.id}`, { id: 'uuidv4', omitOriginId: true }]]), pendingOverwrites: new Set([`${obj5.type}:${obj5.id}`]), }); }); @@ -202,9 +198,7 @@ describe('#checkConflicts', () => { const checkConflictsResult = await checkConflicts(params); expect(checkConflictsResult).toEqual( expect.objectContaining({ - importIdMap: new Map([ - [`${obj3.type}:${obj3.id}`, { id: `new-object-id`, omitOriginId: true }], - ]), + importIdMap: new Map([[`${obj3.type}:${obj3.id}`, { id: 'uuidv4', omitOriginId: true }]]), }) ); }); diff --git a/src/core/server/saved_objects/import/lib/check_origin_conflicts.test.ts b/src/core/server/saved_objects/import/lib/check_origin_conflicts.test.ts index 03d94492e7ec8..fc2c6dde3f8fd 100644 --- a/src/core/server/saved_objects/import/lib/check_origin_conflicts.test.ts +++ b/src/core/server/saved_objects/import/lib/check_origin_conflicts.test.ts @@ -6,7 +6,6 @@ * Side Public License, v 1. */ -import { mockUuidv4 } from './__mocks__'; import { SavedObjectsClientContract, SavedObjectReference, @@ -19,6 +18,10 @@ import { savedObjectsClientMock } from '../../../mocks'; import { typeRegistryMock } from '../../saved_objects_type_registry.mock'; import { ISavedObjectTypeRegistry } from '../../saved_objects_type_registry'; +jest.mock('uuid', () => ({ + v4: () => 'uuidv4', +})); + type SavedObjectType = SavedObject<{ title?: string }>; type CheckOriginConflictsParams = Parameters[0]; @@ -42,10 +45,6 @@ const createObject = ( const MULTI_NS_TYPE = 'multi'; const OTHER_TYPE = 'other'; -beforeEach(() => { - mockUuidv4.mockClear(); -}); - describe('#checkOriginConflicts', () => { let savedObjectsClient: jest.Mocked; let typeRegistry: jest.Mocked; @@ -386,7 +385,6 @@ describe('#checkOriginConflicts', () => { errors: [], pendingOverwrites: new Set(), }; - expect(mockUuidv4).toHaveBeenCalledTimes(4); expect(checkOriginConflictsResult).toEqual(expectedResult); }); @@ -413,7 +411,6 @@ describe('#checkOriginConflicts', () => { ], pendingOverwrites: new Set(), }; - expect(mockUuidv4).not.toHaveBeenCalled(); expect(checkOriginConflictsResult).toEqual(expectedResult); }); @@ -446,7 +443,6 @@ describe('#checkOriginConflicts', () => { errors: [], pendingOverwrites: new Set(), }; - expect(mockUuidv4).toHaveBeenCalledTimes(4); expect(checkOriginConflictsResult).toEqual(expectedResult); }); }); @@ -498,7 +494,6 @@ describe('#checkOriginConflicts', () => { ], pendingOverwrites: new Set(), }; - expect(mockUuidv4).toHaveBeenCalledTimes(2); expect(checkOriginConflictsResult).toEqual(expectedResult); }); @@ -514,7 +509,6 @@ describe('#checkOriginConflicts', () => { errors: [createAmbiguousConflictError(obj6, [objB, objC])], pendingOverwrites: new Set([`${obj5.type}:${obj5.id}`]), }; - expect(mockUuidv4).toHaveBeenCalledTimes(2); expect(checkOriginConflictsResult).toEqual(expectedResult); }); }); diff --git a/src/core/server/saved_objects/import/lib/create_saved_objects.ts b/src/core/server/saved_objects/import/lib/create_saved_objects.ts index 66792642ea24e..30a3b4df96125 100644 --- a/src/core/server/saved_objects/import/lib/create_saved_objects.ts +++ b/src/core/server/saved_objects/import/lib/create_saved_objects.ts @@ -10,7 +10,7 @@ import { SavedObject, SavedObjectsClientContract, SavedObjectsImportFailure } fr import { extractErrors } from './extract_errors'; import { CreatedObject } from '../types'; -interface CreateSavedObjectsParams { +export interface CreateSavedObjectsParams { objects: Array>; accumulatedErrors: SavedObjectsImportFailure[]; savedObjectsClient: SavedObjectsClientContract; @@ -18,7 +18,7 @@ interface CreateSavedObjectsParams { namespace?: string; overwrite?: boolean; } -interface CreateSavedObjectsResult { +export interface CreateSavedObjectsResult { createdObjects: Array>; errors: SavedObjectsImportFailure[]; } diff --git a/src/core/server/saved_objects/import/lib/execute_import_hooks.ts b/src/core/server/saved_objects/import/lib/execute_import_hooks.ts index 1595d52ca8c0e..c0b5ae0437b2c 100644 --- a/src/core/server/saved_objects/import/lib/execute_import_hooks.ts +++ b/src/core/server/saved_objects/import/lib/execute_import_hooks.ts @@ -9,7 +9,7 @@ import { SavedObject } from '../../types'; import { SavedObjectsImportHook, SavedObjectsImportWarning } from '../types'; -interface ExecuteImportHooksOptions { +export interface ExecuteImportHooksOptions { objects: SavedObject[]; importHooks: Record; } diff --git a/src/core/server/saved_objects/import/lib/regenerate_ids.test.ts b/src/core/server/saved_objects/import/lib/regenerate_ids.test.ts index 2696a52e0554f..1cc14d9cfa428 100644 --- a/src/core/server/saved_objects/import/lib/regenerate_ids.test.ts +++ b/src/core/server/saved_objects/import/lib/regenerate_ids.test.ts @@ -6,37 +6,31 @@ * Side Public License, v 1. */ -import { mockUuidv4 } from './__mocks__'; import { regenerateIds } from './regenerate_ids'; import { SavedObject } from '../../types'; +jest.mock('uuid', () => ({ + v4: jest + .fn() + .mockReturnValueOnce('uuidv4 #1') + .mockReturnValueOnce('uuidv4 #2') + .mockReturnValueOnce('uuidv4 #3'), +})); + describe('#regenerateIds', () => { const objects = [ { type: 'foo', id: '1' }, { type: 'bar', id: '2' }, { type: 'baz', id: '3' }, - ] as any as SavedObject[]; + ] as SavedObject[]; test('returns expected values', () => { - mockUuidv4 - .mockReturnValueOnce('uuidv4 #1') - .mockReturnValueOnce('uuidv4 #2') - .mockReturnValueOnce('uuidv4 #3'); - expect(regenerateIds(objects)).toMatchInlineSnapshot(` - Map { - "foo:1" => Object { - "id": "uuidv4 #1", - "omitOriginId": true, - }, - "bar:2" => Object { - "id": "uuidv4 #2", - "omitOriginId": true, - }, - "baz:3" => Object { - "id": "uuidv4 #3", - "omitOriginId": true, - }, - } - `); + expect(regenerateIds(objects)).toEqual( + new Map([ + ['foo:1', { id: 'uuidv4 #1', omitOriginId: true }], + ['bar:2', { id: 'uuidv4 #2', omitOriginId: true }], + ['baz:3', { id: 'uuidv4 #3', omitOriginId: true }], + ]) + ); }); }); diff --git a/src/core/server/saved_objects/import/resolve_import_errors.test.mock.ts b/src/core/server/saved_objects/import/resolve_import_errors.test.mock.ts new file mode 100644 index 0000000000000..02e6b423d9f89 --- /dev/null +++ b/src/core/server/saved_objects/import/resolve_import_errors.test.mock.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { validateRetries } from './lib/validate_retries'; +import type { createObjectsFilter } from './lib/create_objects_filter'; +import type { collectSavedObjects } from './lib/collect_saved_objects'; +import type { regenerateIds } from './lib/regenerate_ids'; +import type { validateReferences } from './lib/validate_references'; +import type { checkConflicts } from './lib/check_conflicts'; +import type { getImportIdMapForRetries } from './lib/check_origin_conflicts'; +import type { splitOverwrites } from './lib/split_overwrites'; +import type { createSavedObjects } from './lib/create_saved_objects'; +import type { executeImportHooks } from './lib/execute_import_hooks'; + +export const mockValidateRetries = jest.fn() as jest.MockedFunction; +jest.mock('./lib/validate_retries', () => ({ + validateRetries: mockValidateRetries, +})); + +export const mockCreateObjectsFilter = jest.fn() as jest.MockedFunction; +jest.mock('./lib/create_objects_filter', () => ({ + createObjectsFilter: mockCreateObjectsFilter, +})); + +export const mockCollectSavedObjects = jest.fn() as jest.MockedFunction; +jest.mock('./lib/collect_saved_objects', () => ({ + collectSavedObjects: mockCollectSavedObjects, +})); + +export const mockRegenerateIds = jest.fn() as jest.MockedFunction; +jest.mock('./lib/regenerate_ids', () => ({ + regenerateIds: mockRegenerateIds, +})); + +export const mockValidateReferences = jest.fn() as jest.MockedFunction; +jest.mock('./lib/validate_references', () => ({ + validateReferences: mockValidateReferences, +})); + +export const mockCheckConflicts = jest.fn() as jest.MockedFunction; +jest.mock('./lib/check_conflicts', () => ({ + checkConflicts: mockCheckConflicts, +})); + +export const mockGetImportIdMapForRetries = jest.fn() as jest.MockedFunction< + typeof getImportIdMapForRetries +>; +jest.mock('./lib/check_origin_conflicts', () => ({ + getImportIdMapForRetries: mockGetImportIdMapForRetries, +})); + +export const mockSplitOverwrites = jest.fn() as jest.MockedFunction; +jest.mock('./lib/split_overwrites', () => ({ + splitOverwrites: mockSplitOverwrites, +})); + +export const mockCreateSavedObjects = jest.fn() as jest.MockedFunction; +jest.mock('./lib/create_saved_objects', () => ({ + createSavedObjects: mockCreateSavedObjects, +})); + +export const mockExecuteImportHooks = jest.fn() as jest.MockedFunction; +jest.mock('./lib/execute_import_hooks', () => ({ + executeImportHooks: mockExecuteImportHooks, +})); diff --git a/src/core/server/saved_objects/import/resolve_import_errors.test.ts b/src/core/server/saved_objects/import/resolve_import_errors.test.ts index d7d7544baafcb..9fc5a2c55ac90 100644 --- a/src/core/server/saved_objects/import/resolve_import_errors.test.ts +++ b/src/core/server/saved_objects/import/resolve_import_errors.test.ts @@ -6,6 +6,19 @@ * Side Public License, v 1. */ +import { + mockValidateRetries, + mockCreateObjectsFilter, + mockCollectSavedObjects, + mockRegenerateIds, + mockValidateReferences, + mockCheckConflicts, + mockGetImportIdMapForRetries, + mockSplitOverwrites, + mockCreateSavedObjects, + mockExecuteImportHooks, +} from './resolve_import_errors.test.mock'; + import { Readable } from 'stream'; import { v4 as uuidv4 } from 'uuid'; import { @@ -25,58 +38,31 @@ import { ResolveSavedObjectsImportErrorsOptions, } from './resolve_import_errors'; -import { - validateRetries, - collectSavedObjects, - regenerateIds, - validateReferences, - checkConflicts, - getImportIdMapForRetries, - splitOverwrites, - createSavedObjects, - createObjectsFilter, - executeImportHooks, -} from './lib'; - -jest.mock('./lib/validate_retries'); -jest.mock('./lib/create_objects_filter'); -jest.mock('./lib/collect_saved_objects'); -jest.mock('./lib/regenerate_ids'); -jest.mock('./lib/validate_references'); -jest.mock('./lib/check_conflicts'); -jest.mock('./lib/check_origin_conflicts'); -jest.mock('./lib/split_overwrites'); -jest.mock('./lib/create_saved_objects'); -jest.mock('./lib/execute_import_hooks'); - -const getMockFn = any, U>(fn: (...args: Parameters) => U) => - fn as jest.MockedFunction<(...args: Parameters) => U>; - describe('#importSavedObjectsFromStream', () => { beforeEach(() => { jest.clearAllMocks(); // mock empty output of each of these mocked modules so the import doesn't throw an error - getMockFn(createObjectsFilter).mockReturnValue(() => false); - getMockFn(collectSavedObjects).mockResolvedValue({ + mockCreateObjectsFilter.mockReturnValue(() => false); + mockCollectSavedObjects.mockResolvedValue({ errors: [], collectedObjects: [], importIdMap: new Map(), }); - getMockFn(regenerateIds).mockReturnValue(new Map()); - getMockFn(validateReferences).mockResolvedValue([]); - getMockFn(checkConflicts).mockResolvedValue({ + mockRegenerateIds.mockReturnValue(new Map()); + mockValidateReferences.mockResolvedValue([]); + mockCheckConflicts.mockResolvedValue({ errors: [], filteredObjects: [], importIdMap: new Map(), pendingOverwrites: new Set(), // not used by resolveImportErrors, but is a required return type }); - getMockFn(getImportIdMapForRetries).mockReturnValue(new Map()); - getMockFn(splitOverwrites).mockReturnValue({ + mockGetImportIdMapForRetries.mockReturnValue(new Map()); + mockSplitOverwrites.mockReturnValue({ objectsToOverwrite: [], objectsToNotOverwrite: [], }); - getMockFn(createSavedObjects).mockResolvedValue({ errors: [], createdObjects: [] }); - getMockFn(executeImportHooks).mockResolvedValue([]); + mockCreateSavedObjects.mockResolvedValue({ errors: [], createdObjects: [] }); + mockExecuteImportHooks.mockResolvedValue([]); }); let readStream: Readable; @@ -162,7 +148,7 @@ describe('#importSavedObjectsFromStream', () => { const options = setupOptions({ retries: [retry] }); await resolveSavedObjectsImportErrors(options); - expect(validateRetries).toHaveBeenCalledWith([retry]); + expect(mockValidateRetries).toHaveBeenCalledWith([retry]); }); test('creates objects filter', async () => { @@ -170,7 +156,7 @@ describe('#importSavedObjectsFromStream', () => { const options = setupOptions({ retries: [retry] }); await resolveSavedObjectsImportErrors(options); - expect(createObjectsFilter).toHaveBeenCalledWith([retry]); + expect(mockCreateObjectsFilter).toHaveBeenCalledWith([retry]); }); test('collects saved objects from stream', async () => { @@ -182,23 +168,23 @@ describe('#importSavedObjectsFromStream', () => { await resolveSavedObjectsImportErrors(options); expect(typeRegistry.getImportableAndExportableTypes).toHaveBeenCalled(); - const filter = getMockFn(createObjectsFilter).mock.results[0].value; - const collectSavedObjectsOptions = { readStream, objectLimit, filter, supportedTypes }; - expect(collectSavedObjects).toHaveBeenCalledWith(collectSavedObjectsOptions); + const filter = mockCreateObjectsFilter.mock.results[0].value; + const mockCollectSavedObjectsOptions = { readStream, objectLimit, filter, supportedTypes }; + expect(mockCollectSavedObjects).toHaveBeenCalledWith(mockCollectSavedObjectsOptions); }); test('validates references', async () => { const retries = [createRetry()]; const options = setupOptions({ retries }); const collectedObjects = [createObject()]; - getMockFn(collectSavedObjects).mockResolvedValue({ + mockCollectSavedObjects.mockResolvedValue({ errors: [], collectedObjects, importIdMap: new Map(), // doesn't matter }); await resolveSavedObjectsImportErrors(options); - expect(validateReferences).toHaveBeenCalledWith( + expect(mockValidateReferences).toHaveBeenCalledWith( collectedObjects, savedObjectsClient, namespace, @@ -212,19 +198,19 @@ describe('#importSavedObjectsFromStream', () => { }; const options = setupOptions({ importHooks }); const collectedObjects = [createObject()]; - getMockFn(collectSavedObjects).mockResolvedValue({ + mockCollectSavedObjects.mockResolvedValue({ errors: [], collectedObjects, importIdMap: new Map(), }); - getMockFn(createSavedObjects).mockResolvedValueOnce({ + mockCreateSavedObjects.mockResolvedValueOnce({ errors: [], createdObjects: collectedObjects, }); await resolveSavedObjectsImportErrors(options); - expect(executeImportHooks).toHaveBeenCalledWith({ + expect(mockExecuteImportHooks).toHaveBeenCalledWith({ objects: collectedObjects, importHooks, }); @@ -239,7 +225,7 @@ describe('#importSavedObjectsFromStream', () => { }), ]; const options = setupOptions({ retries }); - getMockFn(collectSavedObjects).mockResolvedValue({ + mockCollectSavedObjects.mockResolvedValue({ errors: [], collectedObjects: [object], importIdMap: new Map(), // doesn't matter @@ -250,7 +236,7 @@ describe('#importSavedObjectsFromStream', () => { ...object, references: [{ ...object.references[0], id: 'def' }], }; - expect(validateReferences).toHaveBeenCalledWith( + expect(mockValidateReferences).toHaveBeenCalledWith( [objectWithReplacedReferences], savedObjectsClient, namespace, @@ -263,7 +249,7 @@ describe('#importSavedObjectsFromStream', () => { const retries = [createRetry()]; const options = setupOptions({ retries, createNewCopies }); const collectedObjects = [createObject()]; - getMockFn(collectSavedObjects).mockResolvedValue({ + mockCollectSavedObjects.mockResolvedValue({ errors: [], collectedObjects, importIdMap: new Map(), // doesn't matter @@ -277,7 +263,7 @@ describe('#importSavedObjectsFromStream', () => { retries, createNewCopies, }; - expect(checkConflicts).toHaveBeenCalledWith(checkConflictsParams); + expect(mockCheckConflicts).toHaveBeenCalledWith(checkConflictsParams); }); test('gets import ID map for retries', async () => { @@ -285,7 +271,7 @@ describe('#importSavedObjectsFromStream', () => { const createNewCopies = Symbol() as unknown as boolean; const options = setupOptions({ retries, createNewCopies }); const filteredObjects = [createObject()]; - getMockFn(checkConflicts).mockResolvedValue({ + mockCheckConflicts.mockResolvedValue({ errors: [], filteredObjects, importIdMap: new Map(), @@ -294,53 +280,53 @@ describe('#importSavedObjectsFromStream', () => { await resolveSavedObjectsImportErrors(options); const getImportIdMapForRetriesParams = { objects: filteredObjects, retries, createNewCopies }; - expect(getImportIdMapForRetries).toHaveBeenCalledWith(getImportIdMapForRetriesParams); + expect(mockGetImportIdMapForRetries).toHaveBeenCalledWith(getImportIdMapForRetriesParams); }); test('splits objects to overwrite from those not to overwrite', async () => { const retries = [createRetry()]; const options = setupOptions({ retries }); const collectedObjects = [createObject()]; - getMockFn(collectSavedObjects).mockResolvedValue({ + mockCollectSavedObjects.mockResolvedValue({ errors: [], collectedObjects, importIdMap: new Map(), // doesn't matter }); await resolveSavedObjectsImportErrors(options); - expect(splitOverwrites).toHaveBeenCalledWith(collectedObjects, retries); + expect(mockSplitOverwrites).toHaveBeenCalledWith(collectedObjects, retries); }); describe('with createNewCopies disabled', () => { test('does not regenerate object IDs', async () => { const options = setupOptions(); const collectedObjects = [createObject()]; - getMockFn(collectSavedObjects).mockResolvedValue({ + mockCollectSavedObjects.mockResolvedValue({ errors: [], collectedObjects, importIdMap: new Map(), // doesn't matter }); await resolveSavedObjectsImportErrors(options); - expect(regenerateIds).not.toHaveBeenCalled(); + expect(mockRegenerateIds).not.toHaveBeenCalled(); }); test('creates saved objects', async () => { const options = setupOptions(); const errors = [createError(), createError(), createError()]; - getMockFn(collectSavedObjects).mockResolvedValue({ + mockCollectSavedObjects.mockResolvedValue({ errors: [errors[0]], collectedObjects: [], // doesn't matter importIdMap: new Map(), // doesn't matter }); - getMockFn(validateReferences).mockResolvedValue([errors[1]]); - getMockFn(checkConflicts).mockResolvedValue({ + mockValidateReferences.mockResolvedValue([errors[1]]); + mockCheckConflicts.mockResolvedValue({ errors: [errors[2]], filteredObjects: [], importIdMap: new Map([['foo', { id: 'someId' }]]), pendingOverwrites: new Set(), // not used by resolveImportErrors, but is a required return type }); - getMockFn(getImportIdMapForRetries).mockReturnValue( + mockGetImportIdMapForRetries.mockReturnValue( new Map([ ['foo', { id: 'newId' }], ['bar', { id: 'anotherNewId' }], @@ -352,9 +338,9 @@ describe('#importSavedObjectsFromStream', () => { ]); const objectsToOverwrite = [createObject()]; const objectsToNotOverwrite = [createObject()]; - getMockFn(splitOverwrites).mockReturnValue({ objectsToOverwrite, objectsToNotOverwrite }); - getMockFn(createSavedObjects).mockResolvedValueOnce({ - errors: [createError()], // this error will NOT be passed to the second `createSavedObjects` call + mockSplitOverwrites.mockReturnValue({ objectsToOverwrite, objectsToNotOverwrite }); + mockCreateSavedObjects.mockResolvedValueOnce({ + errors: [createError()], // this error will NOT be passed to the second `mockCreateSavedObjects` call createdObjects: [], }); @@ -365,12 +351,12 @@ describe('#importSavedObjectsFromStream', () => { importIdMap, namespace, }; - expect(createSavedObjects).toHaveBeenNthCalledWith(1, { + expect(mockCreateSavedObjects).toHaveBeenNthCalledWith(1, { ...partialCreateSavedObjectsParams, objects: objectsToOverwrite, overwrite: true, }); - expect(createSavedObjects).toHaveBeenNthCalledWith(2, { + expect(mockCreateSavedObjects).toHaveBeenNthCalledWith(2, { ...partialCreateSavedObjectsParams, objects: objectsToNotOverwrite, }); @@ -381,39 +367,39 @@ describe('#importSavedObjectsFromStream', () => { test('regenerates object IDs', async () => { const options = setupOptions({ createNewCopies: true }); const collectedObjects = [createObject()]; - getMockFn(collectSavedObjects).mockResolvedValue({ + mockCollectSavedObjects.mockResolvedValue({ errors: [], collectedObjects, importIdMap: new Map(), // doesn't matter }); await resolveSavedObjectsImportErrors(options); - expect(regenerateIds).toHaveBeenCalledWith(collectedObjects); + expect(mockRegenerateIds).toHaveBeenCalledWith(collectedObjects); }); test('creates saved objects', async () => { const options = setupOptions({ createNewCopies: true }); const errors = [createError(), createError(), createError()]; - getMockFn(collectSavedObjects).mockResolvedValue({ + mockCollectSavedObjects.mockResolvedValue({ errors: [errors[0]], collectedObjects: [], // doesn't matter importIdMap: new Map(), // doesn't matter }); - getMockFn(validateReferences).mockResolvedValue([errors[1]]); - getMockFn(regenerateIds).mockReturnValue( + mockValidateReferences.mockResolvedValue([errors[1]]); + mockRegenerateIds.mockReturnValue( new Map([ ['foo', { id: 'randomId1' }], ['bar', { id: 'randomId2' }], ['baz', { id: 'randomId3' }], ]) ); - getMockFn(checkConflicts).mockResolvedValue({ + mockCheckConflicts.mockResolvedValue({ errors: [errors[2]], filteredObjects: [], importIdMap: new Map([['bar', { id: 'someId' }]]), pendingOverwrites: new Set(), // not used by resolveImportErrors, but is a required return type }); - getMockFn(getImportIdMapForRetries).mockReturnValue( + mockGetImportIdMapForRetries.mockReturnValue( new Map([ ['bar', { id: 'newId' }], ['baz', { id: 'anotherNewId' }], @@ -426,9 +412,9 @@ describe('#importSavedObjectsFromStream', () => { ]); const objectsToOverwrite = [createObject()]; const objectsToNotOverwrite = [createObject()]; - getMockFn(splitOverwrites).mockReturnValue({ objectsToOverwrite, objectsToNotOverwrite }); - getMockFn(createSavedObjects).mockResolvedValueOnce({ - errors: [createError()], // this error will NOT be passed to the second `createSavedObjects` call + mockSplitOverwrites.mockReturnValue({ objectsToOverwrite, objectsToNotOverwrite }); + mockCreateSavedObjects.mockResolvedValueOnce({ + errors: [createError()], // this error will NOT be passed to the second `mockCreateSavedObjects` call createdObjects: [], }); @@ -439,12 +425,12 @@ describe('#importSavedObjectsFromStream', () => { importIdMap, namespace, }; - expect(createSavedObjects).toHaveBeenNthCalledWith(1, { + expect(mockCreateSavedObjects).toHaveBeenNthCalledWith(1, { ...partialCreateSavedObjectsParams, objects: objectsToOverwrite, overwrite: true, }); - expect(createSavedObjects).toHaveBeenNthCalledWith(2, { + expect(mockCreateSavedObjects).toHaveBeenNthCalledWith(2, { ...partialCreateSavedObjectsParams, objects: objectsToNotOverwrite, }); @@ -462,7 +448,7 @@ describe('#importSavedObjectsFromStream', () => { test('returns success=false if an error occurred', async () => { const options = setupOptions(); - getMockFn(collectSavedObjects).mockResolvedValue({ + mockCollectSavedObjects.mockResolvedValue({ errors: [createError()], collectedObjects: [], importIdMap: new Map(), // doesn't matter @@ -480,17 +466,17 @@ describe('#importSavedObjectsFromStream', () => { test('executes import hooks', async () => { const options = setupOptions(); const collectedObjects = [createObject()]; - getMockFn(collectSavedObjects).mockResolvedValue({ + mockCollectSavedObjects.mockResolvedValue({ errors: [], collectedObjects, importIdMap: new Map(), }); - getMockFn(createSavedObjects).mockResolvedValueOnce({ + mockCreateSavedObjects.mockResolvedValueOnce({ errors: [], createdObjects: collectedObjects, }); const warnings: SavedObjectsImportWarning[] = [{ type: 'simple', message: 'foo' }]; - getMockFn(executeImportHooks).mockResolvedValue(warnings); + mockExecuteImportHooks.mockResolvedValue(warnings); const result = await resolveSavedObjectsImportErrors(options); @@ -507,11 +493,11 @@ describe('#importSavedObjectsFromStream', () => { const tmp = createObject(); const obj2 = { ...tmp, destinationId: 'some-destinationId', originId: tmp.id }; const obj3 = { ...createObject(), destinationId: 'another-destinationId' }; // empty originId; this is a new copy - getMockFn(createSavedObjects).mockResolvedValueOnce({ + mockCreateSavedObjects.mockResolvedValueOnce({ errors: [error1], createdObjects: [obj1], }); - getMockFn(createSavedObjects).mockResolvedValueOnce({ + mockCreateSavedObjects.mockResolvedValueOnce({ errors: [error2], createdObjects: [obj2, obj3], }); @@ -569,13 +555,13 @@ describe('#importSavedObjectsFromStream', () => { }, }); - getMockFn(checkConflicts).mockResolvedValue({ + mockCheckConflicts.mockResolvedValue({ errors: [], filteredObjects: [], importIdMap: new Map(), pendingOverwrites: new Set(), }); - getMockFn(createSavedObjects) + mockCreateSavedObjects .mockResolvedValueOnce({ errors: [], createdObjects: [obj1, obj2] }) .mockResolvedValueOnce({ errors: [], createdObjects: [] }); @@ -607,17 +593,17 @@ describe('#importSavedObjectsFromStream', () => { test('accumulates multiple errors', async () => { const options = setupOptions(); const errors = [createError(), createError(), createError(), createError()]; - getMockFn(collectSavedObjects).mockResolvedValue({ + mockCollectSavedObjects.mockResolvedValue({ errors: [errors[0]], collectedObjects: [], importIdMap: new Map(), // doesn't matter }); - getMockFn(validateReferences).mockResolvedValue([errors[1]]); - getMockFn(createSavedObjects).mockResolvedValueOnce({ + mockValidateReferences.mockResolvedValue([errors[1]]); + mockCreateSavedObjects.mockResolvedValueOnce({ errors: [errors[2]], createdObjects: [], }); - getMockFn(createSavedObjects).mockResolvedValueOnce({ + mockCreateSavedObjects.mockResolvedValueOnce({ errors: [errors[3]], createdObjects: [], }); diff --git a/src/core/server/saved_objects/routes/integration_tests/import.test.ts b/src/core/server/saved_objects/routes/integration_tests/import.test.ts index 64c79b3424376..d5f994a3e01ea 100644 --- a/src/core/server/saved_objects/routes/integration_tests/import.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/import.test.ts @@ -6,7 +6,8 @@ * Side Public License, v 1. */ -import { mockUuidv4 } from '../../import/lib/__mocks__'; +jest.mock('uuid'); + import supertest from 'supertest'; import { registerImportRoute } from '../import'; import { savedObjectsClientMock } from '../../../../../core/server/mocks'; @@ -19,7 +20,6 @@ import { SavedObjectsErrorHelpers, SavedObjectsImporter } from '../..'; type SetupServerReturn = Awaited>; -const { v4: uuidv4 } = jest.requireActual('uuid'); const allowedTypes = ['index-pattern', 'visualization', 'dashboard']; const config = { maxImportPayloadBytes: 26214400, maxImportExportSize: 10000 } as SavedObjectConfig; let coreUsageStatsClient: jest.Mocked; @@ -46,8 +46,6 @@ describe(`POST ${URL}`, () => { }; beforeEach(async () => { - mockUuidv4.mockReset(); - mockUuidv4.mockImplementation(() => uuidv4()); ({ server, httpSetup, handlerContext } = await setupServer()); handlerContext.savedObjects.typeRegistry.getImportableAndExportableTypes.mockReturnValue( allowedTypes.map(createExportableType) @@ -487,7 +485,9 @@ describe(`POST ${URL}`, () => { describe('createNewCopies enabled', () => { it('imports objects, regenerating all IDs/reference IDs present, and resetting all origin IDs', async () => { - mockUuidv4 + const mockUuid = jest.requireMock('uuid'); + mockUuid.v4 = jest + .fn() .mockReturnValueOnce('foo') // a uuid.v4() is generated for the request.id .mockReturnValueOnce('foo') // another uuid.v4() is used for the request.uuid .mockReturnValueOnce('new-id-1') diff --git a/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts b/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts index 99139d82821c5..a7f1b1e304aa7 100644 --- a/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts @@ -6,7 +6,8 @@ * Side Public License, v 1. */ -import { mockUuidv4 } from '../../import/lib/__mocks__'; +jest.mock('uuid'); + import supertest from 'supertest'; import { registerResolveImportErrorsRoute } from '../resolve_import_errors'; import { savedObjectsClientMock } from '../../../../../core/server/mocks'; @@ -19,7 +20,6 @@ import { SavedObjectsImporter } from '../..'; type SetupServerReturn = Awaited>; -const { v4: uuidv4 } = jest.requireActual('uuid'); const allowedTypes = ['index-pattern', 'visualization', 'dashboard']; const config = { maxImportPayloadBytes: 26214400, maxImportExportSize: 10000 } as SavedObjectConfig; let coreUsageStatsClient: jest.Mocked; @@ -51,8 +51,6 @@ describe(`POST ${URL}`, () => { }; beforeEach(async () => { - mockUuidv4.mockReset(); - mockUuidv4.mockImplementation(() => uuidv4()); ({ server, httpSetup, handlerContext } = await setupServer()); handlerContext.savedObjects.typeRegistry.getImportableAndExportableTypes.mockReturnValue( allowedTypes.map(createExportableType) @@ -335,7 +333,8 @@ describe(`POST ${URL}`, () => { describe('createNewCopies enabled', () => { it('imports objects, regenerating all IDs/reference IDs present, and resetting all origin IDs', async () => { - mockUuidv4.mockReturnValue('new-id-1'); + const mockUuid = jest.requireMock('uuid'); + mockUuid.v4 = jest.fn().mockReturnValue('new-id-1'); savedObjectsClient.bulkGet.mockResolvedValueOnce({ saved_objects: [mockIndexPattern] }); const obj1 = { type: 'visualization', From 76f17de4d37bff93a1c9938f1bf8a8938dfb1bb1 Mon Sep 17 00:00:00 2001 From: Joe Portner Date: Sun, 2 Jan 2022 12:00:53 -0500 Subject: [PATCH 2/8] Refactor getImportIdMapForRetries Split this function into its own module. --- .../import/lib/check_origin_conflicts.test.ts | 60 +--------------- .../import/lib/check_origin_conflicts.ts | 40 +---------- .../lib/get_import_id_map_for_retries.test.ts | 68 +++++++++++++++++++ .../lib/get_import_id_map_for_retries.ts | 42 ++++++++++++ .../server/saved_objects/import/lib/index.ts | 3 +- .../import/resolve_import_errors.test.mock.ts | 4 +- 6 files changed, 116 insertions(+), 101 deletions(-) create mode 100644 src/core/server/saved_objects/import/lib/get_import_id_map_for_retries.test.ts create mode 100644 src/core/server/saved_objects/import/lib/get_import_id_map_for_retries.ts diff --git a/src/core/server/saved_objects/import/lib/check_origin_conflicts.test.ts b/src/core/server/saved_objects/import/lib/check_origin_conflicts.test.ts index fc2c6dde3f8fd..8ac31c7c83c25 100644 --- a/src/core/server/saved_objects/import/lib/check_origin_conflicts.test.ts +++ b/src/core/server/saved_objects/import/lib/check_origin_conflicts.test.ts @@ -10,10 +10,9 @@ import { SavedObjectsClientContract, SavedObjectReference, SavedObject, - SavedObjectsImportRetry, SavedObjectsImportFailure, } from '../../types'; -import { checkOriginConflicts, getImportIdMapForRetries } from './check_origin_conflicts'; +import { checkOriginConflicts } from './check_origin_conflicts'; import { savedObjectsClientMock } from '../../../mocks'; import { typeRegistryMock } from '../../saved_objects_type_registry.mock'; import { ISavedObjectTypeRegistry } from '../../saved_objects_type_registry'; @@ -514,60 +513,3 @@ describe('#checkOriginConflicts', () => { }); }); }); - -describe('#getImportIdMapForRetries', () => { - const createRetry = ( - { type, id }: { type: string; id: string }, - params: { destinationId?: string; createNewCopy?: boolean } = {} - ): SavedObjectsImportRetry => { - const { destinationId, createNewCopy } = params; - return { type, id, overwrite: false, destinationId, replaceReferences: [], createNewCopy }; - }; - - test('throws an error if retry is not found for an object', async () => { - const obj1 = createObject(MULTI_NS_TYPE, 'id-1'); - const obj2 = createObject(MULTI_NS_TYPE, 'id-2'); - const objects = [obj1, obj2]; - const retries = [createRetry(obj1)]; - const params = { objects, retries, createNewCopies: false }; - - expect(() => getImportIdMapForRetries(params)).toThrowErrorMatchingInlineSnapshot( - `"Retry was expected for \\"multi:id-2\\" but not found"` - ); - }); - - test('returns expected results', async () => { - const obj1 = createObject('type-1', 'id-1'); - const obj2 = createObject('type-2', 'id-2'); - const obj3 = createObject('type-3', 'id-3'); - const obj4 = createObject('type-4', 'id-4'); - const objects = [obj1, obj2, obj3, obj4]; - const retries = [ - createRetry(obj1), // retries that do not have `destinationId` specified are ignored - createRetry(obj2, { destinationId: obj2.id }), // retries that have `id` that matches `destinationId` are ignored - createRetry(obj3, { destinationId: 'id-X' }), // this retry will get added to the `importIdMap`! - createRetry(obj4, { destinationId: 'id-Y', createNewCopy: true }), // this retry will get added to the `importIdMap`! - ]; - const params = { objects, retries, createNewCopies: false }; - - const checkOriginConflictsResult = await getImportIdMapForRetries(params); - expect(checkOriginConflictsResult).toEqual( - new Map([ - [`${obj3.type}:${obj3.id}`, { id: 'id-X', omitOriginId: false }], - [`${obj4.type}:${obj4.id}`, { id: 'id-Y', omitOriginId: true }], - ]) - ); - }); - - test('omits origin ID in `importIdMap` entries when createNewCopies=true', async () => { - const obj = createObject('type-1', 'id-1'); - const objects = [obj]; - const retries = [createRetry(obj, { destinationId: 'id-X' })]; - const params = { objects, retries, createNewCopies: true }; - - const checkOriginConflictsResult = await getImportIdMapForRetries(params); - expect(checkOriginConflictsResult).toEqual( - new Map([[`${obj.type}:${obj.id}`, { id: 'id-X', omitOriginId: true }]]) - ); - }); -}); diff --git a/src/core/server/saved_objects/import/lib/check_origin_conflicts.ts b/src/core/server/saved_objects/import/lib/check_origin_conflicts.ts index d689f37f5ad26..efb6d3af06a55 100644 --- a/src/core/server/saved_objects/import/lib/check_origin_conflicts.ts +++ b/src/core/server/saved_objects/import/lib/check_origin_conflicts.ts @@ -8,12 +8,7 @@ import pMap from 'p-map'; import { v4 as uuidv4 } from 'uuid'; -import { - SavedObject, - SavedObjectsClientContract, - SavedObjectsImportFailure, - SavedObjectsImportRetry, -} from '../../types'; +import { SavedObject, SavedObjectsClientContract, SavedObjectsImportFailure } from '../../types'; import { ISavedObjectTypeRegistry } from '../../saved_objects_type_registry'; interface CheckOriginConflictsParams { @@ -29,12 +24,6 @@ type CheckOriginConflictParams = Omit & { object: SavedObject<{ title?: string }>; }; -interface GetImportIdMapForRetriesParams { - objects: SavedObject[]; - retries: SavedObjectsImportRetry[]; - createNewCopies: boolean; -} - interface InexactMatch { object: SavedObject; destinations: Array<{ id: string; title?: string; updatedAt?: string }>; @@ -216,30 +205,3 @@ export async function checkOriginConflicts({ objects, ...params }: CheckOriginCo return { errors, importIdMap, pendingOverwrites }; } - -/** - * Assume that all objects exist in the `retries` map (due to filtering at the beginning of `resolveSavedObjectsImportErrors`). - */ -export function getImportIdMapForRetries(params: GetImportIdMapForRetriesParams) { - const { objects, retries, createNewCopies } = params; - - const retryMap = retries.reduce( - (acc, cur) => acc.set(`${cur.type}:${cur.id}`, cur), - new Map() - ); - const importIdMap = new Map(); - - objects.forEach(({ type, id }) => { - const retry = retryMap.get(`${type}:${id}`); - if (!retry) { - throw new Error(`Retry was expected for "${type}:${id}" but not found`); - } - const { destinationId } = retry; - const omitOriginId = createNewCopies || Boolean(retry.createNewCopy); - if (destinationId && destinationId !== id) { - importIdMap.set(`${type}:${id}`, { id: destinationId, omitOriginId }); - } - }); - - return importIdMap; -} diff --git a/src/core/server/saved_objects/import/lib/get_import_id_map_for_retries.test.ts b/src/core/server/saved_objects/import/lib/get_import_id_map_for_retries.test.ts new file mode 100644 index 0000000000000..1a6c3fa43ea5c --- /dev/null +++ b/src/core/server/saved_objects/import/lib/get_import_id_map_for_retries.test.ts @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { SavedObject } from '../../types'; +import type { SavedObjectsImportRetry } from '../types'; +import { getImportIdMapForRetries } from './get_import_id_map_for_retries'; + +describe('#getImportIdMapForRetries', () => { + const createRetry = ( + { type, id }: { type: string; id: string }, + params: { destinationId?: string; createNewCopy?: boolean } = {} + ): SavedObjectsImportRetry => { + const { destinationId, createNewCopy } = params; + return { type, id, overwrite: false, destinationId, replaceReferences: [], createNewCopy }; + }; + + test('throws an error if retry is not found for an object', async () => { + const obj1 = { type: 'type-1', id: 'id-1' }; + const obj2 = { type: 'type-2', id: 'id-2' }; + const objects = [obj1, obj2] as SavedObject[]; + const retries = [createRetry(obj1)]; + const params = { objects, retries, createNewCopies: false }; + + expect(() => getImportIdMapForRetries(params)).toThrowErrorMatchingInlineSnapshot( + `"Retry was expected for \\"type-2:id-2\\" but not found"` + ); + }); + + test('returns expected results', async () => { + const obj1 = { type: 'type-1', id: 'id-1' }; + const obj2 = { type: 'type-2', id: 'id-2' }; + const obj3 = { type: 'type-3', id: 'id-3' }; + const obj4 = { type: 'type-4', id: 'id-4' }; + const objects = [obj1, obj2, obj3, obj4] as SavedObject[]; + const retries = [ + createRetry(obj1), // retries that do not have `destinationId` specified are ignored + createRetry(obj2, { destinationId: obj2.id }), // retries that have `id` that matches `destinationId` are ignored + createRetry(obj3, { destinationId: 'id-X' }), // this retry will get added to the `importIdMap`! + createRetry(obj4, { destinationId: 'id-Y', createNewCopy: true }), // this retry will get added to the `importIdMap`! + ]; + const params = { objects, retries, createNewCopies: false }; + + const result = await getImportIdMapForRetries(params); + expect(result).toEqual( + new Map([ + [`${obj3.type}:${obj3.id}`, { id: 'id-X', omitOriginId: false }], + [`${obj4.type}:${obj4.id}`, { id: 'id-Y', omitOriginId: true }], + ]) + ); + }); + + test('omits origin ID in `importIdMap` entries when createNewCopies=true', async () => { + const obj1 = { type: 'type-1', id: 'id-1' }; + const objects = [obj1] as SavedObject[]; + const retries = [createRetry(obj1, { destinationId: 'id-X' })]; + const params = { objects, retries, createNewCopies: true }; + + const result = await getImportIdMapForRetries(params); + expect(result).toEqual( + new Map([[`${obj1.type}:${obj1.id}`, { id: 'id-X', omitOriginId: true }]]) + ); + }); +}); diff --git a/src/core/server/saved_objects/import/lib/get_import_id_map_for_retries.ts b/src/core/server/saved_objects/import/lib/get_import_id_map_for_retries.ts new file mode 100644 index 0000000000000..888d3032842e0 --- /dev/null +++ b/src/core/server/saved_objects/import/lib/get_import_id_map_for_retries.ts @@ -0,0 +1,42 @@ +/* + * 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 { SavedObject, SavedObjectsImportRetry } from '../../types'; + +interface GetImportIdMapForRetriesParams { + objects: SavedObject[]; + retries: SavedObjectsImportRetry[]; + createNewCopies: boolean; +} + +/** + * Assume that all objects exist in the `retries` map (due to filtering at the beginning of `resolveSavedObjectsImportErrors`). + */ +export function getImportIdMapForRetries(params: GetImportIdMapForRetriesParams) { + const { objects, retries, createNewCopies } = params; + + const retryMap = retries.reduce( + (acc, cur) => acc.set(`${cur.type}:${cur.id}`, cur), + new Map() + ); + const importIdMap = new Map(); + + objects.forEach(({ type, id }) => { + const retry = retryMap.get(`${type}:${id}`); + if (!retry) { + throw new Error(`Retry was expected for "${type}:${id}" but not found`); + } + const { destinationId } = retry; + const omitOriginId = createNewCopies || Boolean(retry.createNewCopy); + if (destinationId && destinationId !== id) { + importIdMap.set(`${type}:${id}`, { id: destinationId, omitOriginId }); + } + }); + + return importIdMap; +} diff --git a/src/core/server/saved_objects/import/lib/index.ts b/src/core/server/saved_objects/import/lib/index.ts index ab1c34b2032df..ea8fa376793d7 100644 --- a/src/core/server/saved_objects/import/lib/index.ts +++ b/src/core/server/saved_objects/import/lib/index.ts @@ -7,12 +7,13 @@ */ export { checkConflicts } from './check_conflicts'; -export { checkOriginConflicts, getImportIdMapForRetries } from './check_origin_conflicts'; +export { checkOriginConflicts } from './check_origin_conflicts'; export { collectSavedObjects } from './collect_saved_objects'; export { createLimitStream } from './create_limit_stream'; export { createObjectsFilter } from './create_objects_filter'; export { createSavedObjects } from './create_saved_objects'; export { extractErrors } from './extract_errors'; +export { getImportIdMapForRetries } from './get_import_id_map_for_retries'; export { getNonUniqueEntries } from './get_non_unique_entries'; export { regenerateIds } from './regenerate_ids'; export { splitOverwrites } from './split_overwrites'; diff --git a/src/core/server/saved_objects/import/resolve_import_errors.test.mock.ts b/src/core/server/saved_objects/import/resolve_import_errors.test.mock.ts index 02e6b423d9f89..25f1c0fafb8df 100644 --- a/src/core/server/saved_objects/import/resolve_import_errors.test.mock.ts +++ b/src/core/server/saved_objects/import/resolve_import_errors.test.mock.ts @@ -12,7 +12,7 @@ import type { collectSavedObjects } from './lib/collect_saved_objects'; import type { regenerateIds } from './lib/regenerate_ids'; import type { validateReferences } from './lib/validate_references'; import type { checkConflicts } from './lib/check_conflicts'; -import type { getImportIdMapForRetries } from './lib/check_origin_conflicts'; +import type { getImportIdMapForRetries } from './lib/get_import_id_map_for_retries'; import type { splitOverwrites } from './lib/split_overwrites'; import type { createSavedObjects } from './lib/create_saved_objects'; import type { executeImportHooks } from './lib/execute_import_hooks'; @@ -50,7 +50,7 @@ jest.mock('./lib/check_conflicts', () => ({ export const mockGetImportIdMapForRetries = jest.fn() as jest.MockedFunction< typeof getImportIdMapForRetries >; -jest.mock('./lib/check_origin_conflicts', () => ({ +jest.mock('./lib/get_import_id_map_for_retries', () => ({ getImportIdMapForRetries: mockGetImportIdMapForRetries, })); From 78f2428c84fad2d1e90ab31a03dbb53145d7c400 Mon Sep 17 00:00:00 2001 From: Joe Portner Date: Thu, 30 Dec 2021 13:23:59 -0500 Subject: [PATCH 3/8] Refactor types Renamed `importIdMap` to `importStateMap`, changed `id` value to `destinationId`, and added TSdocs. --- .../import/import_saved_objects.test.ts | 61 ++++++------- .../import/import_saved_objects.ts | 12 +-- .../import/lib/check_conflicts.test.ts | 14 +-- .../import/lib/check_conflicts.ts | 9 +- .../import/lib/check_origin_conflicts.test.ts | 87 +++++++++--------- .../import/lib/check_origin_conflicts.ts | 17 ++-- .../import/lib/collect_saved_objects.test.ts | 18 ++-- .../import/lib/collect_saved_objects.ts | 7 +- .../import/lib/create_saved_objects.test.ts | 20 ++--- .../import/lib/create_saved_objects.ts | 26 +++--- ... get_import_state_map_for_retries.test.ts} | 22 ++--- ...ts => get_import_state_map_for_retries.ts} | 11 +-- .../server/saved_objects/import/lib/index.ts | 3 +- .../import/lib/regenerate_ids.test.ts | 6 +- .../import/lib/regenerate_ids.ts | 12 +-- .../server/saved_objects/import/lib/types.ts | 31 +++++++ .../import/resolve_import_errors.test.mock.ts | 10 +-- .../import/resolve_import_errors.test.ts | 88 ++++++++++--------- .../import/resolve_import_errors.ts | 21 ++--- 19 files changed, 267 insertions(+), 208 deletions(-) rename src/core/server/saved_objects/import/lib/{get_import_id_map_for_retries.test.ts => get_import_state_map_for_retries.test.ts} (73%) rename src/core/server/saved_objects/import/lib/{get_import_id_map_for_retries.ts => get_import_state_map_for_retries.ts} (78%) create mode 100644 src/core/server/saved_objects/import/lib/types.ts diff --git a/src/core/server/saved_objects/import/import_saved_objects.test.ts b/src/core/server/saved_objects/import/import_saved_objects.test.ts index 5a597045f3b07..72011eee0e91b 100644 --- a/src/core/server/saved_objects/import/import_saved_objects.test.ts +++ b/src/core/server/saved_objects/import/import_saved_objects.test.ts @@ -29,6 +29,7 @@ import { ISavedObjectTypeRegistry } from '..'; import { typeRegistryMock } from '../saved_objects_type_registry.mock'; import { importSavedObjectsFromStream, ImportSavedObjectsOptions } from './import_saved_objects'; import { SavedObjectsImportHook, SavedObjectsImportWarning } from './types'; +import type { ImportStateMap } from './lib'; describe('#importSavedObjectsFromStream', () => { beforeEach(() => { @@ -37,19 +38,19 @@ describe('#importSavedObjectsFromStream', () => { mockCollectSavedObjects.mockResolvedValue({ errors: [], collectedObjects: [], - importIdMap: new Map(), + importStateMap: new Map(), }); mockRegenerateIds.mockReturnValue(new Map()); mockValidateReferences.mockResolvedValue([]); mockCheckConflicts.mockResolvedValue({ errors: [], filteredObjects: [], - importIdMap: new Map(), + importStateMap: new Map(), pendingOverwrites: new Set(), }); mockCheckOriginConflicts.mockResolvedValue({ errors: [], - importIdMap: new Map(), + importStateMap: new Map(), pendingOverwrites: new Set(), }); mockCreateSavedObjects.mockResolvedValue({ errors: [], createdObjects: [] }); @@ -141,7 +142,7 @@ describe('#importSavedObjectsFromStream', () => { mockCollectSavedObjects.mockResolvedValue({ errors: [], collectedObjects, - importIdMap: new Map(), + importStateMap: new Map(), }); await importSavedObjectsFromStream(options); @@ -162,7 +163,7 @@ describe('#importSavedObjectsFromStream', () => { mockCollectSavedObjects.mockResolvedValue({ errors: [], collectedObjects, - importIdMap: new Map(), + importStateMap: new Map(), }); mockCreateSavedObjects.mockResolvedValue({ errors: [], @@ -184,7 +185,7 @@ describe('#importSavedObjectsFromStream', () => { mockCollectSavedObjects.mockResolvedValue({ errors: [], collectedObjects, - importIdMap: new Map(), + importStateMap: new Map(), }); await importSavedObjectsFromStream(options); @@ -197,7 +198,7 @@ describe('#importSavedObjectsFromStream', () => { mockCollectSavedObjects.mockResolvedValue({ errors: [], collectedObjects, - importIdMap: new Map(), + importStateMap: new Map(), }); await importSavedObjectsFromStream(options); @@ -213,11 +214,11 @@ describe('#importSavedObjectsFromStream', () => { test('checks origin conflicts', async () => { const options = setupOptions(); const filteredObjects = [createObject()]; - const importIdMap = new Map(); + const importStateMap = new Map(); mockCheckConflicts.mockResolvedValue({ errors: [], filteredObjects, - importIdMap, + importStateMap, pendingOverwrites: new Set(), }); @@ -228,7 +229,7 @@ describe('#importSavedObjectsFromStream', () => { typeRegistry, namespace, ignoreRegularConflicts: overwrite, - importIdMap, + importStateMap, }; expect(mockCheckOriginConflicts).toHaveBeenCalledWith(checkOriginConflictsParams); }); @@ -241,7 +242,7 @@ describe('#importSavedObjectsFromStream', () => { mockCollectSavedObjects.mockResolvedValue({ errors: [errors[0]], collectedObjects, - importIdMap: new Map([ + importStateMap: new Map([ ['foo', {}], ['bar', {}], ['baz', {}], @@ -251,26 +252,26 @@ describe('#importSavedObjectsFromStream', () => { mockCheckConflicts.mockResolvedValue({ errors: [errors[2]], filteredObjects, - importIdMap: new Map([['bar', { id: 'newId1' }]]), + importStateMap: new Map([['bar', { destinationId: 'newId1' }]]), pendingOverwrites: new Set(), }); mockCheckOriginConflicts.mockResolvedValue({ errors: [errors[3]], - importIdMap: new Map([['baz', { id: 'newId2' }]]), + importStateMap: new Map([['baz', { destinationId: 'newId2' }]]), pendingOverwrites: new Set(), }); await importSavedObjectsFromStream(options); - const importIdMap = new Map([ + const importStateMap = new Map([ ['foo', {}], - ['bar', { id: 'newId1' }], - ['baz', { id: 'newId2' }], + ['bar', { destinationId: 'newId1' }], + ['baz', { destinationId: 'newId2' }], ]); const createSavedObjectsParams = { objects: collectedObjects, accumulatedErrors: errors, savedObjectsClient, - importIdMap, + importStateMap, overwrite, namespace, }; @@ -285,7 +286,7 @@ describe('#importSavedObjectsFromStream', () => { mockCollectSavedObjects.mockResolvedValue({ errors: [], collectedObjects, - importIdMap: new Map(), // doesn't matter + importStateMap: new Map(), // doesn't matter }); await importSavedObjectsFromStream(options); @@ -308,22 +309,22 @@ describe('#importSavedObjectsFromStream', () => { mockCollectSavedObjects.mockResolvedValue({ errors: [errors[0]], collectedObjects, - importIdMap: new Map([ + importStateMap: new Map([ ['foo', {}], ['bar', {}], ]), }); mockValidateReferences.mockResolvedValue([errors[1]]); - // this importIdMap is not composed with the one obtained from `collectSavedObjects` - const importIdMap = new Map().set(`id1`, { id: `newId1` }); - mockRegenerateIds.mockReturnValue(importIdMap); + // this importStateMap is not composed with the one obtained from `collectSavedObjects` + const importStateMap: ImportStateMap = new Map().set(`id1`, { destinationId: `newId1` }); + mockRegenerateIds.mockReturnValue(importStateMap); await importSavedObjectsFromStream(options); const createSavedObjectsParams = { objects: collectedObjects, accumulatedErrors: errors, savedObjectsClient, - importIdMap, + importStateMap, overwrite, namespace, }; @@ -345,7 +346,7 @@ describe('#importSavedObjectsFromStream', () => { mockCollectSavedObjects.mockResolvedValue({ errors: [createError()], collectedObjects: [], - importIdMap: new Map(), // doesn't matter + importStateMap: new Map(), // doesn't matter }); const result = await importSavedObjectsFromStream(options); @@ -363,7 +364,7 @@ describe('#importSavedObjectsFromStream', () => { mockCollectSavedObjects.mockResolvedValue({ errors: [], collectedObjects, - importIdMap: new Map(), + importStateMap: new Map(), }); mockCreateSavedObjects.mockResolvedValue({ errors: [], @@ -411,7 +412,7 @@ describe('#importSavedObjectsFromStream', () => { mockCheckConflicts.mockResolvedValue({ errors: [], filteredObjects: [], - importIdMap: new Map(), + importStateMap: new Map(), pendingOverwrites: new Set([ `${success2.type}:${success2.id}`, // the success2 object was overwritten `${error2.type}:${error2.id}`, // an attempt was made to overwrite the error2 object @@ -487,7 +488,7 @@ describe('#importSavedObjectsFromStream', () => { mockCheckConflicts.mockResolvedValue({ errors: [], filteredObjects: [], - importIdMap: new Map(), + importStateMap: new Map(), pendingOverwrites: new Set(), }); mockCreateSavedObjects.mockResolvedValue({ errors: [], createdObjects: [obj1, obj2] }); @@ -521,18 +522,18 @@ describe('#importSavedObjectsFromStream', () => { mockCollectSavedObjects.mockResolvedValue({ errors: [errors[0]], collectedObjects: [], - importIdMap: new Map(), // doesn't matter + importStateMap: new Map(), // doesn't matter }); mockValidateReferences.mockResolvedValue([errors[1]]); mockCheckConflicts.mockResolvedValue({ errors: [errors[2]], filteredObjects: [], - importIdMap: new Map(), // doesn't matter + importStateMap: new Map(), // doesn't matter pendingOverwrites: new Set(), }); mockCheckOriginConflicts.mockResolvedValue({ errors: [errors[3]], - importIdMap: new Map(), // doesn't matter + importStateMap: new Map(), // doesn't matter pendingOverwrites: new Set(), }); mockCreateSavedObjects.mockResolvedValue({ errors: [errors[4]], createdObjects: [] }); diff --git a/src/core/server/saved_objects/import/import_saved_objects.ts b/src/core/server/saved_objects/import/import_saved_objects.ts index 4fc8f04a40270..851d36672b58c 100644 --- a/src/core/server/saved_objects/import/import_saved_objects.ts +++ b/src/core/server/saved_objects/import/import_saved_objects.ts @@ -73,7 +73,7 @@ export async function importSavedObjectsFromStream({ }); errorAccumulator = [...errorAccumulator, ...collectSavedObjectsResult.errors]; /** Map of all IDs for objects that we are attempting to import; each value is empty by default */ - let importIdMap = collectSavedObjectsResult.importIdMap; + let importStateMap = collectSavedObjectsResult.importStateMap; let pendingOverwrites = new Set(); // Validate references @@ -85,7 +85,7 @@ export async function importSavedObjectsFromStream({ errorAccumulator = [...errorAccumulator, ...validateReferencesResult]; if (createNewCopies) { - importIdMap = regenerateIds(collectSavedObjectsResult.collectedObjects); + importStateMap = regenerateIds(collectSavedObjectsResult.collectedObjects); } else { // Check single-namespace objects for conflicts in this namespace, and check multi-namespace objects for conflicts across all namespaces const checkConflictsParams = { @@ -96,7 +96,7 @@ export async function importSavedObjectsFromStream({ }; const checkConflictsResult = await checkConflicts(checkConflictsParams); errorAccumulator = [...errorAccumulator, ...checkConflictsResult.errors]; - importIdMap = new Map([...importIdMap, ...checkConflictsResult.importIdMap]); + importStateMap = new Map([...importStateMap, ...checkConflictsResult.importStateMap]); pendingOverwrites = checkConflictsResult.pendingOverwrites; // Check multi-namespace object types for origin conflicts in this namespace @@ -106,11 +106,11 @@ export async function importSavedObjectsFromStream({ typeRegistry, namespace, ignoreRegularConflicts: overwrite, - importIdMap, + importStateMap, }; const checkOriginConflictsResult = await checkOriginConflicts(checkOriginConflictsParams); errorAccumulator = [...errorAccumulator, ...checkOriginConflictsResult.errors]; - importIdMap = new Map([...importIdMap, ...checkOriginConflictsResult.importIdMap]); + importStateMap = new Map([...importStateMap, ...checkOriginConflictsResult.importStateMap]); pendingOverwrites = new Set([ ...pendingOverwrites, ...checkOriginConflictsResult.pendingOverwrites, @@ -122,7 +122,7 @@ export async function importSavedObjectsFromStream({ objects: collectSavedObjectsResult.collectedObjects, accumulatedErrors: errorAccumulator, savedObjectsClient, - importIdMap, + importStateMap, overwrite, namespace, }; diff --git a/src/core/server/saved_objects/import/lib/check_conflicts.test.ts b/src/core/server/saved_objects/import/lib/check_conflicts.test.ts index b1adf0f574db6..b2de6f11d5cb8 100644 --- a/src/core/server/saved_objects/import/lib/check_conflicts.test.ts +++ b/src/core/server/saved_objects/import/lib/check_conflicts.test.ts @@ -83,7 +83,7 @@ describe('#checkConflicts', () => { expect(checkConflictsResult).toEqual({ filteredObjects: [], errors: [], - importIdMap: new Map(), + importStateMap: new Map(), pendingOverwrites: new Set(), }); }); @@ -119,7 +119,7 @@ describe('#checkConflicts', () => { error: { ...obj4Error.error, type: 'unknown' }, }, ], - importIdMap: new Map([[`${obj3.type}:${obj3.id}`, { id: 'uuidv4' }]]), + importStateMap: new Map([[`${obj3.type}:${obj3.id}`, { destinationId: 'uuidv4' }]]), pendingOverwrites: new Set(), }); }); @@ -185,12 +185,14 @@ describe('#checkConflicts', () => { error: { ...obj4Error.error, type: 'unknown' }, }, ], - importIdMap: new Map([[`${obj3.type}:${obj3.id}`, { id: 'uuidv4', omitOriginId: true }]]), + importStateMap: new Map([ + [`${obj3.type}:${obj3.id}`, { destinationId: 'uuidv4', omitOriginId: true }], + ]), pendingOverwrites: new Set([`${obj5.type}:${obj5.id}`]), }); }); - it('adds `omitOriginId` field to `importIdMap` entries when createNewCopies=true', async () => { + it('adds `omitOriginId` field to `importStateMap` entries when createNewCopies=true', async () => { const namespace = 'foo-namespace'; const params = setupParams({ objects, namespace, createNewCopies: true }); socCheckConflicts.mockResolvedValue({ errors: [obj2Error, obj3Error, obj4Error] }); @@ -198,7 +200,9 @@ describe('#checkConflicts', () => { const checkConflictsResult = await checkConflicts(params); expect(checkConflictsResult).toEqual( expect.objectContaining({ - importIdMap: new Map([[`${obj3.type}:${obj3.id}`, { id: 'uuidv4', omitOriginId: true }]]), + importStateMap: new Map([ + [`${obj3.type}:${obj3.id}`, { destinationId: 'uuidv4', omitOriginId: true }], + ]), }) ); }); diff --git a/src/core/server/saved_objects/import/lib/check_conflicts.ts b/src/core/server/saved_objects/import/lib/check_conflicts.ts index d5e37f21fc84a..c15c4302491b4 100644 --- a/src/core/server/saved_objects/import/lib/check_conflicts.ts +++ b/src/core/server/saved_objects/import/lib/check_conflicts.ts @@ -14,6 +14,7 @@ import { SavedObjectError, SavedObjectsImportRetry, } from '../../types'; +import type { ImportStateMap } from './types'; interface CheckConflictsParams { objects: Array>; @@ -37,12 +38,12 @@ export async function checkConflicts({ }: CheckConflictsParams) { const filteredObjects: Array> = []; const errors: SavedObjectsImportFailure[] = []; - const importIdMap = new Map(); + const importStateMap: ImportStateMap = new Map(); const pendingOverwrites = new Set(); // exit early if there are no objects to check if (objects.length === 0) { - return { filteredObjects, errors, importIdMap, pendingOverwrites }; + return { filteredObjects, errors, importStateMap, pendingOverwrites }; } const retryMap = retries.reduce( @@ -76,7 +77,7 @@ export async function checkConflicts({ // This code path should not be triggered for a retry, but in case the consumer is using the import APIs incorrectly and attempting to // retry an object with a destinationId that would result in an unresolvable conflict, we regenerate the ID here as a fail-safe. const omitOriginId = createNewCopies || createNewCopy; - importIdMap.set(`${type}:${id}`, { id: uuidv4(), omitOriginId }); + importStateMap.set(`${type}:${id}`, { destinationId: uuidv4(), omitOriginId }); filteredObjects.push(object); } else if (errorObj && errorObj.statusCode !== 409) { errors.push({ type, id, title, meta: { title }, error: { ...errorObj, type: 'unknown' } }); @@ -90,5 +91,5 @@ export async function checkConflicts({ } } }); - return { filteredObjects, errors, importIdMap, pendingOverwrites }; + return { filteredObjects, errors, importStateMap, pendingOverwrites }; } diff --git a/src/core/server/saved_objects/import/lib/check_origin_conflicts.test.ts b/src/core/server/saved_objects/import/lib/check_origin_conflicts.test.ts index 8ac31c7c83c25..3d25b66edf5fe 100644 --- a/src/core/server/saved_objects/import/lib/check_origin_conflicts.test.ts +++ b/src/core/server/saved_objects/import/lib/check_origin_conflicts.test.ts @@ -16,6 +16,7 @@ import { checkOriginConflicts } from './check_origin_conflicts'; import { savedObjectsClientMock } from '../../../mocks'; import { typeRegistryMock } from '../../saved_objects_type_registry.mock'; import { ISavedObjectTypeRegistry } from '../../saved_objects_type_registry'; +import type { ImportStateMap } from './types'; jest.mock('uuid', () => ({ v4: () => 'uuidv4', @@ -59,7 +60,7 @@ describe('#checkOriginConflicts', () => { const setupParams = (partial: { objects: SavedObjectType[]; namespace?: string; - importIdMap?: Map; + importStateMap?: ImportStateMap; ignoreRegularConflicts?: boolean; }): CheckOriginConflictsParams => { savedObjectsClient = savedObjectsClientMock.create(); @@ -68,7 +69,7 @@ describe('#checkOriginConflicts', () => { typeRegistry = typeRegistryMock.create(); typeRegistry.isMultiNamespace.mockImplementation((type) => type === MULTI_NS_TYPE); return { - importIdMap: new Map(), // empty by default + importStateMap: new Map(), // empty by default ...partial, savedObjectsClient, typeRegistry, @@ -181,7 +182,7 @@ describe('#checkOriginConflicts', () => { }, }); - describe('object result without a `importIdMap` entry (no match or exact match)', () => { + describe('object result without a `importStateMap` entry (no match or exact match)', () => { test('returns object when no match is detected (0 hits)', async () => { // no objects exist in this space // try to import obj1, obj2, obj3, and obj4 @@ -196,7 +197,7 @@ describe('#checkOriginConflicts', () => { const checkOriginConflictsResult = await checkOriginConflicts(params); const expectedResult = { - importIdMap: new Map(), + importStateMap: new Map(), errors: [], pendingOverwrites: new Set(), }; @@ -213,7 +214,7 @@ describe('#checkOriginConflicts', () => { const objects = [obj2, obj4]; const params = setupParams({ objects, - importIdMap: new Map([ + importStateMap: new Map([ [`${obj1.type}:${obj1.id}`, {}], [`${obj2.type}:${obj2.id}`, {}], [`${obj3.type}:${obj3.id}`, {}], @@ -225,7 +226,7 @@ describe('#checkOriginConflicts', () => { const checkOriginConflictsResult = await checkOriginConflicts(params); const expectedResult = { - importIdMap: new Map(), + importStateMap: new Map(), errors: [], pendingOverwrites: new Set(), }; @@ -241,7 +242,7 @@ describe('#checkOriginConflicts', () => { const objects = [obj3]; const params = setupParams({ objects, - importIdMap: new Map([ + importStateMap: new Map([ [`${obj1.type}:${obj1.id}`, {}], [`${obj2.type}:${obj2.id}`, {}], [`${obj3.type}:${obj3.id}`, {}], @@ -251,7 +252,7 @@ describe('#checkOriginConflicts', () => { const checkOriginConflictsResult = await checkOriginConflicts(params); const expectedResult = { - importIdMap: new Map(), + importStateMap: new Map(), errors: [], pendingOverwrites: new Set(), }; @@ -259,7 +260,7 @@ describe('#checkOriginConflicts', () => { }); }); - describe('object result with a `importIdMap` entry (partial match with a single destination)', () => { + describe('object result with a `importStateMap` entry (partial match with a single destination)', () => { describe('when an inexact match is detected (1 hit)', () => { // objA and objB exist in this space // try to import obj1 and obj2 @@ -280,20 +281,20 @@ describe('#checkOriginConflicts', () => { const params = setup(false); const checkOriginConflictsResult = await checkOriginConflicts(params); const expectedResult = { - importIdMap: new Map(), + importStateMap: new Map(), errors: [createConflictError(obj1, objA.id), createConflictError(obj2, objB.id)], pendingOverwrites: new Set(), }; expect(checkOriginConflictsResult).toEqual(expectedResult); }); - test('returns object with a `importIdMap` entry when ignoreRegularConflicts=true', async () => { + test('returns object with a `importStateMap` entry when ignoreRegularConflicts=true', async () => { const params = setup(true); const checkOriginConflictsResult = await checkOriginConflicts(params); const expectedResult = { - importIdMap: new Map([ - [`${obj1.type}:${obj1.id}`, { id: objA.id }], - [`${obj2.type}:${obj2.id}`, { id: objB.id }], + importStateMap: new Map([ + [`${obj1.type}:${obj1.id}`, { destinationId: objA.id }], + [`${obj2.type}:${obj2.id}`, { destinationId: objB.id }], ]), errors: [], pendingOverwrites: new Set([`${obj1.type}:${obj1.id}`, `${obj2.type}:${obj2.id}`]), @@ -317,7 +318,7 @@ describe('#checkOriginConflicts', () => { const params = setupParams({ objects, ignoreRegularConflicts, - importIdMap: new Map([ + importStateMap: new Map([ [`${obj1.type}:${obj1.id}`, {}], [`${obj2.type}:${obj2.id}`, {}], [`${obj3.type}:${obj3.id}`, {}], @@ -333,20 +334,20 @@ describe('#checkOriginConflicts', () => { const params = setup(false); const checkOriginConflictsResult = await checkOriginConflicts(params); const expectedResult = { - importIdMap: new Map(), + importStateMap: new Map(), errors: [createConflictError(obj2, objA.id), createConflictError(obj4, objB.id)], pendingOverwrites: new Set(), }; expect(checkOriginConflictsResult).toEqual(expectedResult); }); - test('returns object with a `importIdMap` entry when ignoreRegularConflicts=true', async () => { + test('returns object with a `importStateMap` entry when ignoreRegularConflicts=true', async () => { const params = setup(true); const checkOriginConflictsResult = await checkOriginConflicts(params); const expectedResult = { - importIdMap: new Map([ - [`${obj2.type}:${obj2.id}`, { id: objA.id }], - [`${obj4.type}:${obj4.id}`, { id: objB.id }], + importStateMap: new Map([ + [`${obj2.type}:${obj2.id}`, { destinationId: objA.id }], + [`${obj4.type}:${obj4.id}`, { destinationId: objB.id }], ]), errors: [], pendingOverwrites: new Set([`${obj2.type}:${obj2.id}`, `${obj4.type}:${obj4.id}`]), @@ -357,7 +358,7 @@ describe('#checkOriginConflicts', () => { }); describe('ambiguous conflicts', () => { - test('returns object with a `importIdMap` entry when multiple inexact matches are detected that target the same single destination', async () => { + test('returns object with a `importStateMap` entry when multiple inexact matches are detected that target the same single destination', async () => { // objA and objB exist in this space // try to import obj1, obj2, obj3, and obj4 const obj1 = createObject(MULTI_NS_TYPE, 'id-1'); @@ -375,11 +376,11 @@ describe('#checkOriginConflicts', () => { const checkOriginConflictsResult = await checkOriginConflicts(params); const expectedResult = { - importIdMap: new Map([ - [`${obj1.type}:${obj1.id}`, { id: 'uuidv4', omitOriginId: true }], - [`${obj2.type}:${obj2.id}`, { id: 'uuidv4', omitOriginId: true }], - [`${obj3.type}:${obj3.id}`, { id: 'uuidv4', omitOriginId: true }], - [`${obj4.type}:${obj4.id}`, { id: 'uuidv4', omitOriginId: true }], + importStateMap: new Map([ + [`${obj1.type}:${obj1.id}`, { destinationId: 'uuidv4', omitOriginId: true }], + [`${obj2.type}:${obj2.id}`, { destinationId: 'uuidv4', omitOriginId: true }], + [`${obj3.type}:${obj3.id}`, { destinationId: 'uuidv4', omitOriginId: true }], + [`${obj4.type}:${obj4.id}`, { destinationId: 'uuidv4', omitOriginId: true }], ]), errors: [], pendingOverwrites: new Set(), @@ -403,7 +404,7 @@ describe('#checkOriginConflicts', () => { const checkOriginConflictsResult = await checkOriginConflicts(params); const expectedResult = { - importIdMap: new Map(), + importStateMap: new Map(), errors: [ createAmbiguousConflictError(obj1, [objB, objA]), // Assert that these have been sorted by updatedAt in descending order createAmbiguousConflictError(obj2, [objC, objD]), // Assert that these have been sorted by ID in ascending order (since their updatedAt values are the same) @@ -413,7 +414,7 @@ describe('#checkOriginConflicts', () => { expect(checkOriginConflictsResult).toEqual(expectedResult); }); - test('returns object with a `importIdMap` entry when multiple inexact matches are detected that target the same multiple destinations', async () => { + test('returns object with a `importStateMap` entry when multiple inexact matches are detected that target the same multiple destinations', async () => { // objA, objB, objC, and objD exist in this space // try to import obj1, obj2, obj3, and obj4 const obj1 = createObject(MULTI_NS_TYPE, 'id-1'); @@ -433,11 +434,11 @@ describe('#checkOriginConflicts', () => { const checkOriginConflictsResult = await checkOriginConflicts(params); const expectedResult = { - importIdMap: new Map([ - [`${obj1.type}:${obj1.id}`, { id: 'uuidv4', omitOriginId: true }], - [`${obj2.type}:${obj2.id}`, { id: 'uuidv4', omitOriginId: true }], - [`${obj3.type}:${obj3.id}`, { id: 'uuidv4', omitOriginId: true }], - [`${obj4.type}:${obj4.id}`, { id: 'uuidv4', omitOriginId: true }], + importStateMap: new Map([ + [`${obj1.type}:${obj1.id}`, { destinationId: 'uuidv4', omitOriginId: true }], + [`${obj2.type}:${obj2.id}`, { destinationId: 'uuidv4', omitOriginId: true }], + [`${obj3.type}:${obj3.id}`, { destinationId: 'uuidv4', omitOriginId: true }], + [`${obj4.type}:${obj4.id}`, { destinationId: 'uuidv4', omitOriginId: true }], ]), errors: [], pendingOverwrites: new Set(), @@ -465,10 +466,12 @@ describe('#checkOriginConflicts', () => { const objE = createObject(MULTI_NS_TYPE, 'id-E', obj7.id); const objects = [obj1, obj2, obj4, obj5, obj6, obj7, obj8]; - const importIdMap = new Map([...objects, obj3].map(({ type, id }) => [`${type}:${id}`, {}])); + const importStateMap = new Map( + [...objects, obj3].map(({ type, id }) => [`${type}:${id}`, {}]) + ); const setup = (ignoreRegularConflicts: boolean) => { - const params = setupParams({ objects, importIdMap, ignoreRegularConflicts }); + const params = setupParams({ objects, importStateMap, ignoreRegularConflicts }); // obj1 is a non-multi-namespace type, so it is skipped while searching mockFindResult(); // find for obj2: the result is no match mockFindResult(obj3); // find for obj4: the result is an inexact match with one destination that is exactly matched by obj3 so it is ignored -- accordingly, obj4 has no match @@ -483,9 +486,9 @@ describe('#checkOriginConflicts', () => { const params = setup(false); const checkOriginConflictsResult = await checkOriginConflicts(params); const expectedResult = { - importIdMap: new Map([ - [`${obj7.type}:${obj7.id}`, { id: 'uuidv4', omitOriginId: true }], - [`${obj8.type}:${obj8.id}`, { id: 'uuidv4', omitOriginId: true }], + importStateMap: new Map([ + [`${obj7.type}:${obj7.id}`, { destinationId: 'uuidv4', omitOriginId: true }], + [`${obj8.type}:${obj8.id}`, { destinationId: 'uuidv4', omitOriginId: true }], ]), errors: [ createConflictError(obj5, objA.id), @@ -500,10 +503,10 @@ describe('#checkOriginConflicts', () => { const params = setup(true); const checkOriginConflictsResult = await checkOriginConflicts(params); const expectedResult = { - importIdMap: new Map([ - [`${obj5.type}:${obj5.id}`, { id: objA.id }], - [`${obj7.type}:${obj7.id}`, { id: 'uuidv4', omitOriginId: true }], - [`${obj8.type}:${obj8.id}`, { id: 'uuidv4', omitOriginId: true }], + importStateMap: new Map([ + [`${obj5.type}:${obj5.id}`, { destinationId: objA.id }], + [`${obj7.type}:${obj7.id}`, { destinationId: 'uuidv4', omitOriginId: true }], + [`${obj8.type}:${obj8.id}`, { destinationId: 'uuidv4', omitOriginId: true }], ]), errors: [createAmbiguousConflictError(obj6, [objB, objC])], pendingOverwrites: new Set([`${obj5.type}:${obj5.id}`]), diff --git a/src/core/server/saved_objects/import/lib/check_origin_conflicts.ts b/src/core/server/saved_objects/import/lib/check_origin_conflicts.ts index efb6d3af06a55..fe4058ae868dd 100644 --- a/src/core/server/saved_objects/import/lib/check_origin_conflicts.ts +++ b/src/core/server/saved_objects/import/lib/check_origin_conflicts.ts @@ -10,6 +10,7 @@ import pMap from 'p-map'; import { v4 as uuidv4 } from 'uuid'; import { SavedObject, SavedObjectsClientContract, SavedObjectsImportFailure } from '../../types'; import { ISavedObjectTypeRegistry } from '../../saved_objects_type_registry'; +import type { ImportStateMap } from './types'; interface CheckOriginConflictsParams { objects: Array>; @@ -17,7 +18,7 @@ interface CheckOriginConflictsParams { typeRegistry: ISavedObjectTypeRegistry; namespace?: string; ignoreRegularConflicts?: boolean; - importIdMap: Map; + importStateMap: ImportStateMap; } type CheckOriginConflictParams = Omit & { @@ -76,8 +77,8 @@ const getAmbiguousConflictSourceKey = ({ object }: InexactMatch) => const checkOriginConflict = async ( params: CheckOriginConflictParams ): Promise> => { - const { object, savedObjectsClient, typeRegistry, namespace, importIdMap } = params; - const importIds = new Set(importIdMap.keys()); + const { object, savedObjectsClient, typeRegistry, namespace, importStateMap } = params; + const importIds = new Set(importStateMap.keys()); const { type, originId } = object; if (!typeRegistry.isMultiNamespace(type)) { @@ -126,7 +127,7 @@ const checkOriginConflict = async ( * that match this object's `originId` or `id` exist in the specified namespace: * - If this is a `Right` result; return the import object and allow `createSavedObjects` to handle the conflict (if any). * - If this is a `Left` "partial match" result: - * A. If there is a single source and destination match, add the destination to the importIdMap and return the import object, which + * A. If there is a single source and destination match, add the destination to the importStateMap and return the import object, which * will allow `createSavedObjects` to modify the ID before creating the object (thus ensuring a conflict during). * B. Otherwise, this is an "ambiguous conflict" result; return an error. */ @@ -148,7 +149,7 @@ export async function checkOriginConflicts({ objects, ...params }: CheckOriginCo }, new Map>>()); const errors: SavedObjectsImportFailure[] = []; - const importIdMap = new Map(); + const importStateMap: ImportStateMap = new Map(); const pendingOverwrites = new Set(); checkOriginConflictResults.forEach((result) => { if (!isLeft(result)) { @@ -163,7 +164,7 @@ export async function checkOriginConflicts({ objects, ...params }: CheckOriginCo if (sources.length === 1 && destinations.length === 1) { // This is a simple "inexact match" result -- a single import object has a single destination conflict. if (params.ignoreRegularConflicts) { - importIdMap.set(`${type}:${id}`, { id: destinations[0].id }); + importStateMap.set(`${type}:${id}`, { destinationId: destinations[0].id }); pendingOverwrites.add(`${type}:${id}`); } else { const { title } = attributes; @@ -187,7 +188,7 @@ export async function checkOriginConflicts({ objects, ...params }: CheckOriginCo if (sources.length > 1) { // In the case of ambiguous source conflicts, don't treat them as errors; instead, regenerate the object ID and reset its origin // (e.g., the same outcome as if `createNewCopies` was enabled for the entire import operation). - importIdMap.set(`${type}:${id}`, { id: uuidv4(), omitOriginId: true }); + importStateMap.set(`${type}:${id}`, { destinationId: uuidv4(), omitOriginId: true }); return; } const { title } = attributes; @@ -203,5 +204,5 @@ export async function checkOriginConflicts({ objects, ...params }: CheckOriginCo }); }); - return { errors, importIdMap, pendingOverwrites }; + return { errors, importStateMap, pendingOverwrites }; } diff --git a/src/core/server/saved_objects/import/lib/collect_saved_objects.test.ts b/src/core/server/saved_objects/import/lib/collect_saved_objects.test.ts index c6307070d9231..7247ca9371833 100644 --- a/src/core/server/saved_objects/import/lib/collect_saved_objects.test.ts +++ b/src/core/server/saved_objects/import/lib/collect_saved_objects.test.ts @@ -120,7 +120,7 @@ describe('collectSavedObjects()', () => { const readStream = createReadStream(); const result = await collectSavedObjects({ readStream, supportedTypes: [], objectLimit }); - expect(result).toEqual({ collectedObjects: [], errors: [], importIdMap: new Map() }); + expect(result).toEqual({ collectedObjects: [], errors: [], importStateMap: new Map() }); }); test('collects objects from stream', async () => { @@ -129,8 +129,8 @@ describe('collectSavedObjects()', () => { const result = await collectSavedObjects({ readStream, supportedTypes, objectLimit }); const collectedObjects = [{ ...obj1, migrationVersion: {} }]; - const importIdMap = new Map([[`${obj1.type}:${obj1.id}`, {}]]); - expect(result).toEqual({ collectedObjects, errors: [], importIdMap }); + const importStateMap = new Map([[`${obj1.type}:${obj1.id}`, {}]]); + expect(result).toEqual({ collectedObjects, errors: [], importStateMap }); }); test('unsupported types return as import errors', async () => { @@ -141,7 +141,7 @@ describe('collectSavedObjects()', () => { const error = { type: 'unsupported_type' }; const { title } = obj1.attributes; const errors = [{ error, type: obj1.type, id: obj1.id, title, meta: { title } }]; - expect(result).toEqual({ collectedObjects: [], errors, importIdMap: new Map() }); + expect(result).toEqual({ collectedObjects: [], errors, importStateMap: new Map() }); }); test('returns mixed results', async () => { @@ -150,11 +150,11 @@ describe('collectSavedObjects()', () => { const result = await collectSavedObjects({ readStream, supportedTypes, objectLimit }); const collectedObjects = [{ ...obj2, migrationVersion: {} }]; - const importIdMap = new Map([[`${obj2.type}:${obj2.id}`, {}]]); + const importStateMap = new Map([[`${obj2.type}:${obj2.id}`, {}]]); const error = { type: 'unsupported_type' }; const { title } = obj1.attributes; const errors = [{ error, type: obj1.type, id: obj1.id, title, meta: { title } }]; - expect(result).toEqual({ collectedObjects, errors, importIdMap }); + expect(result).toEqual({ collectedObjects, errors, importStateMap }); }); describe('with optional filter', () => { @@ -172,7 +172,7 @@ describe('collectSavedObjects()', () => { const error = { type: 'unsupported_type' }; const { title } = obj1.attributes; const errors = [{ error, type: obj1.type, id: obj1.id, title, meta: { title } }]; - expect(result).toEqual({ collectedObjects: [], errors, importIdMap: new Map() }); + expect(result).toEqual({ collectedObjects: [], errors, importStateMap: new Map() }); }); test('does not filter out objects when result === true', async () => { @@ -187,11 +187,11 @@ describe('collectSavedObjects()', () => { }); const collectedObjects = [{ ...obj2, migrationVersion: {} }]; - const importIdMap = new Map([[`${obj2.type}:${obj2.id}`, {}]]); + const importStateMap = new Map([[`${obj2.type}:${obj2.id}`, {}]]); const error = { type: 'unsupported_type' }; const { title } = obj1.attributes; const errors = [{ error, type: obj1.type, id: obj1.id, title, meta: { title } }]; - expect(result).toEqual({ collectedObjects, errors, importIdMap }); + expect(result).toEqual({ collectedObjects, errors, importStateMap }); }); }); }); diff --git a/src/core/server/saved_objects/import/lib/collect_saved_objects.ts b/src/core/server/saved_objects/import/lib/collect_saved_objects.ts index 58c7a759cf0bb..4e2888f46f1c0 100644 --- a/src/core/server/saved_objects/import/lib/collect_saved_objects.ts +++ b/src/core/server/saved_objects/import/lib/collect_saved_objects.ts @@ -19,6 +19,7 @@ import { SavedObjectsImportFailure } from '../types'; import { SavedObjectsImportError } from '../errors'; import { getNonUniqueEntries } from './get_non_unique_entries'; import { createLimitStream } from './create_limit_stream'; +import type { ImportStateMap } from './types'; interface CollectSavedObjectsOptions { readStream: Readable; @@ -35,7 +36,7 @@ export async function collectSavedObjects({ }: CollectSavedObjectsOptions) { const errors: SavedObjectsImportFailure[] = []; const entries: Array<{ type: string; id: string }> = []; - const importIdMap = new Map(); + const importStateMap: ImportStateMap = new Map(); const collectedObjects: Array> = await createPromiseFromStreams([ readStream, createLimitStream(objectLimit), @@ -58,7 +59,7 @@ export async function collectSavedObjects({ }), createFilterStream((obj) => (filter ? filter(obj) : true)), createMapStream((obj: SavedObject) => { - importIdMap.set(`${obj.type}:${obj.id}`, {}); + importStateMap.set(`${obj.type}:${obj.id}`, {}); // Ensure migrations execute on every saved object return Object.assign({ migrationVersion: {} }, obj); }), @@ -74,6 +75,6 @@ export async function collectSavedObjects({ return { errors, collectedObjects, - importIdMap, + importStateMap, }; } diff --git a/src/core/server/saved_objects/import/lib/create_saved_objects.test.ts b/src/core/server/saved_objects/import/lib/create_saved_objects.test.ts index 38372e8fad6fd..7f8b67406773e 100644 --- a/src/core/server/saved_objects/import/lib/create_saved_objects.test.ts +++ b/src/core/server/saved_objects/import/lib/create_saved_objects.test.ts @@ -23,8 +23,8 @@ const createObject = (type: string, id: string, originId?: string): SavedObject attributes: {}, references: [ { name: 'name-1', type: 'other-type', id: 'other-id' }, // object that is not present - { name: 'name-2', type: MULTI_NS_TYPE, id: 'id-1' }, // object that is present, but does not have an importIdMap entry - { name: 'name-3', type: MULTI_NS_TYPE, id: 'id-3' }, // object that is present and has an importIdMap entry + { name: 'name-2', type: MULTI_NS_TYPE, id: 'id-1' }, // object that is present, but does not have an importStateMap entry + { name: 'name-3', type: MULTI_NS_TYPE, id: 'id-3' }, // object that is present and has an importStateMap entry ], ...(originId && { originId }), }); @@ -52,10 +52,10 @@ const obj13 = createObject(OTHER_TYPE, 'id-13'); // -> conflict const importId3 = 'id-foo'; const importId4 = 'id-bar'; const importId8 = 'id-baz'; -const importIdMap = new Map([ - [`${obj3.type}:${obj3.id}`, { id: importId3, omitOriginId: true }], - [`${obj4.type}:${obj4.id}`, { id: importId4 }], - [`${obj8.type}:${obj8.id}`, { id: importId8 }], +const importStateMap = new Map([ + [`${obj3.type}:${obj3.id}`, { destinationId: importId3, omitOriginId: true }], + [`${obj4.type}:${obj4.id}`, { destinationId: importId4 }], + [`${obj8.type}:${obj8.id}`, { destinationId: importId8 }], ]); describe('#createSavedObjects', () => { @@ -74,7 +74,7 @@ describe('#createSavedObjects', () => { }): CreateSavedObjectsParams => { savedObjectsClient = savedObjectsClientMock.create(); bulkCreate = savedObjectsClient.bulkCreate; - return { accumulatedErrors: [], ...partial, savedObjectsClient, importIdMap }; + return { accumulatedErrors: [], ...partial, savedObjectsClient, importStateMap }; }; const getExpectedBulkCreateArgsObjects = (objects: SavedObject[], retry?: boolean) => @@ -84,8 +84,8 @@ describe('#createSavedObjects', () => { attributes, references: [ { name: 'name-1', type: 'other-type', id: 'other-id' }, // object that is not present - { name: 'name-2', type: MULTI_NS_TYPE, id: 'id-1' }, // object that is present, but does not have an importIdMap entry - { name: 'name-3', type: MULTI_NS_TYPE, id: 'id-foo' }, // object that is present and has an importIdMap entry + { name: 'name-2', type: MULTI_NS_TYPE, id: 'id-1' }, // object that is present, but does not have an importStateMap entry + { name: 'name-3', type: MULTI_NS_TYPE, id: 'id-foo' }, // object that is present and has an importStateMap entry ], // if the import object had an originId, and/or if we regenerated the id, expect an originId to be included in the create args ...((originId || retry) && { originId: originId || id }), @@ -245,7 +245,7 @@ describe('#createSavedObjects', () => { await createSavedObjects(options); expect(bulkCreate).toHaveBeenCalledTimes(1); - // these three objects are transformed before being created, because they are included in the `importIdMap` + // these three objects are transformed before being created, because they are included in the `importStateMap` const x3 = { ...obj3, id: importId3, originId: undefined }; // this import object already has an originId, but the entry has omitOriginId=true const x4 = { ...obj4, id: importId4 }; // this import object already has an originId const x8 = { ...obj8, id: importId8, originId: obj8.id }; // this import object doesn't have an originId, so it is set before create diff --git a/src/core/server/saved_objects/import/lib/create_saved_objects.ts b/src/core/server/saved_objects/import/lib/create_saved_objects.ts index 30a3b4df96125..bf58b2bb4b00e 100644 --- a/src/core/server/saved_objects/import/lib/create_saved_objects.ts +++ b/src/core/server/saved_objects/import/lib/create_saved_objects.ts @@ -9,12 +9,13 @@ import { SavedObject, SavedObjectsClientContract, SavedObjectsImportFailure } from '../../types'; import { extractErrors } from './extract_errors'; import { CreatedObject } from '../types'; +import type { ImportStateMap } from './types'; export interface CreateSavedObjectsParams { objects: Array>; accumulatedErrors: SavedObjectsImportFailure[]; savedObjectsClient: SavedObjectsClientContract; - importIdMap: Map; + importStateMap: ImportStateMap; namespace?: string; overwrite?: boolean; } @@ -31,7 +32,7 @@ export const createSavedObjects = async ({ objects, accumulatedErrors, savedObjectsClient, - importIdMap, + importStateMap, namespace, overwrite, }: CreateSavedObjectsParams): Promise> => { @@ -58,19 +59,24 @@ export const createSavedObjects = async ({ // use the import ID map to ensure that each reference is being created with the correct ID const references = object.references?.map((reference) => { const { type, id } = reference; - const importIdEntry = importIdMap.get(`${type}:${id}`); - if (importIdEntry?.id) { - return { ...reference, id: importIdEntry.id }; + const importStateValue = importStateMap.get(`${type}:${id}`); + if (importStateValue?.destinationId) { + return { ...reference, id: importStateValue.destinationId }; } return reference; }); // use the import ID map to ensure that each object is being created with the correct ID, also ensure that the `originId` is set on // the created object if it did not have one (or is omitted if specified) - const importIdEntry = importIdMap.get(`${object.type}:${object.id}`); - if (importIdEntry?.id) { - objectIdMap.set(`${object.type}:${importIdEntry.id}`, object); - const originId = importIdEntry.omitOriginId ? undefined : object.originId ?? object.id; - return { ...object, id: importIdEntry.id, originId, ...(references && { references }) }; + const importStateValue = importStateMap.get(`${object.type}:${object.id}`); + if (importStateValue?.destinationId) { + objectIdMap.set(`${object.type}:${importStateValue.destinationId}`, object); + const originId = importStateValue.omitOriginId ? undefined : object.originId ?? object.id; + return { + ...object, + id: importStateValue.destinationId, + originId, + ...(references && { references }), + }; } return { ...object, ...(references && { references }) }; }); diff --git a/src/core/server/saved_objects/import/lib/get_import_id_map_for_retries.test.ts b/src/core/server/saved_objects/import/lib/get_import_state_map_for_retries.test.ts similarity index 73% rename from src/core/server/saved_objects/import/lib/get_import_id_map_for_retries.test.ts rename to src/core/server/saved_objects/import/lib/get_import_state_map_for_retries.test.ts index 1a6c3fa43ea5c..af5aca10ba289 100644 --- a/src/core/server/saved_objects/import/lib/get_import_id_map_for_retries.test.ts +++ b/src/core/server/saved_objects/import/lib/get_import_state_map_for_retries.test.ts @@ -8,9 +8,9 @@ import type { SavedObject } from '../../types'; import type { SavedObjectsImportRetry } from '../types'; -import { getImportIdMapForRetries } from './get_import_id_map_for_retries'; +import { getImportStateMapForRetries } from './get_import_state_map_for_retries'; -describe('#getImportIdMapForRetries', () => { +describe('#getImportStateMapForRetries', () => { const createRetry = ( { type, id }: { type: string; id: string }, params: { destinationId?: string; createNewCopy?: boolean } = {} @@ -26,7 +26,7 @@ describe('#getImportIdMapForRetries', () => { const retries = [createRetry(obj1)]; const params = { objects, retries, createNewCopies: false }; - expect(() => getImportIdMapForRetries(params)).toThrowErrorMatchingInlineSnapshot( + expect(() => getImportStateMapForRetries(params)).toThrowErrorMatchingInlineSnapshot( `"Retry was expected for \\"type-2:id-2\\" but not found"` ); }); @@ -40,29 +40,29 @@ describe('#getImportIdMapForRetries', () => { const retries = [ createRetry(obj1), // retries that do not have `destinationId` specified are ignored createRetry(obj2, { destinationId: obj2.id }), // retries that have `id` that matches `destinationId` are ignored - createRetry(obj3, { destinationId: 'id-X' }), // this retry will get added to the `importIdMap`! - createRetry(obj4, { destinationId: 'id-Y', createNewCopy: true }), // this retry will get added to the `importIdMap`! + createRetry(obj3, { destinationId: 'id-X' }), // this retry will get added to the `importStateMap`! + createRetry(obj4, { destinationId: 'id-Y', createNewCopy: true }), // this retry will get added to the `importStateMap`! ]; const params = { objects, retries, createNewCopies: false }; - const result = await getImportIdMapForRetries(params); + const result = await getImportStateMapForRetries(params); expect(result).toEqual( new Map([ - [`${obj3.type}:${obj3.id}`, { id: 'id-X', omitOriginId: false }], - [`${obj4.type}:${obj4.id}`, { id: 'id-Y', omitOriginId: true }], + [`${obj3.type}:${obj3.id}`, { destinationId: 'id-X', omitOriginId: false }], + [`${obj4.type}:${obj4.id}`, { destinationId: 'id-Y', omitOriginId: true }], ]) ); }); - test('omits origin ID in `importIdMap` entries when createNewCopies=true', async () => { + test('omits origin ID in `importStateMap` entries when createNewCopies=true', async () => { const obj1 = { type: 'type-1', id: 'id-1' }; const objects = [obj1] as SavedObject[]; const retries = [createRetry(obj1, { destinationId: 'id-X' })]; const params = { objects, retries, createNewCopies: true }; - const result = await getImportIdMapForRetries(params); + const result = await getImportStateMapForRetries(params); expect(result).toEqual( - new Map([[`${obj1.type}:${obj1.id}`, { id: 'id-X', omitOriginId: true }]]) + new Map([[`${obj1.type}:${obj1.id}`, { destinationId: 'id-X', omitOriginId: true }]]) ); }); }); diff --git a/src/core/server/saved_objects/import/lib/get_import_id_map_for_retries.ts b/src/core/server/saved_objects/import/lib/get_import_state_map_for_retries.ts similarity index 78% rename from src/core/server/saved_objects/import/lib/get_import_id_map_for_retries.ts rename to src/core/server/saved_objects/import/lib/get_import_state_map_for_retries.ts index 888d3032842e0..3066ae72738a4 100644 --- a/src/core/server/saved_objects/import/lib/get_import_id_map_for_retries.ts +++ b/src/core/server/saved_objects/import/lib/get_import_state_map_for_retries.ts @@ -7,8 +7,9 @@ */ import { SavedObject, SavedObjectsImportRetry } from '../../types'; +import type { ImportStateMap } from './types'; -interface GetImportIdMapForRetriesParams { +interface GetImportStateMapForRetriesParams { objects: SavedObject[]; retries: SavedObjectsImportRetry[]; createNewCopies: boolean; @@ -17,14 +18,14 @@ interface GetImportIdMapForRetriesParams { /** * Assume that all objects exist in the `retries` map (due to filtering at the beginning of `resolveSavedObjectsImportErrors`). */ -export function getImportIdMapForRetries(params: GetImportIdMapForRetriesParams) { +export function getImportStateMapForRetries(params: GetImportStateMapForRetriesParams) { const { objects, retries, createNewCopies } = params; const retryMap = retries.reduce( (acc, cur) => acc.set(`${cur.type}:${cur.id}`, cur), new Map() ); - const importIdMap = new Map(); + const importStateMap: ImportStateMap = new Map(); objects.forEach(({ type, id }) => { const retry = retryMap.get(`${type}:${id}`); @@ -34,9 +35,9 @@ export function getImportIdMapForRetries(params: GetImportIdMapForRetriesParams) const { destinationId } = retry; const omitOriginId = createNewCopies || Boolean(retry.createNewCopy); if (destinationId && destinationId !== id) { - importIdMap.set(`${type}:${id}`, { id: destinationId, omitOriginId }); + importStateMap.set(`${type}:${id}`, { destinationId, omitOriginId }); } }); - return importIdMap; + return importStateMap; } diff --git a/src/core/server/saved_objects/import/lib/index.ts b/src/core/server/saved_objects/import/lib/index.ts index ea8fa376793d7..2f0e0aa9505dc 100644 --- a/src/core/server/saved_objects/import/lib/index.ts +++ b/src/core/server/saved_objects/import/lib/index.ts @@ -13,10 +13,11 @@ export { createLimitStream } from './create_limit_stream'; export { createObjectsFilter } from './create_objects_filter'; export { createSavedObjects } from './create_saved_objects'; export { extractErrors } from './extract_errors'; -export { getImportIdMapForRetries } from './get_import_id_map_for_retries'; +export { getImportStateMapForRetries } from './get_import_state_map_for_retries'; export { getNonUniqueEntries } from './get_non_unique_entries'; export { regenerateIds } from './regenerate_ids'; export { splitOverwrites } from './split_overwrites'; export { getNonExistingReferenceAsKeys, validateReferences } from './validate_references'; export { validateRetries } from './validate_retries'; export { executeImportHooks } from './execute_import_hooks'; +export type { ImportStateMap, ImportStateValue } from './types'; diff --git a/src/core/server/saved_objects/import/lib/regenerate_ids.test.ts b/src/core/server/saved_objects/import/lib/regenerate_ids.test.ts index 1cc14d9cfa428..d22b9431367d4 100644 --- a/src/core/server/saved_objects/import/lib/regenerate_ids.test.ts +++ b/src/core/server/saved_objects/import/lib/regenerate_ids.test.ts @@ -27,9 +27,9 @@ describe('#regenerateIds', () => { test('returns expected values', () => { expect(regenerateIds(objects)).toEqual( new Map([ - ['foo:1', { id: 'uuidv4 #1', omitOriginId: true }], - ['bar:2', { id: 'uuidv4 #2', omitOriginId: true }], - ['baz:3', { id: 'uuidv4 #3', omitOriginId: true }], + ['foo:1', { destinationId: 'uuidv4 #1', omitOriginId: true }], + ['bar:2', { destinationId: 'uuidv4 #2', omitOriginId: true }], + ['baz:3', { destinationId: 'uuidv4 #3', omitOriginId: true }], ]) ); }); diff --git a/src/core/server/saved_objects/import/lib/regenerate_ids.ts b/src/core/server/saved_objects/import/lib/regenerate_ids.ts index 01ce8bd93c01a..174658555aaf1 100644 --- a/src/core/server/saved_objects/import/lib/regenerate_ids.ts +++ b/src/core/server/saved_objects/import/lib/regenerate_ids.ts @@ -8,15 +8,17 @@ import { v4 as uuidv4 } from 'uuid'; import { SavedObject } from '../../types'; +import type { ImportStateMap } from './types'; /** - * Takes an array of saved objects and returns an importIdMap of randomly-generated new IDs. + * Takes an array of saved objects and returns an importStateMap of randomly-generated new IDs. * * @param objects The saved objects to generate new IDs for. */ export const regenerateIds = (objects: SavedObject[]) => { - const importIdMap = objects.reduce((acc, object) => { - return acc.set(`${object.type}:${object.id}`, { id: uuidv4(), omitOriginId: true }); - }, new Map()); - return importIdMap; + const importStateMap: ImportStateMap = new Map(); + for (const { type, id } of objects) { + importStateMap.set(`${type}:${id}`, { destinationId: uuidv4(), omitOriginId: true }); + } + return importStateMap; }; diff --git a/src/core/server/saved_objects/import/lib/types.ts b/src/core/server/saved_objects/import/lib/types.ts new file mode 100644 index 0000000000000..74cdf94fa1091 --- /dev/null +++ b/src/core/server/saved_objects/import/lib/types.ts @@ -0,0 +1,31 @@ +/* + * 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. + */ + +/** + * This map contains entries for objects that are included in the import operation. The entry key is the object's `type:id`, and the entry + * value contains optional attributes which change how that object is created. The initial map that is created by the collectSavedObjects + * module contains one entry with an empty value for each object that is being imported. + * + * This map is meant to function as a sort of accumulator; each module that is called during the import process can emit new entries that + * will override those from the initial map. + */ +export type ImportStateMap = Map; + +/** + * The value of an import state entry, which contains optional attributes that change how the object is created. + */ +export interface ImportStateValue { + /** + * This attribute indicates that the object should have this ID instead of what was specified in the import file. + */ + destinationId?: string; + /** + * This attribute indicates that the object's originId should be cleared. + */ + omitOriginId?: boolean; +} diff --git a/src/core/server/saved_objects/import/resolve_import_errors.test.mock.ts b/src/core/server/saved_objects/import/resolve_import_errors.test.mock.ts index 25f1c0fafb8df..8723fadcef024 100644 --- a/src/core/server/saved_objects/import/resolve_import_errors.test.mock.ts +++ b/src/core/server/saved_objects/import/resolve_import_errors.test.mock.ts @@ -12,7 +12,7 @@ import type { collectSavedObjects } from './lib/collect_saved_objects'; import type { regenerateIds } from './lib/regenerate_ids'; import type { validateReferences } from './lib/validate_references'; import type { checkConflicts } from './lib/check_conflicts'; -import type { getImportIdMapForRetries } from './lib/get_import_id_map_for_retries'; +import type { getImportStateMapForRetries } from './lib/get_import_state_map_for_retries'; import type { splitOverwrites } from './lib/split_overwrites'; import type { createSavedObjects } from './lib/create_saved_objects'; import type { executeImportHooks } from './lib/execute_import_hooks'; @@ -47,11 +47,11 @@ jest.mock('./lib/check_conflicts', () => ({ checkConflicts: mockCheckConflicts, })); -export const mockGetImportIdMapForRetries = jest.fn() as jest.MockedFunction< - typeof getImportIdMapForRetries +export const mockGetImportStateMapForRetries = jest.fn() as jest.MockedFunction< + typeof getImportStateMapForRetries >; -jest.mock('./lib/get_import_id_map_for_retries', () => ({ - getImportIdMapForRetries: mockGetImportIdMapForRetries, +jest.mock('./lib/get_import_state_map_for_retries', () => ({ + getImportStateMapForRetries: mockGetImportStateMapForRetries, })); export const mockSplitOverwrites = jest.fn() as jest.MockedFunction; diff --git a/src/core/server/saved_objects/import/resolve_import_errors.test.ts b/src/core/server/saved_objects/import/resolve_import_errors.test.ts index 9fc5a2c55ac90..ce6276af1fccb 100644 --- a/src/core/server/saved_objects/import/resolve_import_errors.test.ts +++ b/src/core/server/saved_objects/import/resolve_import_errors.test.ts @@ -13,7 +13,7 @@ import { mockRegenerateIds, mockValidateReferences, mockCheckConflicts, - mockGetImportIdMapForRetries, + mockGetImportStateMapForRetries, mockSplitOverwrites, mockCreateSavedObjects, mockExecuteImportHooks, @@ -46,17 +46,17 @@ describe('#importSavedObjectsFromStream', () => { mockCollectSavedObjects.mockResolvedValue({ errors: [], collectedObjects: [], - importIdMap: new Map(), + importStateMap: new Map(), }); mockRegenerateIds.mockReturnValue(new Map()); mockValidateReferences.mockResolvedValue([]); mockCheckConflicts.mockResolvedValue({ errors: [], filteredObjects: [], - importIdMap: new Map(), + importStateMap: new Map(), pendingOverwrites: new Set(), // not used by resolveImportErrors, but is a required return type }); - mockGetImportIdMapForRetries.mockReturnValue(new Map()); + mockGetImportStateMapForRetries.mockReturnValue(new Map()); mockSplitOverwrites.mockReturnValue({ objectsToOverwrite: [], objectsToNotOverwrite: [], @@ -139,7 +139,7 @@ describe('#importSavedObjectsFromStream', () => { /** * These tests use minimal mocks which don't look realistic, but are sufficient to exercise the code paths correctly. For example, for an * object to be imported successfully it would need to be obtained from `collectSavedObjects`, passed to `validateReferences`, passed to - * `getImportIdMapForRetries`, passed to `createSavedObjects`, and returned from that. However, for each of the tests below, we skip the + * `getImportStateMapForRetries`, passed to `createSavedObjects`, and returned from that. However, for each of the tests below, we skip the * intermediate steps in the interest of brevity. */ describe('module calls', () => { @@ -180,7 +180,7 @@ describe('#importSavedObjectsFromStream', () => { mockCollectSavedObjects.mockResolvedValue({ errors: [], collectedObjects, - importIdMap: new Map(), // doesn't matter + importStateMap: new Map(), // doesn't matter }); await resolveSavedObjectsImportErrors(options); @@ -201,7 +201,7 @@ describe('#importSavedObjectsFromStream', () => { mockCollectSavedObjects.mockResolvedValue({ errors: [], collectedObjects, - importIdMap: new Map(), + importStateMap: new Map(), }); mockCreateSavedObjects.mockResolvedValueOnce({ errors: [], @@ -228,7 +228,7 @@ describe('#importSavedObjectsFromStream', () => { mockCollectSavedObjects.mockResolvedValue({ errors: [], collectedObjects: [object], - importIdMap: new Map(), // doesn't matter + importStateMap: new Map(), // doesn't matter }); await resolveSavedObjectsImportErrors(options); @@ -252,7 +252,7 @@ describe('#importSavedObjectsFromStream', () => { mockCollectSavedObjects.mockResolvedValue({ errors: [], collectedObjects, - importIdMap: new Map(), // doesn't matter + importStateMap: new Map(), // doesn't matter }); await resolveSavedObjectsImportErrors(options); @@ -274,13 +274,19 @@ describe('#importSavedObjectsFromStream', () => { mockCheckConflicts.mockResolvedValue({ errors: [], filteredObjects, - importIdMap: new Map(), + importStateMap: new Map(), pendingOverwrites: new Set(), // not used by resolveImportErrors, but is a required return type }); await resolveSavedObjectsImportErrors(options); - const getImportIdMapForRetriesParams = { objects: filteredObjects, retries, createNewCopies }; - expect(mockGetImportIdMapForRetries).toHaveBeenCalledWith(getImportIdMapForRetriesParams); + const getImportStateMapForRetriesParams = { + objects: filteredObjects, + retries, + createNewCopies, + }; + expect(mockGetImportStateMapForRetries).toHaveBeenCalledWith( + getImportStateMapForRetriesParams + ); }); test('splits objects to overwrite from those not to overwrite', async () => { @@ -290,7 +296,7 @@ describe('#importSavedObjectsFromStream', () => { mockCollectSavedObjects.mockResolvedValue({ errors: [], collectedObjects, - importIdMap: new Map(), // doesn't matter + importStateMap: new Map(), // doesn't matter }); await resolveSavedObjectsImportErrors(options); @@ -304,7 +310,7 @@ describe('#importSavedObjectsFromStream', () => { mockCollectSavedObjects.mockResolvedValue({ errors: [], collectedObjects, - importIdMap: new Map(), // doesn't matter + importStateMap: new Map(), // doesn't matter }); await resolveSavedObjectsImportErrors(options); @@ -317,24 +323,24 @@ describe('#importSavedObjectsFromStream', () => { mockCollectSavedObjects.mockResolvedValue({ errors: [errors[0]], collectedObjects: [], // doesn't matter - importIdMap: new Map(), // doesn't matter + importStateMap: new Map(), // doesn't matter }); mockValidateReferences.mockResolvedValue([errors[1]]); mockCheckConflicts.mockResolvedValue({ errors: [errors[2]], filteredObjects: [], - importIdMap: new Map([['foo', { id: 'someId' }]]), + importStateMap: new Map([['foo', { destinationId: 'someId' }]]), pendingOverwrites: new Set(), // not used by resolveImportErrors, but is a required return type }); - mockGetImportIdMapForRetries.mockReturnValue( + mockGetImportStateMapForRetries.mockReturnValue( new Map([ - ['foo', { id: 'newId' }], - ['bar', { id: 'anotherNewId' }], + ['foo', { destinationId: 'newId' }], + ['bar', { destinationId: 'anotherNewId' }], ]) ); - const importIdMap = new Map([ - ['foo', { id: 'someId' }], - ['bar', { id: 'anotherNewId' }], + const importStateMap = new Map([ + ['foo', { destinationId: 'someId' }], + ['bar', { destinationId: 'anotherNewId' }], ]); const objectsToOverwrite = [createObject()]; const objectsToNotOverwrite = [createObject()]; @@ -348,7 +354,7 @@ describe('#importSavedObjectsFromStream', () => { const partialCreateSavedObjectsParams = { accumulatedErrors: errors, savedObjectsClient, - importIdMap, + importStateMap, namespace, }; expect(mockCreateSavedObjects).toHaveBeenNthCalledWith(1, { @@ -370,7 +376,7 @@ describe('#importSavedObjectsFromStream', () => { mockCollectSavedObjects.mockResolvedValue({ errors: [], collectedObjects, - importIdMap: new Map(), // doesn't matter + importStateMap: new Map(), // doesn't matter }); await resolveSavedObjectsImportErrors(options); @@ -383,32 +389,32 @@ describe('#importSavedObjectsFromStream', () => { mockCollectSavedObjects.mockResolvedValue({ errors: [errors[0]], collectedObjects: [], // doesn't matter - importIdMap: new Map(), // doesn't matter + importStateMap: new Map(), // doesn't matter }); mockValidateReferences.mockResolvedValue([errors[1]]); mockRegenerateIds.mockReturnValue( new Map([ - ['foo', { id: 'randomId1' }], - ['bar', { id: 'randomId2' }], - ['baz', { id: 'randomId3' }], + ['foo', { destinationId: 'randomId1' }], + ['bar', { destinationId: 'randomId2' }], + ['baz', { destinationId: 'randomId3' }], ]) ); mockCheckConflicts.mockResolvedValue({ errors: [errors[2]], filteredObjects: [], - importIdMap: new Map([['bar', { id: 'someId' }]]), + importStateMap: new Map([['bar', { destinationId: 'someId' }]]), pendingOverwrites: new Set(), // not used by resolveImportErrors, but is a required return type }); - mockGetImportIdMapForRetries.mockReturnValue( + mockGetImportStateMapForRetries.mockReturnValue( new Map([ - ['bar', { id: 'newId' }], - ['baz', { id: 'anotherNewId' }], + ['bar', { destinationId: 'newId' }], + ['baz', { destinationId: 'anotherNewId' }], ]) ); - const importIdMap = new Map([ - ['foo', { id: 'randomId1' }], - ['bar', { id: 'someId' }], - ['baz', { id: 'anotherNewId' }], + const importStateMap = new Map([ + ['foo', { destinationId: 'randomId1' }], + ['bar', { destinationId: 'someId' }], + ['baz', { destinationId: 'anotherNewId' }], ]); const objectsToOverwrite = [createObject()]; const objectsToNotOverwrite = [createObject()]; @@ -422,7 +428,7 @@ describe('#importSavedObjectsFromStream', () => { const partialCreateSavedObjectsParams = { accumulatedErrors: errors, savedObjectsClient, - importIdMap, + importStateMap, namespace, }; expect(mockCreateSavedObjects).toHaveBeenNthCalledWith(1, { @@ -451,7 +457,7 @@ describe('#importSavedObjectsFromStream', () => { mockCollectSavedObjects.mockResolvedValue({ errors: [createError()], collectedObjects: [], - importIdMap: new Map(), // doesn't matter + importStateMap: new Map(), // doesn't matter }); const result = await resolveSavedObjectsImportErrors(options); @@ -469,7 +475,7 @@ describe('#importSavedObjectsFromStream', () => { mockCollectSavedObjects.mockResolvedValue({ errors: [], collectedObjects, - importIdMap: new Map(), + importStateMap: new Map(), }); mockCreateSavedObjects.mockResolvedValueOnce({ errors: [], @@ -558,7 +564,7 @@ describe('#importSavedObjectsFromStream', () => { mockCheckConflicts.mockResolvedValue({ errors: [], filteredObjects: [], - importIdMap: new Map(), + importStateMap: new Map(), pendingOverwrites: new Set(), }); mockCreateSavedObjects @@ -596,7 +602,7 @@ describe('#importSavedObjectsFromStream', () => { mockCollectSavedObjects.mockResolvedValue({ errors: [errors[0]], collectedObjects: [], - importIdMap: new Map(), // doesn't matter + importStateMap: new Map(), // doesn't matter }); mockValidateReferences.mockResolvedValue([errors[1]]); mockCreateSavedObjects.mockResolvedValueOnce({ diff --git a/src/core/server/saved_objects/import/resolve_import_errors.ts b/src/core/server/saved_objects/import/resolve_import_errors.ts index 25382965e845b..f6ed9de3825e6 100644 --- a/src/core/server/saved_objects/import/resolve_import_errors.ts +++ b/src/core/server/saved_objects/import/resolve_import_errors.ts @@ -23,9 +23,10 @@ import { validateReferences, validateRetries, createSavedObjects, - getImportIdMapForRetries, + getImportStateMapForRetries, checkConflicts, executeImportHooks, + ImportStateMap, } from './lib'; /** @@ -71,7 +72,7 @@ export async function resolveSavedObjectsImportErrors({ let successCount = 0; let errorAccumulator: SavedObjectsImportFailure[] = []; - let importIdMap: Map = new Map(); + let importStateMap: ImportStateMap = new Map(); const supportedTypes = typeRegistry.getImportableAndExportableTypes().map((type) => type.name); const filter = createObjectsFilter(retries); @@ -122,7 +123,7 @@ export async function resolveSavedObjectsImportErrors({ if (createNewCopies) { // In case any missing reference errors were resolved, ensure that we regenerate those object IDs as well // This is because a retry to resolve a missing reference error may not necessarily specify a destinationId - importIdMap = regenerateIds(objectsToResolve); + importStateMap = regenerateIds(objectsToResolve); } // Check single-namespace objects for conflicts in this namespace, and check multi-namespace objects for conflicts across all namespaces @@ -137,16 +138,16 @@ export async function resolveSavedObjectsImportErrors({ errorAccumulator = [...errorAccumulator, ...checkConflictsResult.errors]; // Check multi-namespace object types for regular conflicts and ambiguous conflicts - const getImportIdMapForRetriesParams = { + const getImportStateMapForRetriesParams = { objects: checkConflictsResult.filteredObjects, retries, createNewCopies, }; - const importIdMapForRetries = getImportIdMapForRetries(getImportIdMapForRetriesParams); - importIdMap = new Map([ - ...importIdMap, - ...importIdMapForRetries, - ...checkConflictsResult.importIdMap, // this importIdMap takes precedence over the others + const importStateMapForRetries = getImportStateMapForRetries(getImportStateMapForRetriesParams); + importStateMap = new Map([ + ...importStateMap, + ...importStateMapForRetries, + ...checkConflictsResult.importStateMap, // this importStateMap takes precedence over the others ]); // Bulk create in two batches, overwrites and non-overwrites @@ -161,7 +162,7 @@ export async function resolveSavedObjectsImportErrors({ objects, accumulatedErrors, savedObjectsClient, - importIdMap, + importStateMap, namespace, overwrite, }; From bb26d6ce5627f88bf1840270fcaaf7bcccc5ff46 Mon Sep 17 00:00:00 2001 From: Joe Portner Date: Fri, 10 Dec 2021 11:43:28 -0500 Subject: [PATCH 4/8] Fix import exact match bug (issue 121038) --- .../import/import_saved_objects.test.ts | 4 +++- .../import/import_saved_objects.ts | 1 + .../import/lib/check_origin_conflicts.test.ts | 16 +++++++++++++++- .../import/lib/check_origin_conflicts.ts | 12 ++++++++---- .../common/suites/import.ts | 1 + .../security_and_spaces/apis/import.ts | 18 ++++++++++++++++-- .../spaces_only/apis/import.ts | 17 +++++++++++++++-- 7 files changed, 59 insertions(+), 10 deletions(-) diff --git a/src/core/server/saved_objects/import/import_saved_objects.test.ts b/src/core/server/saved_objects/import/import_saved_objects.test.ts index 72011eee0e91b..dab4940a311e2 100644 --- a/src/core/server/saved_objects/import/import_saved_objects.test.ts +++ b/src/core/server/saved_objects/import/import_saved_objects.test.ts @@ -215,11 +215,12 @@ describe('#importSavedObjectsFromStream', () => { const options = setupOptions(); const filteredObjects = [createObject()]; const importStateMap = new Map(); + const pendingOverwrites = new Set(); mockCheckConflicts.mockResolvedValue({ errors: [], filteredObjects, importStateMap, - pendingOverwrites: new Set(), + pendingOverwrites, }); await importSavedObjectsFromStream(options); @@ -230,6 +231,7 @@ describe('#importSavedObjectsFromStream', () => { namespace, ignoreRegularConflicts: overwrite, importStateMap, + pendingOverwrites, }; expect(mockCheckOriginConflicts).toHaveBeenCalledWith(checkOriginConflictsParams); }); diff --git a/src/core/server/saved_objects/import/import_saved_objects.ts b/src/core/server/saved_objects/import/import_saved_objects.ts index 851d36672b58c..389f47e2fe0e1 100644 --- a/src/core/server/saved_objects/import/import_saved_objects.ts +++ b/src/core/server/saved_objects/import/import_saved_objects.ts @@ -107,6 +107,7 @@ export async function importSavedObjectsFromStream({ namespace, ignoreRegularConflicts: overwrite, importStateMap, + pendingOverwrites, }; const checkOriginConflictsResult = await checkOriginConflicts(checkOriginConflictsParams); errorAccumulator = [...errorAccumulator, ...checkOriginConflictsResult.errors]; diff --git a/src/core/server/saved_objects/import/lib/check_origin_conflicts.test.ts b/src/core/server/saved_objects/import/lib/check_origin_conflicts.test.ts index 3d25b66edf5fe..ca901542d8fe4 100644 --- a/src/core/server/saved_objects/import/lib/check_origin_conflicts.test.ts +++ b/src/core/server/saved_objects/import/lib/check_origin_conflicts.test.ts @@ -60,8 +60,9 @@ describe('#checkOriginConflicts', () => { const setupParams = (partial: { objects: SavedObjectType[]; namespace?: string; - importStateMap?: ImportStateMap; ignoreRegularConflicts?: boolean; + importStateMap?: ImportStateMap; + pendingOverwrites?: Set; }): CheckOriginConflictsParams => { savedObjectsClient = savedObjectsClientMock.create(); find = savedObjectsClient.find; @@ -70,6 +71,7 @@ describe('#checkOriginConflicts', () => { typeRegistry.isMultiNamespace.mockImplementation((type) => type === MULTI_NS_TYPE); return { importStateMap: new Map(), // empty by default + pendingOverwrites: new Set(), // empty by default ...partial, savedObjectsClient, typeRegistry, @@ -104,6 +106,18 @@ describe('#checkOriginConflicts', () => { expect(find).not.toHaveBeenCalled(); }); + test('does not execute searches for multi-namespace objects that already have pending overwrites (exact match conflicts)', async () => { + const objects = [multiNsObj, multiNsObjWithOriginId]; + const pendingOverwrites = new Set([ + `${multiNsObj.type}:${multiNsObj.id}`, + `${multiNsObjWithOriginId.type}:${multiNsObjWithOriginId.id}`, + ]); + const params = setupParams({ objects, pendingOverwrites }); + + await checkOriginConflicts(params); + expect(find).not.toHaveBeenCalled(); + }); + test('executes searches for multi-namespace objects', async () => { const objects = [multiNsObj, otherObj, multiNsObjWithOriginId, otherObjWithOriginId]; const params1 = setupParams({ objects }); diff --git a/src/core/server/saved_objects/import/lib/check_origin_conflicts.ts b/src/core/server/saved_objects/import/lib/check_origin_conflicts.ts index fe4058ae868dd..fcde23e78a218 100644 --- a/src/core/server/saved_objects/import/lib/check_origin_conflicts.ts +++ b/src/core/server/saved_objects/import/lib/check_origin_conflicts.ts @@ -19,6 +19,7 @@ interface CheckOriginConflictsParams { namespace?: string; ignoreRegularConflicts?: boolean; importStateMap: ImportStateMap; + pendingOverwrites: Set; } type CheckOriginConflictParams = Omit & { @@ -71,18 +72,21 @@ const getAmbiguousConflictSourceKey = ({ object }: InexactMatch) => * specified namespace: * - A `Right` result indicates that no conflict destinations were found in this namespace ("no match"). * - A `Left` result indicates that one or more conflict destinations exist in this namespace, none of which exactly match this object's ID - * ("inexact match"). We can make this assumption because any "exact match" results would have been obtained and filtered out by the - * `checkConflicts` submodule, which is called before this. + * ("inexact match"). We can make this assumption because any "exact match" conflict errors would have been obtained and filtered out by + * the `checkConflicts` submodule, which is called before this, *or* if `overwrite: true` is used, we explicitly filter out any pending + * overwrites for exact matches. */ const checkOriginConflict = async ( params: CheckOriginConflictParams ): Promise> => { - const { object, savedObjectsClient, typeRegistry, namespace, importStateMap } = params; + const { object, savedObjectsClient, typeRegistry, namespace, importStateMap, pendingOverwrites } = + params; const importIds = new Set(importStateMap.keys()); const { type, originId } = object; - if (!typeRegistry.isMultiNamespace(type)) { + if (!typeRegistry.isMultiNamespace(type) || pendingOverwrites.has(`${type}:${object.id}`)) { // Skip the search request for non-multi-namespace types, since by definition they cannot have inexact matches or ambiguous conflicts. + // Also skip the search request for objects that we've already determined have an "exact match" conflict. return { tag: 'right', value: object }; } diff --git a/x-pack/test/saved_object_api_integration/common/suites/import.ts b/x-pack/test/saved_object_api_integration/common/suites/import.ts index 04e0f3c41ed87..b959b32f5fb95 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/import.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/import.ts @@ -43,6 +43,7 @@ export const TEST_CASES: Record = Object.freeze({ CONFLICT_1_OBJ: Object.freeze({ type: 'sharedtype', id: `${CID}1` }), CONFLICT_1A_OBJ: Object.freeze({ type: 'sharedtype', id: `${CID}1a`, originId: `${CID}1` }), CONFLICT_1B_OBJ: Object.freeze({ type: 'sharedtype', id: `${CID}1b`, originId: `${CID}1` }), + CONFLICT_2A_OBJ: Object.freeze({ type: 'sharedtype', id: `${CID}2a`, originId: `${CID}2` }), CONFLICT_2C_OBJ: Object.freeze({ type: 'sharedtype', id: `${CID}2c`, originId: `${CID}2` }), CONFLICT_2D_OBJ: Object.freeze({ type: 'sharedtype', id: `${CID}2d`, originId: `${CID}2` }), CONFLICT_3A_OBJ: Object.freeze({ diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/import.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/import.ts index 1992dd6fea224..70ee1db175534 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/import.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/import.ts @@ -96,6 +96,18 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { { ...CASES.CONFLICT_2C_OBJ, ...ambiguousConflict('2c') }, // "ambiguous destination" conflict ]; const group4 = [ + // This group needs to be executed *after* the previous test case, because those error assertions include metadata of the destinations, + // and *these* test cases would change that metadata. + { ...CASES.CONFLICT_2A_OBJ, ...fail409(!overwrite) }, // "exact match" conflict with 2a + { + // "inexact match" conflict with 2b (since 2a already has a conflict source, this is not an ambiguous destination conflict) + ...CASES.CONFLICT_2C_OBJ, + ...fail409(!overwrite), + ...destinationId(), + expectedNewId: 'conflict_2b', + }, + ]; + const group5 = [ // when overwrite=true, all of the objects in this group are created successfully, so we can check the created object attributes { ...CASES.CONFLICT_1_OBJ, ...fail409(!overwrite) }, // "exact match" conflict CASES.CONFLICT_1A_OBJ, // no conflict because CONFLICT_1_OBJ is an exact match @@ -103,7 +115,7 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { { ...CASES.CONFLICT_2C_OBJ, ...newCopy() }, // "ambiguous source and destination" conflict which results in a new destination ID and empty origin ID { ...CASES.CONFLICT_2D_OBJ, ...newCopy() }, // "ambiguous source and destination" conflict which results in a new destination ID and empty origin ID ]; - return { group1Importable, group1NonImportable, group1All, group2, group3, group4 }; + return { group1Importable, group1NonImportable, group1All, group2, group3, group4, group5 }; }; export default function ({ getService }: FtrProviderContext) { @@ -141,7 +153,7 @@ export default function ({ getService }: FtrProviderContext) { }; } - const { group1Importable, group1NonImportable, group1All, group2, group3, group4 } = + const { group1Importable, group1NonImportable, group1All, group2, group3, group4, group5 } = createTestCases(overwrite, spaceId); return { unauthorized: [ @@ -156,12 +168,14 @@ export default function ({ getService }: FtrProviderContext) { createTestDefinitions(group2, true, { overwrite, spaceId, singleRequest }), createTestDefinitions(group3, true, { overwrite, spaceId, singleRequest }), createTestDefinitions(group4, true, { overwrite, spaceId, singleRequest }), + createTestDefinitions(group5, true, { overwrite, spaceId, singleRequest }), ].flat(), authorized: [ createTestDefinitions(group1All, false, { overwrite, spaceId, singleRequest }), createTestDefinitions(group2, false, { overwrite, spaceId, singleRequest }), createTestDefinitions(group3, false, { overwrite, spaceId, singleRequest }), createTestDefinitions(group4, false, { overwrite, spaceId, singleRequest }), + createTestDefinitions(group5, false, { overwrite, spaceId, singleRequest }), ].flat(), }; }; diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/import.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/import.ts index 910b51a92ed81..df15354ae2673 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/import.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/import.ts @@ -87,6 +87,18 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { { ...CASES.CONFLICT_2C_OBJ, ...ambiguousConflict('2c') }, // "ambiguous destination" conflict ]; const group3 = [ + // This group needs to be executed *after* the previous test case, because those error assertions include metadata of the destinations, + // and *these* test cases would change that metadata. + { ...CASES.CONFLICT_2A_OBJ, ...fail409(!overwrite) }, // "exact match" conflict with 2a + { + // "inexact match" conflict with 2b (since 2a already has a conflict source, this is not an ambiguous destination conflict) + ...CASES.CONFLICT_2C_OBJ, + ...fail409(!overwrite), + ...destinationId(), + expectedNewId: 'conflict_2b', + }, + ]; + const group4 = [ // when overwrite=true, all of the objects in this group are created successfully, so we can check the created object attributes { ...CASES.CONFLICT_1_OBJ, ...fail409(!overwrite) }, // "exact match" conflict CASES.CONFLICT_1A_OBJ, // no conflict because CONFLICT_1_OBJ is an exact match @@ -94,7 +106,7 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { { ...CASES.CONFLICT_2C_OBJ, ...newCopy() }, // "ambiguous source and destination" conflict which results in a new destination ID and empty origin ID { ...CASES.CONFLICT_2D_OBJ, ...newCopy() }, // "ambiguous source and destination" conflict which results in a new destination ID and empty origin ID ]; - return { group1, group2, group3 }; + return { group1, group2, group3, group4 }; }; export default function ({ getService }: FtrProviderContext) { @@ -110,11 +122,12 @@ export default function ({ getService }: FtrProviderContext) { return createTestDefinitions(cases, false, { createNewCopies, spaceId, singleRequest }); } - const { group1, group2, group3 } = createTestCases(overwrite, spaceId); + const { group1, group2, group3, group4 } = createTestCases(overwrite, spaceId); return [ createTestDefinitions(group1, false, { overwrite, spaceId, singleRequest }), createTestDefinitions(group2, false, { overwrite, spaceId, singleRequest }), createTestDefinitions(group3, false, { overwrite, spaceId, singleRequest }), + createTestDefinitions(group4, false, { overwrite, spaceId, singleRequest }), ].flat(); }; From 9184efba30e52a2379103a95fc4d76cee81d309e Mon Sep 17 00:00:00 2001 From: Joe Portner Date: Thu, 30 Dec 2021 10:58:38 -0500 Subject: [PATCH 5/8] Fix copy inexact match bug (issue 122149) --- .../import/lib/check_origin_conflicts.test.ts | 15 +-- .../import/lib/check_origin_conflicts.ts | 12 +- .../saved_objects/spaces/data.json | 106 ++++++++++++++++-- .../common/suites/copy_to_space.ts | 104 +++++++++++++++-- .../common/suites/delete.ts | 6 +- .../suites/resolve_copy_to_space_conflicts.ts | 75 +++++++++++-- 6 files changed, 272 insertions(+), 46 deletions(-) diff --git a/src/core/server/saved_objects/import/lib/check_origin_conflicts.test.ts b/src/core/server/saved_objects/import/lib/check_origin_conflicts.test.ts index ca901542d8fe4..1c16929e3a1a8 100644 --- a/src/core/server/saved_objects/import/lib/check_origin_conflicts.test.ts +++ b/src/core/server/saved_objects/import/lib/check_origin_conflicts.test.ts @@ -89,9 +89,9 @@ describe('#checkOriginConflicts', () => { // non-multi-namespace types shouldn't have origin IDs, but we include a test case to ensure it's handled gracefully const otherObjWithOriginId = createObject(OTHER_TYPE, 'id-4', 'originId-bar'); - const expectFindArgs = (n: number, object: SavedObject, rawIdPrefix: string) => { + const expectFindArgs = (n: number, object: SavedObject) => { const { type, id, originId } = object; - const search = `"${rawIdPrefix}${type}:${originId || id}" | "${originId || id}"`; // this template works for our basic test cases + const search = `"${type}:${originId || id}" | "${originId || id}"`; // this template works for our basic test cases const expectedArgs = expect.objectContaining({ type, search }); // exclude rootSearchFields, page, perPage, and fields attributes from assertion -- these are constant // exclude namespace from assertion -- a separate test covers that @@ -124,15 +124,8 @@ describe('#checkOriginConflicts', () => { await checkOriginConflicts(params1); expect(find).toHaveBeenCalledTimes(2); - expectFindArgs(1, multiNsObj, ''); - expectFindArgs(2, multiNsObjWithOriginId, ''); - - find.mockClear(); - const params2 = setupParams({ objects, namespace: 'some-namespace' }); - await checkOriginConflicts(params2); - expect(find).toHaveBeenCalledTimes(2); - expectFindArgs(1, multiNsObj, 'some-namespace:'); - expectFindArgs(2, multiNsObjWithOriginId, 'some-namespace:'); + expectFindArgs(1, multiNsObj); + expectFindArgs(2, multiNsObjWithOriginId); }); test('searches within the current `namespace`', async () => { diff --git a/src/core/server/saved_objects/import/lib/check_origin_conflicts.ts b/src/core/server/saved_objects/import/lib/check_origin_conflicts.ts index fcde23e78a218..7e263d75cceea 100644 --- a/src/core/server/saved_objects/import/lib/check_origin_conflicts.ts +++ b/src/core/server/saved_objects/import/lib/check_origin_conflicts.ts @@ -44,8 +44,10 @@ const isLeft = (object: Either): object is Left => object.tag === 'left const MAX_CONCURRENT_SEARCHES = 10; const createQueryTerm = (input: string) => input.replace(/\\/g, '\\\\').replace(/\"/g, '\\"'); -const createQuery = (type: string, id: string, rawIdPrefix: string) => - `"${createQueryTerm(`${rawIdPrefix}${type}:${id}`)}" | "${createQueryTerm(id)}"`; +const createQuery = (type: string, id: string) => + // 1st query term will match raw object IDs (_id), 2nd query term will match originId + // we intentionally do not include a namespace prefix for the raw object IDs, because this search is only for multi-namespace object types + `"${createQueryTerm(`${type}:${id}`)}" | "${createQueryTerm(id)}"`; const transformObjectsToAmbiguousConflictFields = ( objects: Array> ) => @@ -82,15 +84,15 @@ const checkOriginConflict = async ( const { object, savedObjectsClient, typeRegistry, namespace, importStateMap, pendingOverwrites } = params; const importIds = new Set(importStateMap.keys()); - const { type, originId } = object; + const { type, originId, id } = object; - if (!typeRegistry.isMultiNamespace(type) || pendingOverwrites.has(`${type}:${object.id}`)) { + if (!typeRegistry.isMultiNamespace(type) || pendingOverwrites.has(`${type}:${id}`)) { // Skip the search request for non-multi-namespace types, since by definition they cannot have inexact matches or ambiguous conflicts. // Also skip the search request for objects that we've already determined have an "exact match" conflict. return { tag: 'right', value: object }; } - const search = createQuery(type, originId || object.id, namespace ? `${namespace}:` : ''); + const search = createQuery(type, originId || id); const findOptions = { type, search, diff --git a/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json b/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json index c5dc147b45123..ab7118c132f1b 100644 --- a/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json +++ b/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json @@ -673,12 +673,12 @@ { "type": "doc", "value": { - "id": "sharedtype:conflict_1_default", + "id": "sharedtype:conflict_1a_default", "index": ".kibana", "source": { - "originId": "conflict_1", + "originId": "conflict_1a", "sharedtype": { - "title": "A shared saved-object in one space" + "title": "This is used to test an inexact match conflict for an originId -> originId match" }, "type": "sharedtype", "namespaces": ["default"], @@ -691,12 +691,12 @@ { "type": "doc", "value": { - "id": "sharedtype:conflict_1_space_1", + "id": "sharedtype:conflict_1a_space_1", "index": ".kibana", "source": { - "originId": "conflict_1", + "originId": "conflict_1a", "sharedtype": { - "title": "A shared saved-object in one space" + "title": "This is used to test an inexact match conflict for an originId -> originId match" }, "type": "sharedtype", "namespaces": ["space_1"], @@ -709,12 +709,100 @@ { "type": "doc", "value": { - "id": "sharedtype:conflict_1_space_2", + "id": "sharedtype:conflict_1a_space_2", "index": ".kibana", "source": { - "originId": "conflict_1", + "originId": "conflict_1a", "sharedtype": { - "title": "A shared saved-object in one space" + "title": "This is used to test an inexact match conflict for an originId -> originId match" + }, + "type": "sharedtype", + "namespaces": ["space_2"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "sharedtype:conflict_1b_default", + "index": ".kibana", + "source": { + "originId": "conflict_1b_space_2", + "sharedtype": { + "title": "This is used to test an inexact match conflict for an originId -> id match" + }, + "type": "sharedtype", + "namespaces": ["default"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "sharedtype:conflict_1b_space_1", + "index": ".kibana", + "source": { + "originId": "conflict_1b_space_2", + "sharedtype": { + "title": "This is used to test an inexact match conflict for an originId -> id match" + }, + "type": "sharedtype", + "namespaces": ["space_1"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "sharedtype:conflict_1b_space_2", + "index": ".kibana", + "source": { + "sharedtype": { + "title": "This is used to test an inexact match conflict for an originId -> id match" + }, + "type": "sharedtype", + "namespaces": ["space_2"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "sharedtype:conflict_1c_default_and_space_1", + "index": ".kibana", + "source": { + "sharedtype": { + "title": "This is used to test an inexact match conflict for an id -> originId match" + }, + "type": "sharedtype", + "namespaces": ["default", "space_1"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "sharedtype:conflict_1c_space_2", + "index": ".kibana", + "source": { + "originId": "conflict_1c_default_and_space_1", + "sharedtype": { + "title": "This is used to test an inexact match conflict for an id -> originId match" }, "type": "sharedtype", "namespaces": ["space_2"], diff --git a/x-pack/test/spaces_api_integration/common/suites/copy_to_space.ts b/x-pack/test/spaces_api_integration/common/suites/copy_to_space.ts index d0f83bb6574ef..8ade75e90b541 100644 --- a/x-pack/test/spaces_api_integration/common/suites/copy_to_space.ts +++ b/x-pack/test/spaces_api_integration/common/suites/copy_to_space.ts @@ -482,7 +482,9 @@ export function copyToSpaceTestSuiteFactory( const type = 'sharedtype'; const noConflictId = `${spaceId}_only`; const exactMatchId = 'each_space'; - const inexactMatchId = `conflict_1_${spaceId}`; + const inexactMatchIdA = `conflict_1a_${spaceId}`; + const inexactMatchIdB = `conflict_1b_${spaceId}`; + const inexactMatchIdC = `conflict_1c_default_and_space_1`; const ambiguousConflictId = `conflict_2_${spaceId}`; const getResult = (response: TestResponse) => (response.body as CopyResponse).space_2; @@ -560,22 +562,108 @@ export function copyToSpaceTestSuiteFactory( }, }, { - testTitle: 'copying with an inexact match conflict', - objects: [{ type, id: inexactMatchId }], + testTitle: + 'copying with an inexact match conflict (a) - originId matches existing originId', + objects: [{ type, id: inexactMatchIdA }], statusCode, response: async (response: TestResponse) => { if (outcome === 'authorized') { const { success, successCount, successResults, errors } = getResult(response); - const title = 'A shared saved-object in one space'; + const title = + 'This is used to test an inexact match conflict for an originId -> originId match'; + const meta = { title, icon: 'beaker' }; + const destinationId = 'conflict_1a_space_2'; + if (createNewCopies) { + expectNewCopyResponse(response, inexactMatchIdA, title); + } else if (overwrite) { + expect(success).to.eql(true); + expect(successCount).to.eql(1); + expect(successResults).to.eql([ + { type, id: inexactMatchIdA, meta, overwrite: true, destinationId }, + ]); + expect(errors).to.be(undefined); + } else { + expect(success).to.eql(false); + expect(successCount).to.eql(0); + expect(successResults).to.be(undefined); + expect(errors).to.eql([ + { + error: { type: 'conflict', destinationId }, + type, + id: inexactMatchIdA, + title, + meta, + }, + ]); + } + } else if (outcome === 'noAccess') { + expectRouteForbiddenResponse(response); + } else { + // unauthorized read/write + expectSavedObjectForbiddenResponse(response); + } + }, + }, + { + testTitle: 'copying with an inexact match conflict (b) - originId matches existing id', + objects: [{ type, id: inexactMatchIdB }], + statusCode, + response: async (response: TestResponse) => { + if (outcome === 'authorized') { + const { success, successCount, successResults, errors } = getResult(response); + const title = + 'This is used to test an inexact match conflict for an originId -> id match'; + const meta = { title, icon: 'beaker' }; + const destinationId = 'conflict_1b_space_2'; + if (createNewCopies) { + expectNewCopyResponse(response, inexactMatchIdB, title); + } else if (overwrite) { + expect(success).to.eql(true); + expect(successCount).to.eql(1); + expect(successResults).to.eql([ + { type, id: inexactMatchIdB, meta, overwrite: true, destinationId }, + ]); + expect(errors).to.be(undefined); + } else { + expect(success).to.eql(false); + expect(successCount).to.eql(0); + expect(successResults).to.be(undefined); + expect(errors).to.eql([ + { + error: { type: 'conflict', destinationId }, + type, + id: inexactMatchIdB, + title, + meta, + }, + ]); + } + } else if (outcome === 'noAccess') { + expectRouteForbiddenResponse(response); + } else { + // unauthorized read/write + expectSavedObjectForbiddenResponse(response); + } + }, + }, + { + testTitle: 'copying with an inexact match conflict (c) - id matches existing originId', + objects: [{ type, id: inexactMatchIdC }], + statusCode, + response: async (response: TestResponse) => { + if (outcome === 'authorized') { + const { success, successCount, successResults, errors } = getResult(response); + const title = + 'This is used to test an inexact match conflict for an id -> originId match'; const meta = { title, icon: 'beaker' }; - const destinationId = 'conflict_1_space_2'; + const destinationId = 'conflict_1c_space_2'; if (createNewCopies) { - expectNewCopyResponse(response, inexactMatchId, title); + expectNewCopyResponse(response, inexactMatchIdC, title); } else if (overwrite) { expect(success).to.eql(true); expect(successCount).to.eql(1); expect(successResults).to.eql([ - { type, id: inexactMatchId, meta, overwrite: true, destinationId }, + { type, id: inexactMatchIdC, meta, overwrite: true, destinationId }, ]); expect(errors).to.be(undefined); } else { @@ -586,7 +674,7 @@ export function copyToSpaceTestSuiteFactory( { error: { type: 'conflict', destinationId }, type, - id: inexactMatchId, + id: inexactMatchIdC, title, meta, }, diff --git a/x-pack/test/spaces_api_integration/common/suites/delete.ts b/x-pack/test/spaces_api_integration/common/suites/delete.ts index ae8b73535c2c6..84f899bb911e5 100644 --- a/x-pack/test/spaces_api_integration/common/suites/delete.ts +++ b/x-pack/test/spaces_api_integration/common/suites/delete.ts @@ -101,7 +101,7 @@ export function deleteTestSuiteFactory(es: Client, esArchiver: any, supertest: S expect(buckets).to.eql(expectedBuckets); - // There were 15 multi-namespace objects. + // There were 22 multi-namespace objects. // Since Space 2 was deleted, any multi-namespace objects that existed in that space // are updated to remove it, and of those, any that don't exist in any space are deleted. const multiNamespaceResponse = await es.search>({ @@ -110,8 +110,8 @@ export function deleteTestSuiteFactory(es: Client, esArchiver: any, supertest: S body: { query: { terms: { type: ['sharedtype'] } } }, }); const docs = multiNamespaceResponse.hits.hits; - // Just 14 results, since spaces_2_only, conflict_1_space_2 and conflict_2_space_2 got deleted. - expect(docs).length(14); + // Just 17 results, since spaces_2_only, conflict_1a_space_2, conflict_1b_space_2, conflict_1c_space_2, and conflict_2_space_2 got deleted. + expect(docs).length(17); docs.forEach((doc) => () => { const containsSpace2 = doc?._source?.namespaces.includes('space_2'); expect(containsSpace2).to.eql(false); diff --git a/x-pack/test/spaces_api_integration/common/suites/resolve_copy_to_space_conflicts.ts b/x-pack/test/spaces_api_integration/common/suites/resolve_copy_to_space_conflicts.ts index 1d9d5325cbabf..336b04832e2dc 100644 --- a/x-pack/test/spaces_api_integration/common/suites/resolve_copy_to_space_conflicts.ts +++ b/x-pack/test/spaces_api_integration/common/suites/resolve_copy_to_space_conflicts.ts @@ -323,7 +323,9 @@ export function resolveCopyToSpaceConflictsSuite( const statusCode = outcome === 'noAccess' ? 403 : 200; const type = 'sharedtype'; const exactMatchId = 'each_space'; - const inexactMatchId = `conflict_1_${spaceId}`; + const inexactMatchIdA = `conflict_1a_${spaceId}`; + const inexactMatchIdB = `conflict_1b_${spaceId}`; + const inexactMatchIdC = `conflict_1c_default_and_space_1`; const ambiguousConflictId = `conflict_2_${spaceId}`; const createRetries = (overwriteRetry: Record) => ({ @@ -350,10 +352,20 @@ export function resolveCopyToSpaceConflictsSuite( expect(success).to.eql(true); expect(successCount).to.eql(1); expect(errors).to.be(undefined); - const title = - id === exactMatchId - ? 'A shared saved-object in the default, space_1, and space_2 spaces' - : 'A shared saved-object in one space'; + const title = (() => { + switch (id) { + case exactMatchId: + return 'A shared saved-object in the default, space_1, and space_2 spaces'; + case inexactMatchIdA: + return 'This is used to test an inexact match conflict for an originId -> originId match'; + case inexactMatchIdB: + return 'This is used to test an inexact match conflict for an originId -> id match'; + case inexactMatchIdC: + return 'This is used to test an inexact match conflict for an id -> originId match'; + default: + return 'A shared saved-object in one space'; + } + })(); const meta = { title, icon: 'beaker' }; expect(successResults).to.eql([ { type, id, meta, overwrite: true, ...(destinationId && { destinationId }) }, @@ -378,18 +390,61 @@ export function resolveCopyToSpaceConflictsSuite( }, }, { - testTitle: 'copying with an inexact match conflict', - objects: [{ type, id: inexactMatchId }], + testTitle: + 'copying with an inexact match conflict (a) - originId matches existing originId', + objects: [{ type, id: inexactMatchIdA }], retries: createRetries({ type, - id: inexactMatchId, + id: inexactMatchIdA, overwrite: true, - destinationId: 'conflict_1_space_2', + destinationId: 'conflict_1a_space_2', }), statusCode, response: async (response: TestResponse) => { if (outcome === 'authorized') { - expectSavedObjectSuccessResponse(response, inexactMatchId, 'conflict_1_space_2'); + expectSavedObjectSuccessResponse(response, inexactMatchIdA, 'conflict_1a_space_2'); + } else if (outcome === 'noAccess') { + expectRouteForbiddenResponse(response); + } else { + // unauthorized read/write + expectSavedObjectForbiddenResponse(response); + } + }, + }, + { + testTitle: 'copying with an inexact match conflict (b) - originId matches existing id', + objects: [{ type, id: inexactMatchIdB }], + retries: createRetries({ + type, + id: inexactMatchIdB, + overwrite: true, + destinationId: 'conflict_1b_space_2', + }), + statusCode, + response: async (response: TestResponse) => { + if (outcome === 'authorized') { + expectSavedObjectSuccessResponse(response, inexactMatchIdB, 'conflict_1b_space_2'); + } else if (outcome === 'noAccess') { + expectRouteForbiddenResponse(response); + } else { + // unauthorized read/write + expectSavedObjectForbiddenResponse(response); + } + }, + }, + { + testTitle: 'copying with an inexact match conflict (c) - id matches existing originId', + objects: [{ type, id: inexactMatchIdC }], + retries: createRetries({ + type, + id: inexactMatchIdC, + overwrite: true, + destinationId: 'conflict_1c_space_2', + }), + statusCode, + response: async (response: TestResponse) => { + if (outcome === 'authorized') { + expectSavedObjectSuccessResponse(response, inexactMatchIdC, 'conflict_1c_space_2'); } else if (outcome === 'noAccess') { expectRouteForbiddenResponse(response); } else { From 96b158393d01ecaacb2f5ee01f14c60eaceaf5d5 Mon Sep 17 00:00:00 2001 From: Joe Portner Date: Wed, 5 Jan 2022 15:02:11 -0500 Subject: [PATCH 6/8] Fix broken references for share-capable objects (issue 120312) --- .../import/import_saved_objects.test.mock.ts | 8 + .../import/import_saved_objects.test.ts | 69 +++++- .../import/import_saved_objects.ts | 22 +- .../lib/check_origin_conflicts.test.mock.ts | 14 ++ .../import/lib/check_origin_conflicts.test.ts | 58 +++-- .../import/lib/check_origin_conflicts.ts | 35 +-- .../lib/check_reference_origins.test.mock.ts | 14 ++ .../lib/check_reference_origins.test.ts | 182 ++++++++++++++++ .../import/lib/check_reference_origins.ts | 91 ++++++++ .../import/lib/collect_saved_objects.test.ts | 49 ++++- .../import/lib/collect_saved_objects.ts | 6 + .../server/saved_objects/import/lib/index.ts | 1 + .../server/saved_objects/import/lib/types.ts | 4 + .../saved_objects/import/lib/utils.test.ts | 26 +++ .../server/saved_objects/import/lib/utils.ts | 26 +++ .../import/lib/validate_references.test.ts | 75 ++++++- .../import/lib/validate_references.ts | 16 +- .../import/resolve_import_errors.test.mock.ts | 8 + .../import/resolve_import_errors.test.ts | 59 ++++- .../import/resolve_import_errors.ts | 49 +++-- .../saved_objects/spaces/data.json | 57 +++++ .../common/suites/import.ts | 134 ++++++++---- .../common/suites/resolve_import_errors.ts | 104 +++++++-- .../security_and_spaces/apis/import.ts | 205 +++++++++++------- .../apis/resolve_import_errors.ts | 129 ++++++----- .../spaces_only/apis/import.ts | 82 ++++--- .../spaces_only/apis/resolve_import_errors.ts | 43 ++-- 27 files changed, 1232 insertions(+), 334 deletions(-) create mode 100644 src/core/server/saved_objects/import/lib/check_origin_conflicts.test.mock.ts create mode 100644 src/core/server/saved_objects/import/lib/check_reference_origins.test.mock.ts create mode 100644 src/core/server/saved_objects/import/lib/check_reference_origins.test.ts create mode 100644 src/core/server/saved_objects/import/lib/check_reference_origins.ts create mode 100644 src/core/server/saved_objects/import/lib/utils.test.ts create mode 100644 src/core/server/saved_objects/import/lib/utils.ts diff --git a/src/core/server/saved_objects/import/import_saved_objects.test.mock.ts b/src/core/server/saved_objects/import/import_saved_objects.test.mock.ts index d152bf3039121..82e5aa4a5d77f 100644 --- a/src/core/server/saved_objects/import/import_saved_objects.test.mock.ts +++ b/src/core/server/saved_objects/import/import_saved_objects.test.mock.ts @@ -7,6 +7,7 @@ */ import type { collectSavedObjects } from './lib/collect_saved_objects'; +import type { checkReferenceOrigins } from './lib/check_reference_origins'; import type { regenerateIds } from './lib/regenerate_ids'; import type { validateReferences } from './lib/validate_references'; import type { checkConflicts } from './lib/check_conflicts'; @@ -19,6 +20,13 @@ jest.mock('./lib/collect_saved_objects', () => ({ collectSavedObjects: mockCollectSavedObjects, })); +export const mockCheckReferenceOrigins = jest.fn() as jest.MockedFunction< + typeof checkReferenceOrigins +>; +jest.mock('./lib/check_reference_origins', () => ({ + checkReferenceOrigins: mockCheckReferenceOrigins, +})); + export const mockRegenerateIds = jest.fn() as jest.MockedFunction; jest.mock('./lib/regenerate_ids', () => ({ regenerateIds: mockRegenerateIds, diff --git a/src/core/server/saved_objects/import/import_saved_objects.test.ts b/src/core/server/saved_objects/import/import_saved_objects.test.ts index dab4940a311e2..63be39e3b6c17 100644 --- a/src/core/server/saved_objects/import/import_saved_objects.test.ts +++ b/src/core/server/saved_objects/import/import_saved_objects.test.ts @@ -8,6 +8,7 @@ import { mockCollectSavedObjects, + mockCheckReferenceOrigins, mockRegenerateIds, mockValidateReferences, mockCheckConflicts, @@ -40,6 +41,7 @@ describe('#importSavedObjectsFromStream', () => { collectedObjects: [], importStateMap: new Map(), }); + mockCheckReferenceOrigins.mockResolvedValue({ importStateMap: new Map() }); mockRegenerateIds.mockReturnValue(new Map()); mockValidateReferences.mockResolvedValue([]); mockCheckConflicts.mockResolvedValue({ @@ -136,20 +138,53 @@ describe('#importSavedObjectsFromStream', () => { expect(mockCollectSavedObjects).toHaveBeenCalledWith(collectSavedObjectsOptions); }); + test('checks reference origins', async () => { + const options = setupOptions(); + const collectedObjects = [createObject()]; + const importStateMap = new Map([ + [`${collectedObjects[0].type}:${collectedObjects[0].id}`, {}], + [`foo:bar`, { isOnlyReference: true }], + ]); + mockCollectSavedObjects.mockResolvedValue({ + errors: [], + collectedObjects, + importStateMap, + }); + + await importSavedObjectsFromStream(options); + expect(mockCheckReferenceOrigins).toHaveBeenCalledWith({ + savedObjectsClient, + typeRegistry, + namespace, + importStateMap, + }); + }); + test('validates references', async () => { const options = setupOptions(); const collectedObjects = [createObject()]; mockCollectSavedObjects.mockResolvedValue({ errors: [], collectedObjects, - importStateMap: new Map(), + importStateMap: new Map([ + [`${collectedObjects[0].type}:${collectedObjects[0].id}`, {}], + [`foo:bar`, { isOnlyReference: true }], + ]), + }); + mockCheckReferenceOrigins.mockResolvedValue({ + importStateMap: new Map([[`foo:bar`, { isOnlyReference: true, id: 'baz' }]]), }); await importSavedObjectsFromStream(options); expect(mockValidateReferences).toHaveBeenCalledWith( collectedObjects, savedObjectsClient, - namespace + namespace, + new Map([ + // This importStateMap is a combination of the other two + [`${collectedObjects[0].type}:${collectedObjects[0].id}`, {}], + [`foo:bar`, { isOnlyReference: true, id: 'baz' }], + ]) ); }); @@ -247,27 +282,31 @@ describe('#importSavedObjectsFromStream', () => { importStateMap: new Map([ ['foo', {}], ['bar', {}], - ['baz', {}], + ['baz', { isOnlyReference: true }], ]), }); + mockCheckReferenceOrigins.mockResolvedValue({ + importStateMap: new Map([['baz', { isOnlyReference: true, destinationId: 'newId1' }]]), + }); mockValidateReferences.mockResolvedValue([errors[1]]); mockCheckConflicts.mockResolvedValue({ errors: [errors[2]], filteredObjects, - importStateMap: new Map([['bar', { destinationId: 'newId1' }]]), + importStateMap: new Map([['foo', { destinationId: 'newId2' }]]), pendingOverwrites: new Set(), }); mockCheckOriginConflicts.mockResolvedValue({ errors: [errors[3]], - importStateMap: new Map([['baz', { destinationId: 'newId2' }]]), + importStateMap: new Map([['bar', { destinationId: 'newId3' }]]), pendingOverwrites: new Set(), }); await importSavedObjectsFromStream(options); + // assert that the importStateMap is correctly composed of the results from the four modules const importStateMap = new Map([ - ['foo', {}], - ['bar', { destinationId: 'newId1' }], - ['baz', { destinationId: 'newId2' }], + ['foo', { destinationId: 'newId2' }], + ['bar', { destinationId: 'newId3' }], + ['baz', { isOnlyReference: true, destinationId: 'newId1' }], ]); const createSavedObjectsParams = { objects: collectedObjects, @@ -313,15 +352,21 @@ describe('#importSavedObjectsFromStream', () => { collectedObjects, importStateMap: new Map([ ['foo', {}], - ['bar', {}], + ['bar', { isOnlyReference: true }], ]), }); + mockCheckReferenceOrigins.mockResolvedValue({ + importStateMap: new Map([['bar', { isOnlyReference: true, destinationId: 'newId' }]]), + }); mockValidateReferences.mockResolvedValue([errors[1]]); - // this importStateMap is not composed with the one obtained from `collectSavedObjects` - const importStateMap: ImportStateMap = new Map().set(`id1`, { destinationId: `newId1` }); - mockRegenerateIds.mockReturnValue(importStateMap); + mockRegenerateIds.mockReturnValue(new Map([['foo', { destinationId: `randomId1` }]])); await importSavedObjectsFromStream(options); + // assert that the importStateMap is correctly composed of the results from the three modules + const importStateMap: ImportStateMap = new Map([ + ['foo', { destinationId: `randomId1` }], + ['bar', { isOnlyReference: true, destinationId: 'newId' }], + ]); const createSavedObjectsParams = { objects: collectedObjects, accumulatedErrors: errors, diff --git a/src/core/server/saved_objects/import/import_saved_objects.ts b/src/core/server/saved_objects/import/import_saved_objects.ts index 389f47e2fe0e1..43180524d6062 100644 --- a/src/core/server/saved_objects/import/import_saved_objects.ts +++ b/src/core/server/saved_objects/import/import_saved_objects.ts @@ -15,6 +15,7 @@ import { SavedObjectsImportHook, } from './types'; import { + checkReferenceOrigins, validateReferences, checkOriginConflicts, createSavedObjects, @@ -72,20 +73,35 @@ export async function importSavedObjectsFromStream({ supportedTypes, }); errorAccumulator = [...errorAccumulator, ...collectSavedObjectsResult.errors]; - /** Map of all IDs for objects that we are attempting to import; each value is empty by default */ + // Map of all IDs for objects that we are attempting to import, and any references that are not included in the read stream; + // each value is empty by default let importStateMap = collectSavedObjectsResult.importStateMap; let pendingOverwrites = new Set(); + // Check any references that aren't included in the import file and retries, to see if they have a match with a different origin + const checkReferenceOriginsResult = await checkReferenceOrigins({ + savedObjectsClient, + typeRegistry, + namespace, + importStateMap, + }); + importStateMap = new Map([...importStateMap, ...checkReferenceOriginsResult.importStateMap]); + // Validate references const validateReferencesResult = await validateReferences( collectSavedObjectsResult.collectedObjects, savedObjectsClient, - namespace + namespace, + importStateMap ); errorAccumulator = [...errorAccumulator, ...validateReferencesResult]; if (createNewCopies) { - importStateMap = regenerateIds(collectSavedObjectsResult.collectedObjects); + importStateMap = new Map([ + ...importStateMap, // preserve any entries for references that aren't included in collectedObjects + ...regenerateIds(collectSavedObjectsResult.collectedObjects), + ]); + // TODO: check reference origins! } else { // Check single-namespace objects for conflicts in this namespace, and check multi-namespace objects for conflicts across all namespaces const checkConflictsParams = { diff --git a/src/core/server/saved_objects/import/lib/check_origin_conflicts.test.mock.ts b/src/core/server/saved_objects/import/lib/check_origin_conflicts.test.mock.ts new file mode 100644 index 0000000000000..8fb5704af9d82 --- /dev/null +++ b/src/core/server/saved_objects/import/lib/check_origin_conflicts.test.mock.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { createOriginQuery } from './utils'; + +export const mockCreateOriginQuery = jest.fn() as jest.MockedFunction; +jest.mock('./utils', () => ({ + createOriginQuery: mockCreateOriginQuery, +})); diff --git a/src/core/server/saved_objects/import/lib/check_origin_conflicts.test.ts b/src/core/server/saved_objects/import/lib/check_origin_conflicts.test.ts index 1c16929e3a1a8..6c633b1a119d1 100644 --- a/src/core/server/saved_objects/import/lib/check_origin_conflicts.test.ts +++ b/src/core/server/saved_objects/import/lib/check_origin_conflicts.test.ts @@ -6,6 +6,8 @@ * Side Public License, v 1. */ +import { mockCreateOriginQuery } from './check_reference_origins.test.mock'; + import { SavedObjectsClientContract, SavedObjectReference, @@ -83,6 +85,10 @@ describe('#checkOriginConflicts', () => { }; describe('cluster calls', () => { + beforeEach(() => { + mockCreateOriginQuery.mockClear(); + }); + const multiNsObj = createObject(MULTI_NS_TYPE, 'id-1'); const multiNsObjWithOriginId = createObject(MULTI_NS_TYPE, 'id-2', 'originId-foo'); const otherObj = createObject(OTHER_TYPE, 'id-3'); @@ -90,12 +96,10 @@ describe('#checkOriginConflicts', () => { const otherObjWithOriginId = createObject(OTHER_TYPE, 'id-4', 'originId-bar'); const expectFindArgs = (n: number, object: SavedObject) => { - const { type, id, originId } = object; - const search = `"${type}:${originId || id}" | "${originId || id}"`; // this template works for our basic test cases - const expectedArgs = expect.objectContaining({ type, search }); - // exclude rootSearchFields, page, perPage, and fields attributes from assertion -- these are constant + const idToCheck = object.originId || object.id; + expect(mockCreateOriginQuery).toHaveBeenNthCalledWith(n, object.type, idToCheck); // exclude namespace from assertion -- a separate test covers that - expect(find).toHaveBeenNthCalledWith(n, expectedArgs); + expect(find).toHaveBeenNthCalledWith(n, expect.objectContaining({ type: object.type })); }; test('does not execute searches for non-multi-namespace objects', async () => { @@ -137,22 +141,6 @@ describe('#checkOriginConflicts', () => { expect(find).toHaveBeenCalledTimes(1); expect(find).toHaveBeenCalledWith(expect.objectContaining({ namespaces: [namespace] })); }); - - test('search query escapes quote and backslash characters in `id` and/or `originId`', async () => { - const weirdId = `some"weird\\id`; - const objects = [ - createObject(MULTI_NS_TYPE, weirdId), - createObject(MULTI_NS_TYPE, 'some-id', weirdId), - ]; - const params = setupParams({ objects }); - - await checkOriginConflicts(params); - const escapedId = `some\\"weird\\\\id`; - const expectedQuery = `"${MULTI_NS_TYPE}:${escapedId}" | "${escapedId}"`; - expect(find).toHaveBeenCalledTimes(2); - expect(find).toHaveBeenNthCalledWith(1, expect.objectContaining({ search: expectedQuery })); - expect(find).toHaveBeenNthCalledWith(2, expect.objectContaining({ search: expectedQuery })); - }); }); describe('results', () => { @@ -189,6 +177,34 @@ describe('#checkOriginConflicts', () => { }, }); + test('filters inexact matches of other objects that are being imported, but does not filter inexact matches of references that are not being imported', async () => { + // obj1, obj2, and obj3 exist in this space, and obj1 has references to both obj2 and obj3 + // try to import obj1, obj2, and obj4; simulating a scenario where obj1 and obj2 were filtered out during `checkConflicts`, so we only call `checkOriginConflicts` with the remainder + const obj1 = createObject(MULTI_NS_TYPE, 'id-1'); + const obj2 = createObject(MULTI_NS_TYPE, 'id-2', 'some-originId'); + const obj3 = createObject(MULTI_NS_TYPE, 'id-3', 'some-originId'); + const obj4 = createObject(MULTI_NS_TYPE, 'id-4', 'some-originId'); + const objects = [obj4]; + const params = setupParams({ + objects, + importStateMap: new Map([ + [`${obj1.type}:${obj1.id}`, {}], + [`${obj2.type}:${obj2.id}`, {}], + [`${obj3.type}:${obj3.id}`, { isOnlyReference: true }], // this attribute signifies that there is a reference to this object, but it is not present in the collected objects from the import file + [`${obj4.type}:${obj4.id}`, {}], + ]), + }); + mockFindResult(obj2, obj3); // find for obj4: the result is an inexact match with two destinations, one of which is exactly matched by obj2 -- accordingly, obj4 has an inexact match to obj3 + + const checkOriginConflictsResult = await checkOriginConflicts(params); + const expectedResult = { + importStateMap: new Map(), + errors: [createConflictError(obj4, obj3.id)], + pendingOverwrites: new Set(), + }; + expect(checkOriginConflictsResult).toEqual(expectedResult); + }); + describe('object result without a `importStateMap` entry (no match or exact match)', () => { test('returns object when no match is detected (0 hits)', async () => { // no objects exist in this space diff --git a/src/core/server/saved_objects/import/lib/check_origin_conflicts.ts b/src/core/server/saved_objects/import/lib/check_origin_conflicts.ts index 7e263d75cceea..f1bb1afb2e3e4 100644 --- a/src/core/server/saved_objects/import/lib/check_origin_conflicts.ts +++ b/src/core/server/saved_objects/import/lib/check_origin_conflicts.ts @@ -11,6 +11,7 @@ import { v4 as uuidv4 } from 'uuid'; import { SavedObject, SavedObjectsClientContract, SavedObjectsImportFailure } from '../../types'; import { ISavedObjectTypeRegistry } from '../../saved_objects_type_registry'; import type { ImportStateMap } from './types'; +import { createOriginQuery } from './utils'; interface CheckOriginConflictsParams { objects: Array>; @@ -22,8 +23,9 @@ interface CheckOriginConflictsParams { pendingOverwrites: Set; } -type CheckOriginConflictParams = Omit & { +type CheckOriginConflictParams = Omit & { object: SavedObject<{ title?: string }>; + objectIdsBeingImported: Set; }; interface InexactMatch { @@ -43,11 +45,6 @@ const isLeft = (object: Either): object is Left => object.tag === 'left const MAX_CONCURRENT_SEARCHES = 10; -const createQueryTerm = (input: string) => input.replace(/\\/g, '\\\\').replace(/\"/g, '\\"'); -const createQuery = (type: string, id: string) => - // 1st query term will match raw object IDs (_id), 2nd query term will match originId - // we intentionally do not include a namespace prefix for the raw object IDs, because this search is only for multi-namespace object types - `"${createQueryTerm(`${type}:${id}`)}" | "${createQueryTerm(id)}"`; const transformObjectsToAmbiguousConflictFields = ( objects: Array> ) => @@ -81,9 +78,14 @@ const getAmbiguousConflictSourceKey = ({ object }: InexactMatch) => const checkOriginConflict = async ( params: CheckOriginConflictParams ): Promise> => { - const { object, savedObjectsClient, typeRegistry, namespace, importStateMap, pendingOverwrites } = - params; - const importIds = new Set(importStateMap.keys()); + const { + object, + savedObjectsClient, + typeRegistry, + namespace, + objectIdsBeingImported, + pendingOverwrites, + } = params; const { type, originId, id } = object; if (!typeRegistry.isMultiNamespace(type) || pendingOverwrites.has(`${type}:${id}`)) { @@ -92,10 +94,9 @@ const checkOriginConflict = async ( return { tag: 'right', value: object }; } - const search = createQuery(type, originId || id); const findOptions = { type, - search, + search: createOriginQuery(type, originId || id), rootSearchFields: ['_id', 'originId'], page: 1, perPage: 10, @@ -110,7 +111,9 @@ const checkOriginConflict = async ( return { tag: 'right', value: object }; } // This is an "inexact match" so far; filter the conflict destination(s) to exclude any that exactly match other objects we are importing. - const objects = savedObjects.filter((obj) => !importIds.has(`${obj.type}:${obj.id}`)); + const objects = savedObjects.filter( + (obj) => !objectIdsBeingImported.has(`${obj.type}:${obj.id}`) + ); const destinations = transformObjectsToAmbiguousConflictFields(objects); if (destinations.length === 0) { // No conflict destinations remain after filtering, so this is a "no match" result. @@ -138,9 +141,15 @@ const checkOriginConflict = async ( * B. Otherwise, this is an "ambiguous conflict" result; return an error. */ export async function checkOriginConflicts({ objects, ...params }: CheckOriginConflictsParams) { + const objectIdsBeingImported = new Set(); + for (const [key, { isOnlyReference }] of params.importStateMap.entries()) { + if (!isOnlyReference) { + objectIdsBeingImported.add(key); + } + } // Check each object for possible destination conflicts, ensuring we don't too many concurrent searches running. const mapper = async (object: SavedObject<{ title?: string }>) => - checkOriginConflict({ object, ...params }); + checkOriginConflict({ object, objectIdsBeingImported, ...params }); const checkOriginConflictResults = await pMap(objects, mapper, { concurrency: MAX_CONCURRENT_SEARCHES, }); diff --git a/src/core/server/saved_objects/import/lib/check_reference_origins.test.mock.ts b/src/core/server/saved_objects/import/lib/check_reference_origins.test.mock.ts new file mode 100644 index 0000000000000..8fb5704af9d82 --- /dev/null +++ b/src/core/server/saved_objects/import/lib/check_reference_origins.test.mock.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { createOriginQuery } from './utils'; + +export const mockCreateOriginQuery = jest.fn() as jest.MockedFunction; +jest.mock('./utils', () => ({ + createOriginQuery: mockCreateOriginQuery, +})); diff --git a/src/core/server/saved_objects/import/lib/check_reference_origins.test.ts b/src/core/server/saved_objects/import/lib/check_reference_origins.test.ts new file mode 100644 index 0000000000000..de162856b9873 --- /dev/null +++ b/src/core/server/saved_objects/import/lib/check_reference_origins.test.ts @@ -0,0 +1,182 @@ +/* + * 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 { mockCreateOriginQuery } from './check_reference_origins.test.mock'; + +import type { SavedObjectsFindResult } from '../../service'; +import type { SavedObjectsClientContract } from '../../types'; +import { checkReferenceOrigins, CheckReferenceOriginsParams } from './check_reference_origins'; +import { savedObjectsClientMock } from '../../../mocks'; +import { typeRegistryMock } from '../../saved_objects_type_registry.mock'; +import { ISavedObjectTypeRegistry } from '../../saved_objects_type_registry'; +import type { ImportStateMap } from './types'; + +const MULTI_NS_TYPE = 'multi'; +const OTHER_TYPE = 'other'; + +describe('checkReferenceOrigins', () => { + let savedObjectsClient: jest.Mocked; + let typeRegistry: jest.Mocked; + let find: typeof savedObjectsClient['find']; + + const getResultMock = (...objectIds: string[]) => ({ + page: 1, + per_page: 1, + total: objectIds.length, + saved_objects: objectIds.map((id) => ({ id, score: 0 } as unknown as SavedObjectsFindResult)), + }); + + const setupParams = (partial: { + namespace?: string; + importStateMap: ImportStateMap; + }): CheckReferenceOriginsParams => { + savedObjectsClient = savedObjectsClientMock.create(); + find = savedObjectsClient.find; + find.mockResolvedValue(getResultMock()); // mock zero hits response by default + typeRegistry = typeRegistryMock.create(); + typeRegistry.isMultiNamespace.mockImplementation((type) => type === MULTI_NS_TYPE); + return { + ...partial, + savedObjectsClient, + typeRegistry, + }; + }; + + const mockFindResult = (...objectIds: string[]) => { + // doesn't matter if the mocked result is a "realistic" object, it just needs an `id` field + find.mockResolvedValueOnce(getResultMock(...objectIds)); + }; + + describe('cluster calls', () => { + beforeEach(() => { + mockCreateOriginQuery.mockClear(); + }); + + const expectFindArgs = (n: number, type: string, id: string) => { + expect(mockCreateOriginQuery).toHaveBeenNthCalledWith(n, type, id); + // exclude namespace from assertion -- a separate test covers that + expect(find).toHaveBeenNthCalledWith(n, expect.objectContaining({ type })); + }; + + test('does not execute searches for non-multi-namespace objects', async () => { + const params = setupParams({ + importStateMap: new Map([[`${OTHER_TYPE}:1`, { isOnlyReference: true }]]), + }); + + await checkReferenceOrigins(params); + expect(find).not.toHaveBeenCalled(); + }); + + test('does not execute searches for multi-namespace objects without the isOnlyReference attribute', async () => { + const params = setupParams({ + importStateMap: new Map([[`${MULTI_NS_TYPE}:1`, {}]]), + }); + + await checkReferenceOrigins(params); + expect(find).not.toHaveBeenCalled(); + }); + + test('executes searches for multi-namespace objects with the isOnlyReference attribute', async () => { + const params = setupParams({ + importStateMap: new Map([[`${MULTI_NS_TYPE}:1`, { isOnlyReference: true }]]), + }); + + await checkReferenceOrigins(params); + expect(find).toHaveBeenCalledTimes(1); + expectFindArgs(1, MULTI_NS_TYPE, '1'); + }); + + test('executes mixed searches', async () => { + const params = setupParams({ + importStateMap: new Map([ + [`${MULTI_NS_TYPE}:1`, {}], + [`${MULTI_NS_TYPE}:2`, { isOnlyReference: true }], + [`${OTHER_TYPE}:3`, { isOnlyReference: true }], + [`${MULTI_NS_TYPE}:4`, { isOnlyReference: true }], + ]), + }); + + await checkReferenceOrigins(params); + expect(find).toHaveBeenCalledTimes(2); + expectFindArgs(1, MULTI_NS_TYPE, '2'); + expectFindArgs(2, MULTI_NS_TYPE, '4'); + }); + + test('searches within the current `namespace`', async () => { + const namespace = 'some-namespace'; + const params = setupParams({ + namespace, + importStateMap: new Map([[`${MULTI_NS_TYPE}:1`, { isOnlyReference: true }]]), + }); + + await checkReferenceOrigins(params); + expect(find).toHaveBeenCalledTimes(1); + expect(find).toHaveBeenCalledWith(expect.objectContaining({ namespaces: [namespace] })); + }); + }); + + describe('results', () => { + test('does not return an entry if search resulted in 0 matches', async () => { + const params = setupParams({ + importStateMap: new Map([[`${MULTI_NS_TYPE}:1`, { isOnlyReference: true }]]), + }); + // mock find returns an empty search result by default + + const checkReferenceOriginsResult = await checkReferenceOrigins(params); + + const expectedResult = { + importStateMap: new Map(), + }; + expect(checkReferenceOriginsResult).toEqual(expectedResult); + }); + + test('does not return an entry if search resulted in 2+ matches', async () => { + const params = setupParams({ + importStateMap: new Map([[`${MULTI_NS_TYPE}:1`, { isOnlyReference: true }]]), + }); + mockFindResult('2', '3'); + + const checkReferenceOriginsResult = await checkReferenceOrigins(params); + + const expectedResult = { + importStateMap: new Map(), + }; + expect(checkReferenceOriginsResult).toEqual(expectedResult); + }); + + test('does not return an entry if search resulted in 1 match with the same ID', async () => { + const params = setupParams({ + importStateMap: new Map([[`${MULTI_NS_TYPE}:1`, { isOnlyReference: true }]]), + }); + mockFindResult('1'); + + const checkReferenceOriginsResult = await checkReferenceOrigins(params); + + const expectedResult = { + importStateMap: new Map(), + }; + expect(checkReferenceOriginsResult).toEqual(expectedResult); + }); + + test('returns an entry if search resulted in 1 match with a different ID', async () => { + const params = setupParams({ + importStateMap: new Map([[`${MULTI_NS_TYPE}:1`, { isOnlyReference: true }]]), + }); + mockFindResult('2'); + + const checkReferenceOriginsResult = await checkReferenceOrigins(params); + + const expectedResult = { + importStateMap: new Map([ + [`${MULTI_NS_TYPE}:1`, { isOnlyReference: true, destinationId: '2' }], + ]), + }; + expect(checkReferenceOriginsResult).toEqual(expectedResult); + }); + }); +}); diff --git a/src/core/server/saved_objects/import/lib/check_reference_origins.ts b/src/core/server/saved_objects/import/lib/check_reference_origins.ts new file mode 100644 index 0000000000000..6ee4c615f3fed --- /dev/null +++ b/src/core/server/saved_objects/import/lib/check_reference_origins.ts @@ -0,0 +1,91 @@ +/* + * 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 pMap from 'p-map'; +import { SavedObjectsClientContract } from '../../types'; +import { ISavedObjectTypeRegistry } from '../../saved_objects_type_registry'; +import type { ImportStateMap, ImportStateValue } from './types'; +import { getObjectKey, parseObjectKey } from '../../service/lib/internal_utils'; +import { createOriginQuery } from './utils'; + +export interface CheckReferenceOriginsParams { + savedObjectsClient: SavedObjectsClientContract; + typeRegistry: ISavedObjectTypeRegistry; + namespace?: string; + importStateMap: ImportStateMap; +} + +interface Reference { + type: string; + id: string; +} + +const MAX_CONCURRENT_SEARCHES = 10; + +/** + * Searches for any existing object(s) for the given reference; if there is exactly one object with a matching origin *and* its ID is + * different than this reference ID, then this returns the different ID. Otherwise, it returns null. + */ +async function checkOrigin( + type: string, + id: string, + savedObjectsClient: SavedObjectsClientContract, + namespace: string | undefined +) { + const findOptions = { + type, + search: createOriginQuery(type, id), + rootSearchFields: ['_id', 'originId'], + page: 1, + perPage: 1, // we only need one result for now + fields: ['title'], // we don't actually need the object's title, we just specify one field so we don't fetch *all* fields + sortField: 'updated_at', + sortOrder: 'desc' as const, + ...(namespace && { namespaces: [namespace] }), + }; + const findResult = await savedObjectsClient.find<{ title?: string }>(findOptions); + const { total, saved_objects: savedObjects } = findResult; + if (total === 1) { + const [object] = savedObjects; + if (id !== object.id) { + return { + key: getObjectKey({ type, id }), + value: { isOnlyReference: true, destinationId: object.id } as ImportStateValue, + }; + } + } + // TODO: if the total is 2+, return an "ambiguous reference origin match" to the consumer (#120313) + return null; +} + +export async function checkReferenceOrigins(params: CheckReferenceOriginsParams) { + const { savedObjectsClient, namespace } = params; + const referencesToCheck: Reference[] = []; + for (const [key, { isOnlyReference }] of params.importStateMap.entries()) { + const { type, id } = parseObjectKey(key); + if (params.typeRegistry.isMultiNamespace(type) && isOnlyReference) { + referencesToCheck.push({ type, id }); + } + } + // Check each object for possible destination conflicts, ensuring we don't too many concurrent searches running. + const mapper = async ({ type, id }: Reference) => + checkOrigin(type, id, savedObjectsClient, namespace); + const checkOriginResults = await pMap(referencesToCheck, mapper, { + concurrency: MAX_CONCURRENT_SEARCHES, + }); + + const importStateMap: ImportStateMap = new Map(); + for (const result of checkOriginResults) { + if (result) { + const { key, value } = result; + importStateMap.set(key, value); + } + } + + return { importStateMap }; +} diff --git a/src/core/server/saved_objects/import/lib/collect_saved_objects.test.ts b/src/core/server/saved_objects/import/lib/collect_saved_objects.test.ts index 7247ca9371833..b401d71ffe498 100644 --- a/src/core/server/saved_objects/import/lib/collect_saved_objects.test.ts +++ b/src/core/server/saved_objects/import/lib/collect_saved_objects.test.ts @@ -39,8 +39,18 @@ describe('collectSavedObjects()', () => { }, }); - const obj1 = { type: 'a', id: '1', attributes: { title: 'my title 1' } }; - const obj2 = { type: 'b', id: '2', attributes: { title: 'my title 2' } }; + const obj1 = { + type: 'a', + id: '1', + attributes: { title: 'my title 1' }, + references: [{ type: 'b', id: '2', name: 'b2' }], + }; + const obj2 = { + type: 'b', + id: '2', + attributes: { title: 'my title 2' }, + references: [{ type: 'c', id: '3', name: 'c3' }], + }; describe('module calls', () => { test('limit stream with empty input stream is called with null', async () => { @@ -124,12 +134,19 @@ describe('collectSavedObjects()', () => { }); test('collects objects from stream', async () => { - const readStream = createReadStream(obj1); - const supportedTypes = [obj1.type]; + const readStream = createReadStream(obj1, obj2); + const supportedTypes = [obj1.type, obj2.type]; const result = await collectSavedObjects({ readStream, supportedTypes, objectLimit }); - const collectedObjects = [{ ...obj1, migrationVersion: {} }]; - const importStateMap = new Map([[`${obj1.type}:${obj1.id}`, {}]]); + const collectedObjects = [ + { ...obj1, migrationVersion: {} }, + { ...obj2, migrationVersion: {} }, + ]; + const importStateMap = new Map([ + [`a:1`, {}], // a:1 is included because it is present in the collected objects + [`b:2`, {}], // b:2 is included because it is present in the collected objects + [`c:3`, { isOnlyReference: true }], // c:3 is included because b:2 has a reference to c:3, but this is marked as `isOnlyReference` because c:3 is not present in the collected objects + ]); expect(result).toEqual({ collectedObjects, errors: [], importStateMap }); }); @@ -146,14 +163,18 @@ describe('collectSavedObjects()', () => { test('returns mixed results', async () => { const readStream = createReadStream(obj1, obj2); - const supportedTypes = [obj2.type]; + const supportedTypes = [obj1.type]; const result = await collectSavedObjects({ readStream, supportedTypes, objectLimit }); - const collectedObjects = [{ ...obj2, migrationVersion: {} }]; - const importStateMap = new Map([[`${obj2.type}:${obj2.id}`, {}]]); + const collectedObjects = [{ ...obj1, migrationVersion: {} }]; + const importStateMap = new Map([ + [`a:1`, {}], // a:1 is included because it is present in the collected objects + [`b:2`, { isOnlyReference: true }], // b:2 was filtered out due to an unsupported type; b:2 is included because a:1 has a reference to b:2, but this is marked as `isOnlyReference` because b:2 is not present in the collected objects + // c:3 is not included at all, because b:2 was filtered out and there are no other references to c:3 + ]); const error = { type: 'unsupported_type' }; - const { title } = obj1.attributes; - const errors = [{ error, type: obj1.type, id: obj1.id, title, meta: { title } }]; + const { title } = obj2.attributes; + const errors = [{ error, type: obj2.type, id: obj2.id, title, meta: { title } }]; expect(result).toEqual({ collectedObjects, errors, importStateMap }); }); @@ -187,7 +208,11 @@ describe('collectSavedObjects()', () => { }); const collectedObjects = [{ ...obj2, migrationVersion: {} }]; - const importStateMap = new Map([[`${obj2.type}:${obj2.id}`, {}]]); + const importStateMap = new Map([ + // a:1 was filtered out due to an unsupported type; a:1 is not included because there are no other references to a:1 + [`b:2`, {}], // b:2 is included because it is present in the collected objects + [`c:3`, { isOnlyReference: true }], // c:3 is included because b:2 has a reference to c:3, but this is marked as `isOnlyReference` because c:3 is not present in the collected objects + ]); const error = { type: 'unsupported_type' }; const { title } = obj1.attributes; const errors = [{ error, type: obj1.type, id: obj1.id, title, meta: { title } }]; diff --git a/src/core/server/saved_objects/import/lib/collect_saved_objects.ts b/src/core/server/saved_objects/import/lib/collect_saved_objects.ts index 4e2888f46f1c0..209ae5ecf283e 100644 --- a/src/core/server/saved_objects/import/lib/collect_saved_objects.ts +++ b/src/core/server/saved_objects/import/lib/collect_saved_objects.ts @@ -60,6 +60,12 @@ export async function collectSavedObjects({ createFilterStream((obj) => (filter ? filter(obj) : true)), createMapStream((obj: SavedObject) => { importStateMap.set(`${obj.type}:${obj.id}`, {}); + for (const ref of obj.references ?? []) { + const key = `${ref.type}:${ref.id}`; + if (!importStateMap.has(key)) { + importStateMap.set(key, { isOnlyReference: true }); + } + } // Ensure migrations execute on every saved object return Object.assign({ migrationVersion: {} }, obj); }), diff --git a/src/core/server/saved_objects/import/lib/index.ts b/src/core/server/saved_objects/import/lib/index.ts index 2f0e0aa9505dc..14883786085fa 100644 --- a/src/core/server/saved_objects/import/lib/index.ts +++ b/src/core/server/saved_objects/import/lib/index.ts @@ -7,6 +7,7 @@ */ export { checkConflicts } from './check_conflicts'; +export { checkReferenceOrigins } from './check_reference_origins'; export { checkOriginConflicts } from './check_origin_conflicts'; export { collectSavedObjects } from './collect_saved_objects'; export { createLimitStream } from './create_limit_stream'; diff --git a/src/core/server/saved_objects/import/lib/types.ts b/src/core/server/saved_objects/import/lib/types.ts index 74cdf94fa1091..ccc0373a80de5 100644 --- a/src/core/server/saved_objects/import/lib/types.ts +++ b/src/core/server/saved_objects/import/lib/types.ts @@ -20,6 +20,10 @@ export type ImportStateMap = Map; * The value of an import state entry, which contains optional attributes that change how the object is created. */ export interface ImportStateValue { + /** + * This attribute indicates that the object for this entry is *only* a reference, it does not exist in the import file. + */ + isOnlyReference?: boolean; /** * This attribute indicates that the object should have this ID instead of what was specified in the import file. */ diff --git a/src/core/server/saved_objects/import/lib/utils.test.ts b/src/core/server/saved_objects/import/lib/utils.test.ts new file mode 100644 index 0000000000000..19ecd38283b42 --- /dev/null +++ b/src/core/server/saved_objects/import/lib/utils.test.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { createOriginQuery } from './utils'; + +describe('createOriginQuery', () => { + it('returns expected simple query string', () => { + const result = createOriginQuery('a', 'b'); + expect(result).toEqual('"a:b" | "b"'); + }); + + it('escapes double quotes', () => { + const result = createOriginQuery('a"', 'b"'); + expect(result).toEqual('"a\\":b\\"" | "b\\""'); + }); + + it('escapes backslashes', () => { + const result = createOriginQuery('a\\', 'b\\'); + expect(result).toEqual('"a\\\\:b\\\\" | "b\\\\"'); + }); +}); diff --git a/src/core/server/saved_objects/import/lib/utils.ts b/src/core/server/saved_objects/import/lib/utils.ts new file mode 100644 index 0000000000000..7b4f188f89579 --- /dev/null +++ b/src/core/server/saved_objects/import/lib/utils.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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. + */ + +function createOriginQueryTerm(input: string) { + return input.replace(/\\/g, '\\\\').replace(/\"/g, '\\"'); +} + +/** + * @internal + * Constructs a simple query string for an object that will match any existing objects with the same origin. + * This matches based on the object's raw document ID (_id) or the object's originId. + * + * @param type a saved object type + * @param id a saved object ID to check; this should be the object's originId if present, otherwise it should be the object's ID + * @returns a simple query string + */ +export function createOriginQuery(type: string, id: string) { + // 1st query term will match raw object IDs (_id), 2nd query term will match originId + // we intentionally do not include a namespace prefix for the raw object IDs, because this search is only for multi-namespace object types + return `"${createOriginQueryTerm(`${type}:${id}`)}" | "${createOriginQueryTerm(id)}"`; +} diff --git a/src/core/server/saved_objects/import/lib/validate_references.test.ts b/src/core/server/saved_objects/import/lib/validate_references.test.ts index c6cbc2cacc759..ca0269314c4bf 100644 --- a/src/core/server/saved_objects/import/lib/validate_references.test.ts +++ b/src/core/server/saved_objects/import/lib/validate_references.test.ts @@ -18,7 +18,12 @@ describe('getNonExistingReferenceAsKeys()', () => { }); test('returns empty response when no objects exist', async () => { - const result = await getNonExistingReferenceAsKeys([], savedObjectsClient); + const result = await getNonExistingReferenceAsKeys( + [], + savedObjectsClient, + undefined, + new Map() + ); expect(result).toEqual([]); expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(0); }); @@ -45,12 +50,32 @@ describe('getNonExistingReferenceAsKeys()', () => { savedObjects, savedObjectsClient, undefined, + new Map(), retries ); expect(result).toEqual([]); expect(savedObjectsClient.bulkGet).not.toHaveBeenCalled(); }); + test('skips references when an importStateMap entry indicates that we have already found an origin match with a different ID', async () => { + const savedObjects = [ + { + id: '2', + type: 'visualization', + attributes: {}, + references: [{ name: 'ref_0', type: 'index-pattern', id: '1' }], + }, + ]; + const result = await getNonExistingReferenceAsKeys( + savedObjects, + savedObjectsClient, + undefined, + new Map([[`index-pattern:1`, { isOnlyReference: true, destinationId: 'not-1' }]]) + ); + expect(result).toEqual([]); + expect(savedObjectsClient.bulkGet).not.toHaveBeenCalled(); + }); + test('removes references that exist within savedObjects', async () => { const savedObjects = [ { @@ -72,7 +97,12 @@ describe('getNonExistingReferenceAsKeys()', () => { ], }, ]; - const result = await getNonExistingReferenceAsKeys(savedObjects, savedObjectsClient); + const result = await getNonExistingReferenceAsKeys( + savedObjects, + savedObjectsClient, + undefined, + new Map() + ); expect(result).toEqual([]); expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(0); }); @@ -102,7 +132,12 @@ describe('getNonExistingReferenceAsKeys()', () => { }, ], }); - const result = await getNonExistingReferenceAsKeys(savedObjects, savedObjectsClient); + const result = await getNonExistingReferenceAsKeys( + savedObjects, + savedObjectsClient, + undefined, + new Map() + ); expect(result).toEqual([]); expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(` [MockFunction] { @@ -147,7 +182,12 @@ describe('getNonExistingReferenceAsKeys()', () => { ], }, ]; - const result = await getNonExistingReferenceAsKeys(savedObjects, savedObjectsClient); + const result = await getNonExistingReferenceAsKeys( + savedObjects, + savedObjectsClient, + undefined, + new Map() + ); expect(result).toEqual([]); expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(0); }); @@ -196,7 +236,12 @@ describe('getNonExistingReferenceAsKeys()', () => { }, ], }); - const result = await getNonExistingReferenceAsKeys(savedObjects, savedObjectsClient); + const result = await getNonExistingReferenceAsKeys( + savedObjects, + savedObjectsClient, + undefined, + new Map() + ); expect(result).toEqual(['index-pattern:1', 'search:3']); expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(` [MockFunction] { @@ -242,7 +287,7 @@ describe('validateReferences()', () => { }); test('returns empty when no objects are passed in', async () => { - const result = await validateReferences([], savedObjectsClient); + const result = await validateReferences([], savedObjectsClient, undefined, new Map()); expect(result).toEqual([]); expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(0); }); @@ -340,7 +385,7 @@ describe('validateReferences()', () => { ], }, ]; - const result = await validateReferences(savedObjects, savedObjectsClient); + const result = await validateReferences(savedObjects, savedObjectsClient, undefined, new Map()); expect(result).toMatchInlineSnapshot(` Array [ Object { @@ -461,7 +506,13 @@ describe('validateReferences()', () => { ignoreMissingReferences: true, }, ]; - const result = await validateReferences(savedObjects, savedObjectsClient, undefined, retries); + const result = await validateReferences( + savedObjects, + savedObjectsClient, + undefined, + new Map(), + retries + ); expect(result).toEqual([]); expect(savedObjectsClient.bulkGet).not.toHaveBeenCalled(); }); @@ -491,7 +542,7 @@ describe('validateReferences()', () => { ], }, ]; - const result = await validateReferences(savedObjects, savedObjectsClient); + const result = await validateReferences(savedObjects, savedObjectsClient, undefined, new Map()); expect(result).toEqual([]); expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(1); }); @@ -517,7 +568,7 @@ describe('validateReferences()', () => { ], }, ]; - const result = await validateReferences(savedObjects, savedObjectsClient); + const result = await validateReferences(savedObjects, savedObjectsClient, undefined, new Map()); expect(result).toEqual([]); expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(0); }); @@ -542,7 +593,7 @@ describe('validateReferences()', () => { ], }, ]; - const result = await validateReferences(savedObjects, savedObjectsClient); + const result = await validateReferences(savedObjects, savedObjectsClient, undefined, new Map()); expect(result).toEqual([]); expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(0); }); @@ -574,7 +625,7 @@ describe('validateReferences()', () => { }, ]; await expect( - validateReferences(savedObjects, savedObjectsClient) + validateReferences(savedObjects, savedObjectsClient, undefined, new Map()) ).rejects.toThrowErrorMatchingInlineSnapshot( `"Error fetching references for imported objects"` ); diff --git a/src/core/server/saved_objects/import/lib/validate_references.ts b/src/core/server/saved_objects/import/lib/validate_references.ts index e4c29a5951c27..b6c7ebecd1aa3 100644 --- a/src/core/server/saved_objects/import/lib/validate_references.ts +++ b/src/core/server/saved_objects/import/lib/validate_references.ts @@ -9,6 +9,7 @@ import { SavedObject, SavedObjectsClientContract } from '../../types'; import { SavedObjectsImportFailure, SavedObjectsImportRetry } from '../types'; import { SavedObjectsImportError } from '../errors'; +import type { ImportStateMap } from './types'; const REF_TYPES_TO_VALIDATE = ['index-pattern', 'search']; @@ -25,7 +26,8 @@ const getObjectsToSkip = (retries: SavedObjectsImportRetry[] = []) => export async function getNonExistingReferenceAsKeys( savedObjects: SavedObject[], savedObjectsClient: SavedObjectsClientContract, - namespace?: string, + namespace: string | undefined, + importStateMap: ImportStateMap, retries?: SavedObjectsImportRetry[] ) { const objectsToSkip = getObjectsToSkip(retries); @@ -33,11 +35,17 @@ export async function getNonExistingReferenceAsKeys( // Collect all references within objects for (const savedObject of savedObjects) { if (objectsToSkip.has(`${savedObject.type}:${savedObject.id}`)) { - // skip objects with retries that have specified `ignoreMissingReferences` + // skip objects with retries that have specified `ignoreMissingReferences`, or that share an origin with an existing object that has a different ID continue; } const filteredReferences = (savedObject.references || []).filter(filterReferencesToValidate); for (const { type, id } of filteredReferences) { + const key = `${type}:${id}`; + const { isOnlyReference, destinationId } = importStateMap.get(key) ?? {}; + if (isOnlyReference && destinationId) { + // We previously searched for this reference and found one with a matching origin, skip validating this + continue; + } collector.set(`${type}:${id}`, { type, id }); } } @@ -76,7 +84,8 @@ export async function getNonExistingReferenceAsKeys( export async function validateReferences( savedObjects: Array>, savedObjectsClient: SavedObjectsClientContract, - namespace?: string, + namespace: string | undefined, + importStateMap: ImportStateMap, retries?: SavedObjectsImportRetry[] ) { const objectsToSkip = getObjectsToSkip(retries); @@ -85,6 +94,7 @@ export async function validateReferences( savedObjects, savedObjectsClient, namespace, + importStateMap, retries ); diff --git a/src/core/server/saved_objects/import/resolve_import_errors.test.mock.ts b/src/core/server/saved_objects/import/resolve_import_errors.test.mock.ts index 8723fadcef024..3cf4de850f4df 100644 --- a/src/core/server/saved_objects/import/resolve_import_errors.test.mock.ts +++ b/src/core/server/saved_objects/import/resolve_import_errors.test.mock.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import type { checkReferenceOrigins } from './lib/check_reference_origins'; import type { validateRetries } from './lib/validate_retries'; import type { createObjectsFilter } from './lib/create_objects_filter'; import type { collectSavedObjects } from './lib/collect_saved_objects'; @@ -17,6 +18,13 @@ import type { splitOverwrites } from './lib/split_overwrites'; import type { createSavedObjects } from './lib/create_saved_objects'; import type { executeImportHooks } from './lib/execute_import_hooks'; +export const mockCheckReferenceOrigins = jest.fn() as jest.MockedFunction< + typeof checkReferenceOrigins +>; +jest.mock('./lib/check_reference_origins', () => ({ + checkReferenceOrigins: mockCheckReferenceOrigins, +})); + export const mockValidateRetries = jest.fn() as jest.MockedFunction; jest.mock('./lib/validate_retries', () => ({ validateRetries: mockValidateRetries, diff --git a/src/core/server/saved_objects/import/resolve_import_errors.test.ts b/src/core/server/saved_objects/import/resolve_import_errors.test.ts index ce6276af1fccb..c7bf097241846 100644 --- a/src/core/server/saved_objects/import/resolve_import_errors.test.ts +++ b/src/core/server/saved_objects/import/resolve_import_errors.test.ts @@ -7,6 +7,7 @@ */ import { + mockCheckReferenceOrigins, mockValidateRetries, mockCreateObjectsFilter, mockCollectSavedObjects, @@ -48,6 +49,7 @@ describe('#importSavedObjectsFromStream', () => { collectedObjects: [], importStateMap: new Map(), }); + mockCheckReferenceOrigins.mockResolvedValue({ importStateMap: new Map() }); mockRegenerateIds.mockReturnValue(new Map()); mockValidateReferences.mockResolvedValue([]); mockCheckConflicts.mockResolvedValue({ @@ -173,6 +175,29 @@ describe('#importSavedObjectsFromStream', () => { expect(mockCollectSavedObjects).toHaveBeenCalledWith(mockCollectSavedObjectsOptions); }); + test('checks reference origins', async () => { + const retries = [createRetry()]; + const options = setupOptions({ retries }); + const collectedObjects = [createObject()]; + const importStateMap = new Map([ + [`${collectedObjects[0].type}:${collectedObjects[0].id}`, {}], + [`foo:bar`, { isOnlyReference: true }], + ]); + mockCollectSavedObjects.mockResolvedValue({ + errors: [], + collectedObjects, + importStateMap, + }); + + await resolveSavedObjectsImportErrors(options); + expect(mockCheckReferenceOrigins).toHaveBeenCalledWith({ + savedObjectsClient, + typeRegistry, + namespace, + importStateMap, + }); + }); + test('validates references', async () => { const retries = [createRetry()]; const options = setupOptions({ retries }); @@ -180,7 +205,13 @@ describe('#importSavedObjectsFromStream', () => { mockCollectSavedObjects.mockResolvedValue({ errors: [], collectedObjects, - importStateMap: new Map(), // doesn't matter + importStateMap: new Map([ + [`${collectedObjects[0].type}:${collectedObjects[0].id}`, {}], + [`foo:bar`, { isOnlyReference: true }], + ]), + }); + mockCheckReferenceOrigins.mockResolvedValue({ + importStateMap: new Map([[`foo:bar`, { isOnlyReference: true, id: 'baz' }]]), }); await resolveSavedObjectsImportErrors(options); @@ -188,6 +219,11 @@ describe('#importSavedObjectsFromStream', () => { collectedObjects, savedObjectsClient, namespace, + new Map([ + // This importStateMap is a combination of the other two + [`${collectedObjects[0].type}:${collectedObjects[0].id}`, {}], + [`foo:bar`, { isOnlyReference: true, id: 'baz' }], + ]), retries ); }); @@ -230,6 +266,7 @@ describe('#importSavedObjectsFromStream', () => { collectedObjects: [object], importStateMap: new Map(), // doesn't matter }); + // mockCheckReferenceOrigins returns an empty importStateMap by default await resolveSavedObjectsImportErrors(options); const objectWithReplacedReferences = { @@ -240,6 +277,7 @@ describe('#importSavedObjectsFromStream', () => { [objectWithReplacedReferences], savedObjectsClient, namespace, + new Map(), // doesn't matter retries ); }); @@ -389,7 +427,15 @@ describe('#importSavedObjectsFromStream', () => { mockCollectSavedObjects.mockResolvedValue({ errors: [errors[0]], collectedObjects: [], // doesn't matter - importStateMap: new Map(), // doesn't matter + importStateMap: new Map([ + ['foo', {}], + ['bar', {}], + ['baz', {}], + ['qux', { isOnlyReference: true }], + ]), + }); + mockCheckReferenceOrigins.mockResolvedValue({ + importStateMap: new Map([['qux', { isOnlyReference: true, destinationId: 'newId1' }]]), }); mockValidateReferences.mockResolvedValue([errors[1]]); mockRegenerateIds.mockReturnValue( @@ -407,14 +453,17 @@ describe('#importSavedObjectsFromStream', () => { }); mockGetImportStateMapForRetries.mockReturnValue( new Map([ - ['bar', { destinationId: 'newId' }], - ['baz', { destinationId: 'anotherNewId' }], + ['bar', { destinationId: 'newId2' }], // this is overridden by the checkConflicts result + ['baz', { destinationId: 'newId3' }], ]) ); + + // assert that the importStateMap is correctly composed of the results from the five modules const importStateMap = new Map([ ['foo', { destinationId: 'randomId1' }], ['bar', { destinationId: 'someId' }], - ['baz', { destinationId: 'anotherNewId' }], + ['baz', { destinationId: 'newId3' }], + ['qux', { isOnlyReference: true, destinationId: 'newId1' }], ]); const objectsToOverwrite = [createObject()]; const objectsToNotOverwrite = [createObject()]; diff --git a/src/core/server/saved_objects/import/resolve_import_errors.ts b/src/core/server/saved_objects/import/resolve_import_errors.ts index f6ed9de3825e6..7257692c455a1 100644 --- a/src/core/server/saved_objects/import/resolve_import_errors.ts +++ b/src/core/server/saved_objects/import/resolve_import_errors.ts @@ -20,13 +20,13 @@ import { createObjectsFilter, splitOverwrites, regenerateIds, + checkReferenceOrigins, validateReferences, validateRetries, createSavedObjects, getImportStateMapForRetries, checkConflicts, executeImportHooks, - ImportStateMap, } from './lib'; /** @@ -72,20 +72,20 @@ export async function resolveSavedObjectsImportErrors({ let successCount = 0; let errorAccumulator: SavedObjectsImportFailure[] = []; - let importStateMap: ImportStateMap = new Map(); const supportedTypes = typeRegistry.getImportableAndExportableTypes().map((type) => type.name); const filter = createObjectsFilter(retries); // Get the objects to resolve errors - const { errors: collectorErrors, collectedObjects: objectsToResolve } = await collectSavedObjects( - { - readStream, - objectLimit, - filter, - supportedTypes, - } - ); - errorAccumulator = [...errorAccumulator, ...collectorErrors]; + const collectSavedObjectsResult = await collectSavedObjects({ + readStream, + objectLimit, + filter, + supportedTypes, + }); + // Map of all IDs for objects that we are attempting to import, and any references that are not included in the read stream; + // each value is empty by default + let importStateMap = collectSavedObjectsResult.importStateMap; + errorAccumulator = [...errorAccumulator, ...collectSavedObjectsResult.errors]; // Create a map of references to replace for each object to avoid iterating through // retries for every object to resolve @@ -99,7 +99,7 @@ export async function resolveSavedObjectsImportErrors({ } // Replace references - for (const savedObject of objectsToResolve) { + for (const savedObject of collectSavedObjectsResult.collectedObjects) { const refMap = retriesReferencesMap.get(`${savedObject.type}:${savedObject.id}`); if (!refMap) { continue; @@ -107,15 +107,26 @@ export async function resolveSavedObjectsImportErrors({ for (const reference of savedObject.references || []) { if (refMap[`${reference.type}:${reference.id}`]) { reference.id = refMap[`${reference.type}:${reference.id}`]; + // Any reference ID changed here will supersede the results of checkReferenceOrigins below; this is intentional. } } } + // Check any references that aren't included in the import file and retries, to see if they have a match with a different origin + const checkReferenceOriginsResult = await checkReferenceOrigins({ + savedObjectsClient, + typeRegistry, + namespace, + importStateMap, + }); + importStateMap = new Map([...importStateMap, ...checkReferenceOriginsResult.importStateMap]); + // Validate references const validateReferencesResult = await validateReferences( - objectsToResolve, + collectSavedObjectsResult.collectedObjects, savedObjectsClient, namespace, + importStateMap, retries ); errorAccumulator = [...errorAccumulator, ...validateReferencesResult]; @@ -123,12 +134,15 @@ export async function resolveSavedObjectsImportErrors({ if (createNewCopies) { // In case any missing reference errors were resolved, ensure that we regenerate those object IDs as well // This is because a retry to resolve a missing reference error may not necessarily specify a destinationId - importStateMap = regenerateIds(objectsToResolve); + importStateMap = new Map([ + ...importStateMap, // preserve any entries for references that aren't included in collectedObjects + ...regenerateIds(collectSavedObjectsResult.collectedObjects), + ]); } // Check single-namespace objects for conflicts in this namespace, and check multi-namespace objects for conflicts across all namespaces const checkConflictsParams = { - objects: objectsToResolve, + objects: collectSavedObjectsResult.collectedObjects, savedObjectsClient, namespace, retries, @@ -192,7 +206,10 @@ export async function resolveSavedObjectsImportErrors({ }), ]; }; - const { objectsToOverwrite, objectsToNotOverwrite } = splitOverwrites(objectsToResolve, retries); + const { objectsToOverwrite, objectsToNotOverwrite } = splitOverwrites( + collectSavedObjectsResult.collectedObjects, + retries + ); await bulkCreateObjects(objectsToOverwrite, true); await bulkCreateObjects(objectsToNotOverwrite); diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json index e7d2c630fc130..671072999d90a 100644 --- a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json +++ b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json @@ -710,3 +710,60 @@ "type": "doc" } } + +{ + "type": "doc", + "value": { + "id": "index-pattern:inbound-reference-origin-match-1-newId", + "index": ".kibana", + "source": { + "originId": "inbound-reference-origin-match-1", + "index-pattern": { + "title": "This is used to test if an imported object with a reference to this originId will be remapped properly" + }, + "namespaces": ["*"], + "type": "index-pattern", + "migrationVersion": { "index-pattern": "8.0.0" }, + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "index-pattern:inbound-reference-origin-match-2a", + "index": ".kibana", + "source": { + "originId": "inbound-reference-origin-match-2", + "index-pattern": { + "title": "This is used to test if an imported object with a reference to this originId will *not* be remapped" + }, + "namespaces": ["*"], + "type": "index-pattern", + "migrationVersion": { "index-pattern": "8.0.0" }, + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "index-pattern:inbound-reference-origin-match-2b", + "index": ".kibana", + "source": { + "originId": "inbound-reference-origin-match-2", + "index-pattern": { + "title": "This is used to test if an imported object with a reference to this originId will *not* be remapped" + }, + "namespaces": ["*"], + "type": "index-pattern", + "migrationVersion": { "index-pattern": "8.0.0" }, + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} diff --git a/x-pack/test/saved_object_api_integration/common/suites/import.ts b/x-pack/test/saved_object_api_integration/common/suites/import.ts index b959b32f5fb95..58b323e86d607 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/import.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/import.ts @@ -8,23 +8,34 @@ import expect from '@kbn/expect'; import { SuperTest } from 'supertest'; import type { Client } from '@elastic/elasticsearch'; +import type { SavedObjectReference } from 'src/core/server'; import { SAVED_OBJECT_TEST_CASES as CASES } from '../lib/saved_object_test_cases'; import { SPACES } from '../lib/spaces'; import { expectResponses, getUrlPrefix, getTestTitle } from '../lib/saved_object_test_utils'; import { ExpectResponseBody, TestCase, TestDefinition, TestSuite } from '../lib/types'; export interface ImportTestDefinition extends TestDefinition { - request: Array<{ type: string; id: string; originId?: string }>; + request: Array<{ + type: string; + id: string; + originId?: string; + references?: SavedObjectReference[]; + }>; overwrite: boolean; createNewCopies: boolean; } export type ImportTestSuite = TestSuite; -export interface ImportTestCase extends TestCase { +export type FailureType = + | 'unsupported_type' + | 'conflict' + | 'ambiguous_conflict' + | 'missing_references'; +export interface ImportTestCase extends Omit { originId?: string; expectedNewId?: string; + references?: SavedObjectReference[]; successParam?: string; - failure?: 400 | 409; // only used for permitted response case - fail409Param?: string; + failureType?: FailureType; // only used for permitted response case } const NEW_ATTRIBUTE_KEY = 'title'; // all type mappings include this attribute, for simplicity's sake @@ -37,34 +48,60 @@ const NEW_ATTRIBUTE_VAL = `New attribute value ${Date.now()}`; // * id: conflict_3 // * id: conflict_4a, originId: conflict_4 // using the seven conflict test case objects below, we can exercise various permutations of exact/inexact/ambiguous conflict scenarios -const CID = 'conflict_'; +const { HIDDEN, ...REMAINING_CASES } = CASES; export const TEST_CASES: Record = Object.freeze({ - ...CASES, - CONFLICT_1_OBJ: Object.freeze({ type: 'sharedtype', id: `${CID}1` }), - CONFLICT_1A_OBJ: Object.freeze({ type: 'sharedtype', id: `${CID}1a`, originId: `${CID}1` }), - CONFLICT_1B_OBJ: Object.freeze({ type: 'sharedtype', id: `${CID}1b`, originId: `${CID}1` }), - CONFLICT_2A_OBJ: Object.freeze({ type: 'sharedtype', id: `${CID}2a`, originId: `${CID}2` }), - CONFLICT_2C_OBJ: Object.freeze({ type: 'sharedtype', id: `${CID}2c`, originId: `${CID}2` }), - CONFLICT_2D_OBJ: Object.freeze({ type: 'sharedtype', id: `${CID}2d`, originId: `${CID}2` }), + ...REMAINING_CASES, + CONFLICT_1_OBJ: Object.freeze({ type: 'sharedtype', id: `conflict_1` }), + CONFLICT_1A_OBJ: Object.freeze({ type: 'sharedtype', id: `conflict_1a`, originId: `conflict_1` }), + CONFLICT_1B_OBJ: Object.freeze({ type: 'sharedtype', id: `conflict_1b`, originId: `conflict_1` }), + CONFLICT_2A_OBJ: Object.freeze({ type: 'sharedtype', id: `conflict_2a`, originId: `conflict_2` }), + CONFLICT_2C_OBJ: Object.freeze({ type: 'sharedtype', id: `conflict_2c`, originId: `conflict_2` }), + CONFLICT_2D_OBJ: Object.freeze({ type: 'sharedtype', id: `conflict_2d`, originId: `conflict_2` }), CONFLICT_3A_OBJ: Object.freeze({ type: 'sharedtype', - id: `${CID}3a`, - originId: `${CID}3`, - expectedNewId: `${CID}3`, + id: `conflict_3a`, + originId: `conflict_3`, + expectedNewId: `conflict_3`, + }), + CONFLICT_4_OBJ: Object.freeze({ + type: 'sharedtype', + id: `conflict_4`, + expectedNewId: `conflict_4a`, }), - CONFLICT_4_OBJ: Object.freeze({ type: 'sharedtype', id: `${CID}4`, expectedNewId: `${CID}4a` }), NEW_SINGLE_NAMESPACE_OBJ: Object.freeze({ type: 'isolatedtype', id: 'new-isolatedtype-id' }), NEW_MULTI_NAMESPACE_OBJ: Object.freeze({ type: 'sharedtype', id: 'new-sharedtype-id' }), NEW_NAMESPACE_AGNOSTIC_OBJ: Object.freeze({ type: 'globaltype', id: 'new-globaltype-id' }), }); +export const SPECIAL_TEST_CASES: Record = Object.freeze({ + HIDDEN, + OUTBOUND_REFERENCE_ORIGIN_MATCH_1_OBJ: Object.freeze({ + // This object does not already exist, but it has a reference to the originId of an index pattern that does exist. + // We use index patterns because they are one of the few reference types that are validated, so the import will fail if the reference + // is broken. + // This import is designed to succeed because there is exactly one origin match for its reference, and that reference will be changed to + // match the index pattern's new ID. + type: 'sharedtype', + id: 'outbound-reference-origin-match-1', + references: [{ name: '1', type: 'index-pattern', id: 'inbound-reference-origin-match-1' }], + }), + OUTBOUND_REFERENCE_ORIGIN_MATCH_2_OBJ: Object.freeze({ + // This object does not already exist, but it has a reference to the originId of two index patterns that do exist. + // This import is designed to fail because there are two origin matches for its reference, and we can't currently handle ambiguous + // destinations for reference origin matches. + type: 'sharedtype', + id: 'outbound-reference-origin-match-2', + references: [{ name: '1', type: 'index-pattern', id: 'inbound-reference-origin-match-2' }], + }), +}); /** * Test cases have additional properties that we don't want to send in HTTP Requests */ -const createRequest = ({ type, id, originId }: ImportTestCase) => ({ +const createRequest = ({ type, id, originId, references }: ImportTestCase) => ({ type, id, ...(originId && { originId }), + ...(references && { references }), }); const getConflictDest = (id: string) => ({ @@ -73,8 +110,20 @@ const getConflictDest = (id: string) => ({ updatedAt: '2017-09-21T18:59:16.270Z', }); +export const importTestCaseFailures = { + failUnsupportedType: (condition?: boolean): { failureType?: 'unsupported_type' } => + condition !== false ? { failureType: 'unsupported_type' } : {}, + failConflict: (condition?: boolean): { failureType?: 'conflict' } => + condition !== false ? { failureType: 'conflict' } : {}, + failAmbiguousConflict: (condition?: boolean): { failureType?: 'ambiguous_conflict' } => + condition !== false ? { failureType: 'ambiguous_conflict' } : {}, + failMissingReferences: (condition?: boolean): { failureType?: 'missing_references' } => + condition !== false ? { failureType: 'missing_references' } : {}, +}; + export function importTestSuiteFactory(es: Client, esArchiver: any, supertest: SuperTest) { - const expectSavedObjectForbidden = expectResponses.forbiddenTypes('bulk_create'); + const expectSavedObjectForbidden = (action: string, typeOrTypes: string | string[]) => + expectResponses.forbiddenTypes(action)(typeOrTypes); const expectResponseBody = ( testCases: ImportTestCase | ImportTestCase[], @@ -88,12 +137,12 @@ export function importTestSuiteFactory(es: Client, esArchiver: any, supertest: S const testCaseArray = Array.isArray(testCases) ? testCases : [testCases]; if (statusCode === 403) { const types = testCaseArray.map((x) => x.type); - await expectSavedObjectForbidden(types)(response); + await expectSavedObjectForbidden('bulk_create', types)(response); } else { // permitted const { success, successCount, successResults, errors } = response.body; - const expectedSuccesses = testCaseArray.filter((x) => !x.failure); - const expectedFailures = testCaseArray.filter((x) => x.failure); + const expectedSuccesses = testCaseArray.filter((x) => !x.failureType); + const expectedFailures = testCaseArray.filter((x) => x.failureType); expect(success).to.eql(expectedFailures.length === 0); expect(successCount).to.eql(expectedSuccesses.length); if (expectedFailures.length) { @@ -148,30 +197,37 @@ export function importTestSuiteFactory(es: Client, esArchiver: any, supertest: S } } for (let i = 0; i < expectedFailures.length; i++) { - const { type, id, failure, fail409Param, expectedNewId } = expectedFailures[i]; + const { type, id, failureType, expectedNewId } = expectedFailures[i]; // we don't know the order of the returned errors; search for each one const object = (errors as Array>).find( (x) => x.type === type && x.id === id ); expect(object).not.to.be(undefined); - if (failure === 400) { - expect(object!.error).to.eql({ type: 'unsupported_type' }); - } else { - // 409 - let error: Record = { - type: 'conflict', - ...(expectedNewId && { destinationId: expectedNewId }), - }; - if (fail409Param === 'ambiguous_conflict_2c') { - // "ambiguous destination" conflict - error = { - type: 'ambiguous_conflict', - // response destinations should be sorted by updatedAt in descending order, then ID in ascending order - destinations: [getConflictDest(`${CID}2a`), getConflictDest(`${CID}2b`)], - }; - } - expect(object!.error).to.eql(error); + const expectedError: Record = { type: failureType }; + switch (failureType!) { + case 'unsupported_type': + break; + case 'conflict': + if (expectedNewId) { + expectedError.destinationId = expectedNewId; + } + break; + case 'ambiguous_conflict': + // We only have one test case for ambiguous conflicts, so these destination IDs are hardcoded below for simplicity. + // Response destinations should be sorted by updatedAt in descending order, then ID in ascending order. + expectedError.destinations = [ + getConflictDest(`conflict_2a`), + getConflictDest(`conflict_2b`), + ]; + break; + case 'missing_references': + // We only have one test case for missing references, so this reference is hardcoded below for simplicity. + expectedError.references = [ + { type: 'index-pattern', id: 'inbound-reference-origin-match-2' }, + ]; + break; } + expect(object!.error).to.eql(expectedError); } } }; diff --git a/x-pack/test/saved_object_api_integration/common/suites/resolve_import_errors.ts b/x-pack/test/saved_object_api_integration/common/suites/resolve_import_errors.ts index 6de4e6dfbdcfa..cd4123433cb8b 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/resolve_import_errors.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/resolve_import_errors.ts @@ -8,6 +8,7 @@ import expect from '@kbn/expect'; import { SuperTest } from 'supertest'; import type { Client } from '@elastic/elasticsearch'; +import type { SavedObjectReference, SavedObjectsImportRetry } from 'src/core/server'; import { SAVED_OBJECT_TEST_CASES as CASES } from '../lib/saved_object_test_cases'; import { SPACES } from '../lib/spaces'; import { expectResponses, getUrlPrefix, getTestTitle } from '../lib/saved_object_test_utils'; @@ -15,18 +16,32 @@ import { ExpectResponseBody, TestCase, TestDefinition, TestSuite } from '../lib/ export interface ResolveImportErrorsTestDefinition extends TestDefinition { request: { - objects: Array<{ type: string; id: string; originId?: string }>; - retries: Array<{ type: string; id: string; overwrite: boolean; destinationId?: string }>; + objects: Array<{ + type: string; + id: string; + originId?: string; + references?: SavedObjectReference[]; + }>; + retries: Array<{ + type: string; + id: string; + overwrite: boolean; + destinationId?: string; + replaceReferences?: SavedObjectsImportRetry['replaceReferences']; + }>; }; overwrite: boolean; createNewCopies: boolean; } export type ResolveImportErrorsTestSuite = TestSuite; -export interface ResolveImportErrorsTestCase extends TestCase { +export type FailureType = 'unsupported_type' | 'conflict'; +export interface ResolveImportErrorsTestCase extends Omit { originId?: string; expectedNewId?: string; + references?: SavedObjectReference[]; + replaceReferences?: SavedObjectsImportRetry['replaceReferences']; successParam?: string; - failure?: 400 | 409; // only used for permitted response case + failureType?: FailureType; // only used for permitted response case } const NEW_ATTRIBUTE_KEY = 'title'; // all type mappings include this attribute, for simplicity's sake @@ -39,8 +54,9 @@ const NEW_ATTRIBUTE_VAL = `New attribute value ${Date.now()}`; // * id: conflict_3 // * id: conflict_4a, originId: conflict_4 // using the five conflict test case objects below, we can exercise various permutations of exact/inexact/ambiguous conflict scenarios +const { HIDDEN, ...REMAINING_CASES } = CASES; export const TEST_CASES: Record = Object.freeze({ - ...CASES, + ...REMAINING_CASES, CONFLICT_1A_OBJ: Object.freeze({ type: 'sharedtype', id: `conflict_1a`, @@ -71,32 +87,78 @@ export const TEST_CASES: Record = Object.fr expectedNewId: `conflict_4a`, }), }); +export const SPECIAL_TEST_CASES: Record = Object.freeze({ + HIDDEN, + OUTBOUND_REFERENCE_ORIGIN_MATCH_1_OBJ: Object.freeze({ + // This object does not already exist, but it has a reference to the originId of an index pattern that does exist. + // We use index patterns because they are one of the few reference types that are validated, so the import will fail if the reference + // is broken. + // This import is designed to succeed because there is exactly one origin match for its reference, and that reference will be changed to + // match the index pattern's new ID. + type: 'sharedtype', + id: 'outbound-reference-origin-match-1', + references: [{ name: '1', type: 'index-pattern', id: 'inbound-reference-origin-match-1' }], + }), + OUTBOUND_REFERENCE_ORIGIN_MATCH_2_OBJ: Object.freeze({ + // This object does not already exist, but it has a reference to the originId of two index patterns that do exist. + // This import would normally fail because there are two origin matches for its reference, and we can't currently handle ambiguous + // destinations for reference origin matches. + // However, when retrying we can specify which reference(s) should be replaced. + type: 'sharedtype', + id: 'outbound-reference-origin-match-2', + references: [{ name: '1', type: 'index-pattern', id: 'inbound-reference-origin-match-2' }], + replaceReferences: [ + { + type: 'index-pattern', + from: 'inbound-reference-origin-match-2', + to: 'inbound-reference-origin-match-2a', + }, + ], + }), +}); /** * Test cases have additional properties that we don't want to send in HTTP Requests */ const createRequest = ( - { type, id, originId, expectedNewId, successParam }: ResolveImportErrorsTestCase, + { + type, + id, + originId, + expectedNewId, + references, + replaceReferences, + successParam, + }: ResolveImportErrorsTestCase, overwrite: boolean ): ResolveImportErrorsTestDefinition['request'] => ({ - objects: [{ type, id, ...(originId && { originId }) }], + objects: [{ type, id, ...(originId && { originId }), ...(references && { references }) }], retries: [ { type, id, overwrite, ...(expectedNewId && { destinationId: expectedNewId }), + ...(replaceReferences && { replaceReferences }), ...(successParam === 'createNewCopy' && { createNewCopy: true }), }, ], }); +export const resolveImportErrorsTestCaseFailures = { + failUnsupportedType: (condition?: boolean): { failureType?: 'unsupported_type' } => + condition !== false ? { failureType: 'unsupported_type' } : {}, + failConflict: (condition?: boolean): { failureType?: 'conflict' } => + condition !== false ? { failureType: 'conflict' } : {}, +}; + export function resolveImportErrorsTestSuiteFactory( es: Client, esArchiver: any, supertest: SuperTest ) { - const expectSavedObjectForbidden = expectResponses.forbiddenTypes('bulk_create'); + const expectSavedObjectForbidden = (action: string, typeOrTypes: string | string[]) => + expectResponses.forbiddenTypes(action)(typeOrTypes); const expectResponseBody = ( testCases: ResolveImportErrorsTestCase | ResolveImportErrorsTestCase[], @@ -110,12 +172,12 @@ export function resolveImportErrorsTestSuiteFactory( const testCaseArray = Array.isArray(testCases) ? testCases : [testCases]; if (statusCode === 403) { const types = testCaseArray.map((x) => x.type); - await expectSavedObjectForbidden(types)(response); + await expectSavedObjectForbidden('bulk_create', types)(response); } else { // permitted const { success, successCount, successResults, errors } = response.body; - const expectedSuccesses = testCaseArray.filter((x) => !x.failure); - const expectedFailures = testCaseArray.filter((x) => x.failure); + const expectedSuccesses = testCaseArray.filter((x) => !x.failureType); + const expectedFailures = testCaseArray.filter((x) => x.failureType); expect(success).to.eql(expectedFailures.length === 0); expect(successCount).to.eql(expectedSuccesses.length); if (expectedFailures.length) { @@ -168,21 +230,23 @@ export function resolveImportErrorsTestSuiteFactory( } } for (let i = 0; i < expectedFailures.length; i++) { - const { type, id, failure, expectedNewId } = expectedFailures[i]; + const { type, id, failureType, expectedNewId } = expectedFailures[i]; // we don't know the order of the returned errors; search for each one const object = (errors as Array>).find( (x) => x.type === type && x.id === id ); expect(object).not.to.be(undefined); - if (failure === 400) { - expect(object!.error).to.eql({ type: 'unsupported_type' }); - } else { - // 409 - expect(object!.error).to.eql({ - type: 'conflict', - ...(expectedNewId && { destinationId: expectedNewId }), - }); + const expectedError: Record = { type: failureType }; + switch (failureType!) { + case 'unsupported_type': + break; + case 'conflict': + if (expectedNewId) { + expectedError.destinationId = expectedNewId; + } + break; } + expect(object!.error).to.eql(expectedError); } } }; diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/import.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/import.ts index 70ee1db175534..b1f1776a7c2f1 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/import.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/import.ts @@ -6,12 +6,14 @@ */ import { SPACES } from '../../common/lib/spaces'; -import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; import { importTestSuiteFactory, + importTestCaseFailures, TEST_CASES as CASES, + SPECIAL_TEST_CASES, ImportTestDefinition, } from '../../common/suites/import'; @@ -20,21 +22,23 @@ const { SPACE_1: { spaceId: SPACE_1_ID }, SPACE_2: { spaceId: SPACE_2_ID }, } = SPACES; -const { fail400, fail409 } = testCaseFailures; +const { failUnsupportedType, failConflict, failAmbiguousConflict, failMissingReferences } = + importTestCaseFailures; const destinationId = (condition?: boolean) => condition !== false ? { successParam: 'destinationId' } : {}; const newCopy = () => ({ successParam: 'createNewCopy' }); -const ambiguousConflict = (suffix: string) => ({ - failure: 409 as 409, - fail409Param: `ambiguous_conflict_${suffix}`, -}); const createNewCopiesTestCases = () => { // for each outcome, if failure !== undefined then we expect to receive // an error; otherwise, we expect to receive a success result - const cases = Object.entries(CASES).filter(([key]) => key !== 'HIDDEN'); - const importable = cases.map(([, val]) => ({ ...val, successParam: 'createNewCopies' })); - const nonImportable = [{ ...CASES.HIDDEN, ...fail400() }]; + const importable = Object.entries(CASES).map(([, val]) => ({ + ...val, + successParam: 'createNewCopies', + })); + const nonImportable = [{ ...CASES.HIDDEN, ...failUnsupportedType() }]; // unsupported_type is an "unresolvable" error + // Other special test cases are excluded because they can result in "resolvable" errors that will prevent the rest of the objects from + // being created. The test suite assumes that when the createNewCopies option is enabled, all non-error results are actually created, + // and it makes assertions based on that. const all = [...importable, ...nonImportable]; return { importable, nonImportable, all }; }; @@ -46,76 +50,92 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { // when overwrite=true, all of the objects in this group are created successfully, so we can check the created object attributes { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, - ...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID), + ...failConflict(!overwrite && spaceId === DEFAULT_SPACE_ID), }, - { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail409(!overwrite && spaceId === SPACE_1_ID) }, - { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail409(!overwrite && spaceId === SPACE_2_ID) }, - { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, + { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...failConflict(!overwrite && spaceId === SPACE_1_ID) }, + { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...failConflict(!overwrite && spaceId === SPACE_2_ID) }, + { ...CASES.NAMESPACE_AGNOSTIC, ...failConflict(!overwrite) }, CASES.NEW_SINGLE_NAMESPACE_OBJ, CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, ]; - const group1NonImportable = [{ ...CASES.HIDDEN, ...fail400() }]; + const group1NonImportable = [{ ...CASES.HIDDEN, ...failUnsupportedType() }]; const group1All = group1Importable.concat(group1NonImportable); const group2 = [ // when overwrite=true, all of the objects in this group are created successfully, so we can check the created object attributes CASES.NEW_MULTI_NAMESPACE_OBJ, - { ...CASES.MULTI_NAMESPACE_ALL_SPACES, ...fail409(!overwrite) }, + { ...CASES.MULTI_NAMESPACE_ALL_SPACES, ...failConflict(!overwrite) }, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, - ...fail409(!overwrite && (spaceId === DEFAULT_SPACE_ID || spaceId === SPACE_1_ID)), + ...failConflict(!overwrite && (spaceId === DEFAULT_SPACE_ID || spaceId === SPACE_1_ID)), ...destinationId(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, - ...fail409(!overwrite && spaceId === SPACE_1_ID), + ...failConflict(!overwrite && spaceId === SPACE_1_ID), ...destinationId(spaceId !== SPACE_1_ID), }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, - ...fail409(!overwrite && spaceId === SPACE_2_ID), + ...failConflict(!overwrite && spaceId === SPACE_2_ID), ...destinationId(spaceId !== SPACE_2_ID), }, { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, - ...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID), + ...failConflict(!overwrite && spaceId === DEFAULT_SPACE_ID), ...destinationId(spaceId !== DEFAULT_SPACE_ID), }, { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, - ...fail409(!overwrite && spaceId === SPACE_1_ID), + ...failConflict(!overwrite && spaceId === SPACE_1_ID), ...destinationId(spaceId !== SPACE_1_ID), }, { ...CASES.CONFLICT_1A_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID { ...CASES.CONFLICT_1B_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID - { ...CASES.CONFLICT_3A_OBJ, ...fail409(!overwrite), ...destinationId() }, // "inexact match" conflict - { ...CASES.CONFLICT_4_OBJ, ...fail409(!overwrite), ...destinationId() }, // "inexact match" conflict + { ...CASES.CONFLICT_3A_OBJ, ...failConflict(!overwrite), ...destinationId() }, // "inexact match" conflict + { ...CASES.CONFLICT_4_OBJ, ...failConflict(!overwrite), ...destinationId() }, // "inexact match" conflict ]; const group3 = [ // when overwrite=true, all of the objects in this group are errors, so we cannot check the created object attributes // grouping errors together simplifies the test suite code - { ...CASES.CONFLICT_2C_OBJ, ...ambiguousConflict('2c') }, // "ambiguous destination" conflict + { ...CASES.CONFLICT_2C_OBJ, ...failAmbiguousConflict() }, // "ambiguous destination" conflict ]; const group4 = [ // This group needs to be executed *after* the previous test case, because those error assertions include metadata of the destinations, // and *these* test cases would change that metadata. - { ...CASES.CONFLICT_2A_OBJ, ...fail409(!overwrite) }, // "exact match" conflict with 2a + { ...CASES.CONFLICT_2A_OBJ, ...failConflict(!overwrite) }, // "exact match" conflict with 2a { // "inexact match" conflict with 2b (since 2a already has a conflict source, this is not an ambiguous destination conflict) ...CASES.CONFLICT_2C_OBJ, - ...fail409(!overwrite), + ...failConflict(!overwrite), ...destinationId(), expectedNewId: 'conflict_2b', }, ]; const group5 = [ // when overwrite=true, all of the objects in this group are created successfully, so we can check the created object attributes - { ...CASES.CONFLICT_1_OBJ, ...fail409(!overwrite) }, // "exact match" conflict + { ...CASES.CONFLICT_1_OBJ, ...failConflict(!overwrite) }, // "exact match" conflict CASES.CONFLICT_1A_OBJ, // no conflict because CONFLICT_1_OBJ is an exact match CASES.CONFLICT_1B_OBJ, // no conflict because CONFLICT_1_OBJ is an exact match { ...CASES.CONFLICT_2C_OBJ, ...newCopy() }, // "ambiguous source and destination" conflict which results in a new destination ID and empty origin ID { ...CASES.CONFLICT_2D_OBJ, ...newCopy() }, // "ambiguous source and destination" conflict which results in a new destination ID and empty origin ID ]; - return { group1Importable, group1NonImportable, group1All, group2, group3, group4, group5 }; + const refOrigins = [ + // One of these cases will always generate a missing_references error, which is an "unresolvable" error that stops any other objects + // from being created in the import. Other test cases can have assertions based on the created objects' attributes when the overwrite + // option is enabled, but these test cases are simply asserting pass/fail, so this group needs to be tested separately. + { ...SPECIAL_TEST_CASES.OUTBOUND_REFERENCE_ORIGIN_MATCH_1_OBJ }, + { ...SPECIAL_TEST_CASES.OUTBOUND_REFERENCE_ORIGIN_MATCH_2_OBJ, ...failMissingReferences() }, + ]; + return { + group1Importable, + group1NonImportable, + group1All, + group2, + group3, + group4, + group5, + refOrigins, + }; }; export default function ({ getService }: FtrProviderContext) { @@ -133,50 +153,89 @@ export default function ({ getService }: FtrProviderContext) { if (createNewCopies) { const { importable, nonImportable, all } = createNewCopiesTestCases(); + const unauthorizedCommonTestDefinitions = [ + createTestDefinitions(importable, true, { createNewCopies, spaceId }), + createTestDefinitions(nonImportable, false, { createNewCopies, spaceId, singleRequest }), + createTestDefinitions(all, true, { + createNewCopies, + spaceId, + singleRequest, + responseBodyOverride: expectSavedObjectForbidden('bulk_create', [ + 'globaltype', + 'isolatedtype', + 'sharedtype', + 'sharecapabletype', + ]), + }), + ]; return { - unauthorized: [ - createTestDefinitions(importable, true, { createNewCopies, spaceId }), - createTestDefinitions(nonImportable, false, { createNewCopies, spaceId, singleRequest }), - createTestDefinitions(all, true, { - createNewCopies, - spaceId, - singleRequest, - responseBodyOverride: expectSavedObjectForbidden([ - 'globaltype', - 'isolatedtype', - 'sharedtype', - 'sharecapabletype', - ]), - }), - ].flat(), + unauthorizedRead: unauthorizedCommonTestDefinitions.flat(), + unauthorizedWrite: unauthorizedCommonTestDefinitions.flat(), authorized: createTestDefinitions(all, false, { createNewCopies, spaceId, singleRequest }), }; } - const { group1Importable, group1NonImportable, group1All, group2, group3, group4, group5 } = - createTestCases(overwrite, spaceId); - return { - unauthorized: [ - createTestDefinitions(group1Importable, true, { overwrite, spaceId }), - createTestDefinitions(group1NonImportable, false, { overwrite, spaceId, singleRequest }), - createTestDefinitions(group1All, true, { + const { + group1Importable, + group1NonImportable, + group1All, + group2, + group3, + group4, + group5, + refOrigins, + } = createTestCases(overwrite, spaceId); + const unauthorizedCommonTestDefinitions = [ + createTestDefinitions(group1Importable, true, { overwrite, spaceId }), + createTestDefinitions(group1NonImportable, false, { overwrite, spaceId, singleRequest }), + createTestDefinitions(group1All, true, { + overwrite, + spaceId, + singleRequest, + responseBodyOverride: expectSavedObjectForbidden('bulk_create', [ + 'globaltype', + 'isolatedtype', + ]), + }), + createTestDefinitions(group2, true, { overwrite, spaceId, singleRequest }), + createTestDefinitions(group3, true, { overwrite, spaceId, singleRequest }), + createTestDefinitions(group4, true, { overwrite, spaceId, singleRequest }), + createTestDefinitions(group5, true, { overwrite, spaceId, singleRequest }), + ]; + const unauthorizedReadTestDefinitions = [...unauthorizedCommonTestDefinitions]; + const unauthorizedWriteTestDefinitions = [...unauthorizedCommonTestDefinitions]; + const authorizedTestDefinitions = [ + createTestDefinitions(group1All, false, { overwrite, spaceId, singleRequest }), + createTestDefinitions(group2, false, { overwrite, spaceId, singleRequest }), + createTestDefinitions(group3, false, { overwrite, spaceId, singleRequest }), + createTestDefinitions(group4, false, { overwrite, spaceId, singleRequest }), + createTestDefinitions(group5, false, { overwrite, spaceId, singleRequest }), + ]; + if (!overwrite) { + // Only include this group of test cases if the overwrite option is not enabled + unauthorizedReadTestDefinitions.push( + createTestDefinitions(refOrigins, true, { overwrite, spaceId, singleRequest, - responseBodyOverride: expectSavedObjectForbidden(['globaltype', 'isolatedtype']), - }), - createTestDefinitions(group2, true, { overwrite, spaceId, singleRequest }), - createTestDefinitions(group3, true, { overwrite, spaceId, singleRequest }), - createTestDefinitions(group4, true, { overwrite, spaceId, singleRequest }), - createTestDefinitions(group5, true, { overwrite, spaceId, singleRequest }), - ].flat(), - authorized: [ - createTestDefinitions(group1All, false, { overwrite, spaceId, singleRequest }), - createTestDefinitions(group2, false, { overwrite, spaceId, singleRequest }), - createTestDefinitions(group3, false, { overwrite, spaceId, singleRequest }), - createTestDefinitions(group4, false, { overwrite, spaceId, singleRequest }), - createTestDefinitions(group5, false, { overwrite, spaceId, singleRequest }), - ].flat(), + responseBodyOverride: expectSavedObjectForbidden('bulk_get', ['index-pattern']), + }) + ); + unauthorizedWriteTestDefinitions.push( + createTestDefinitions(refOrigins, true, { + overwrite, + spaceId, + singleRequest, + }) + ); + authorizedTestDefinitions.push( + createTestDefinitions(refOrigins, false, { overwrite, spaceId, singleRequest }) + ); + } + return { + unauthorizedRead: unauthorizedReadTestDefinitions.flat(), + unauthorizedWrite: unauthorizedWriteTestDefinitions.flat(), + authorized: authorizedTestDefinitions.flat(), }; }; @@ -194,20 +253,20 @@ export default function ({ getService }: FtrProviderContext) { ? ' with createNewCopies enabled' : '' }`; - const { unauthorized, authorized } = createTests(overwrite, createNewCopies, spaceId); + const { unauthorizedRead, unauthorizedWrite, authorized } = createTests( + overwrite, + createNewCopies, + spaceId + ); const _addTests = (user: TestUser, tests: ImportTestDefinition[]) => { addTests(`${user.description}${suffix}`, { user, spaceId, tests }); }; - [ - users.noAccess, - users.legacyAll, - users.dualRead, - users.readGlobally, - users.readAtSpace, - users.allAtOtherSpace, - ].forEach((user) => { - _addTests(user, unauthorized); + [users.noAccess, users.legacyAll, users.allAtOtherSpace].forEach((user) => { + _addTests(user, unauthorizedRead); + }); + [users.dualRead, users.readGlobally, users.readAtSpace].forEach((user) => { + _addTests(user, unauthorizedWrite); }); [users.dualAll, users.allGlobally, users.allAtSpace, users.superuser].forEach((user) => { _addTests(user, authorized); diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve_import_errors.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve_import_errors.ts index b59ae92325040..153c756ee6461 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve_import_errors.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve_import_errors.ts @@ -7,12 +7,14 @@ import { v4 as uuidv4 } from 'uuid'; import { SPACES } from '../../common/lib/spaces'; -import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; import { resolveImportErrorsTestSuiteFactory, + resolveImportErrorsTestCaseFailures, TEST_CASES as CASES, + SPECIAL_TEST_CASES, ResolveImportErrorsTestDefinition, } from '../../common/suites/resolve_import_errors'; @@ -21,7 +23,7 @@ const { SPACE_1: { spaceId: SPACE_1_ID }, SPACE_2: { spaceId: SPACE_2_ID }, } = SPACES; -const { fail400, fail409 } = testCaseFailures; +const { failUnsupportedType, failConflict } = resolveImportErrorsTestCaseFailures; const destinationId = (condition?: boolean) => condition !== false ? { successParam: 'destinationId' } : {}; const newCopy = () => ({ successParam: 'createNewCopy' }); @@ -29,13 +31,12 @@ const newCopy = () => ({ successParam: 'createNewCopy' }); const createNewCopiesTestCases = () => { // for each outcome, if failure !== undefined then we expect to receive // an error; otherwise, we expect to receive a success result - const cases = Object.entries(CASES).filter(([key]) => key !== 'HIDDEN'); - const importable = cases.map(([, val]) => ({ + const importable = Object.entries(CASES).map(([, val]) => ({ ...val, successParam: 'createNewCopies', expectedNewId: uuidv4(), })); - const nonImportable = [{ ...CASES.HIDDEN, ...fail400() }]; + const nonImportable = [{ ...SPECIAL_TEST_CASES.HIDDEN, ...failUnsupportedType() }]; // unsupported_type is an "unresolvable" error const all = [...importable, ...nonImportable]; return { importable, nonImportable, all }; }; @@ -50,36 +51,36 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { ? CASES.SINGLE_NAMESPACE_SPACE_1 : CASES.SINGLE_NAMESPACE_SPACE_2; const group1Importable = [ - { ...singleNamespaceObject, ...fail409(!overwrite) }, - { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, + { ...singleNamespaceObject, ...failConflict(!overwrite) }, + { ...CASES.NAMESPACE_AGNOSTIC, ...failConflict(!overwrite) }, ]; - const group1NonImportable = [{ ...CASES.HIDDEN, ...fail400() }]; + const group1NonImportable = [{ ...SPECIAL_TEST_CASES.HIDDEN, ...failUnsupportedType() }]; const group1All = [...group1Importable, ...group1NonImportable]; const group2 = [ - { ...CASES.MULTI_NAMESPACE_ALL_SPACES, ...fail409(!overwrite) }, + { ...CASES.MULTI_NAMESPACE_ALL_SPACES, ...failConflict(!overwrite) }, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, - ...fail409(!overwrite && (spaceId === DEFAULT_SPACE_ID || spaceId === SPACE_1_ID)), + ...failConflict(!overwrite && (spaceId === DEFAULT_SPACE_ID || spaceId === SPACE_1_ID)), ...destinationId(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, - ...fail409(!overwrite && spaceId === SPACE_1_ID), + ...failConflict(!overwrite && spaceId === SPACE_1_ID), ...destinationId(spaceId !== SPACE_1_ID), }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, - ...fail409(!overwrite && spaceId === SPACE_2_ID), + ...failConflict(!overwrite && spaceId === SPACE_2_ID), ...destinationId(spaceId !== SPACE_2_ID), }, { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, - ...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID), + ...failConflict(!overwrite && spaceId === DEFAULT_SPACE_ID), ...destinationId(spaceId !== DEFAULT_SPACE_ID), }, { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, - ...fail409(!overwrite && spaceId === SPACE_1_ID), + ...failConflict(!overwrite && spaceId === SPACE_1_ID), ...destinationId(spaceId !== SPACE_1_ID), }, { ...CASES.CONFLICT_1A_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID @@ -87,11 +88,16 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { // all of the cases below represent imports that had an inexact match conflict or an ambiguous conflict // if we call _resolve_import_errors and don't specify overwrite, each of these will result in a conflict because an object with that // `expectedDestinationId` already exists - { ...CASES.CONFLICT_2C_OBJ, ...fail409(!overwrite), ...destinationId() }, // "ambiguous destination" conflict; if overwrite=true, will overwrite 'conflict_2a' - { ...CASES.CONFLICT_3A_OBJ, ...fail409(!overwrite), ...destinationId() }, // "inexact match" conflict; if overwrite=true, will overwrite 'conflict_3' - { ...CASES.CONFLICT_4_OBJ, ...fail409(!overwrite), ...destinationId() }, // "inexact match" conflict; if overwrite=true, will overwrite 'conflict_4a' + { ...CASES.CONFLICT_2C_OBJ, ...failConflict(!overwrite), ...destinationId() }, // "ambiguous destination" conflict; if overwrite=true, will overwrite 'conflict_2a' + { ...CASES.CONFLICT_3A_OBJ, ...failConflict(!overwrite), ...destinationId() }, // "inexact match" conflict; if overwrite=true, will overwrite 'conflict_3' + { ...CASES.CONFLICT_4_OBJ, ...failConflict(!overwrite), ...destinationId() }, // "inexact match" conflict; if overwrite=true, will overwrite 'conflict_4a' ]; - return { group1Importable, group1NonImportable, group1All, group2 }; + const refOrigins = [ + // These are in a separate group because they will result in a different 403 error for users who are unauthorized to read + { ...SPECIAL_TEST_CASES.OUTBOUND_REFERENCE_ORIGIN_MATCH_1_OBJ }, + { ...SPECIAL_TEST_CASES.OUTBOUND_REFERENCE_ORIGIN_MATCH_2_OBJ }, + ]; + return { group1Importable, group1NonImportable, group1All, group2, refOrigins }; }; export default function ({ getService }: FtrProviderContext) { @@ -107,45 +113,62 @@ export default function ({ getService }: FtrProviderContext) { if (createNewCopies) { const { importable, nonImportable, all } = createNewCopiesTestCases(); + const unauthorizedCommonTestDefinitions = [ + createTestDefinitions(importable, true, { createNewCopies, spaceId }), + createTestDefinitions(nonImportable, false, { createNewCopies, spaceId, singleRequest }), + createTestDefinitions(all, true, { + createNewCopies, + spaceId, + singleRequest, + responseBodyOverride: expectSavedObjectForbidden('bulk_create', [ + 'globaltype', + 'isolatedtype', + 'sharedtype', + 'sharecapabletype', + ]), + }), + ]; return { - unauthorized: [ - createTestDefinitions(importable, true, { createNewCopies, spaceId }), - createTestDefinitions(nonImportable, false, { createNewCopies, spaceId, singleRequest }), - createTestDefinitions(all, true, { - createNewCopies, - spaceId, - singleRequest, - responseBodyOverride: expectSavedObjectForbidden([ - 'globaltype', - 'isolatedtype', - 'sharedtype', - 'sharecapabletype', - ]), - }), - ].flat(), + unauthorizedRead: unauthorizedCommonTestDefinitions.flat(), + unauthorizedWrite: unauthorizedCommonTestDefinitions.flat(), authorized: createTestDefinitions(all, false, { createNewCopies, spaceId, singleRequest }), }; } - const { group1Importable, group1NonImportable, group1All, group2 } = createTestCases( - overwrite, - spaceId - ); + const { group1Importable, group1NonImportable, group1All, group2, refOrigins } = + createTestCases(overwrite, spaceId); + const unauthorizedCommonTestDefinitions = [ + createTestDefinitions(group1Importable, true, { overwrite, spaceId }), + createTestDefinitions(group1NonImportable, false, { overwrite, spaceId, singleRequest }), + createTestDefinitions(group1All, true, { + overwrite, + spaceId, + singleRequest, + responseBodyOverride: expectSavedObjectForbidden('bulk_create', [ + 'globaltype', + 'isolatedtype', + ]), + }), + createTestDefinitions(group2, true, { overwrite, spaceId, singleRequest }), + ]; return { - unauthorized: [ - createTestDefinitions(group1Importable, true, { overwrite, spaceId }), - createTestDefinitions(group1NonImportable, false, { overwrite, spaceId, singleRequest }), - createTestDefinitions(group1All, true, { + unauthorizedRead: [ + ...unauthorizedCommonTestDefinitions, + createTestDefinitions(refOrigins, true, { overwrite, spaceId, singleRequest, - responseBodyOverride: expectSavedObjectForbidden(['globaltype', 'isolatedtype']), + responseBodyOverride: expectSavedObjectForbidden('bulk_get', ['index-pattern']), }), - createTestDefinitions(group2, true, { overwrite, spaceId, singleRequest }), + ].flat(), + unauthorizedWrite: [ + ...unauthorizedCommonTestDefinitions, + createTestDefinitions(refOrigins, true, { overwrite, spaceId, singleRequest }), ].flat(), authorized: [ createTestDefinitions(group1All, false, { overwrite, spaceId, singleRequest }), createTestDefinitions(group2, false, { overwrite, spaceId, singleRequest }), + createTestDefinitions(refOrigins, false, { overwrite, spaceId, singleRequest }), ].flat(), }; }; @@ -164,20 +187,20 @@ export default function ({ getService }: FtrProviderContext) { ? ' with createNewCopies enabled' : '' }`; - const { unauthorized, authorized } = createTests(overwrite, createNewCopies, spaceId); + const { unauthorizedRead, unauthorizedWrite, authorized } = createTests( + overwrite, + createNewCopies, + spaceId + ); const _addTests = (user: TestUser, tests: ResolveImportErrorsTestDefinition[]) => { addTests(`${user.description}${suffix}`, { user, spaceId, tests }); }; - [ - users.noAccess, - users.legacyAll, - users.dualRead, - users.readGlobally, - users.readAtSpace, - users.allAtOtherSpace, - ].forEach((user) => { - _addTests(user, unauthorized); + [users.noAccess, users.legacyAll, users.allAtOtherSpace].forEach((user) => { + _addTests(user, unauthorizedRead); + }); + [users.dualRead, users.readGlobally, users.readAtSpace].forEach((user) => { + _addTests(user, unauthorizedWrite); }); [users.dualAll, users.allGlobally, users.allAtSpace, users.superuser].forEach((user) => { _addTests(user, authorized); diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/import.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/import.ts index df15354ae2673..04631641904a0 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/import.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/import.ts @@ -6,77 +6,81 @@ */ import { SPACES } from '../../common/lib/spaces'; -import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { importTestSuiteFactory, TEST_CASES as CASES } from '../../common/suites/import'; +import { + importTestSuiteFactory, + importTestCaseFailures, + TEST_CASES as CASES, + SPECIAL_TEST_CASES, +} from '../../common/suites/import'; const { DEFAULT: { spaceId: DEFAULT_SPACE_ID }, SPACE_1: { spaceId: SPACE_1_ID }, SPACE_2: { spaceId: SPACE_2_ID }, } = SPACES; -const { fail400, fail409 } = testCaseFailures; +const { failUnsupportedType, failConflict, failAmbiguousConflict, failMissingReferences } = + importTestCaseFailures; const destinationId = (condition?: boolean) => condition !== false ? { successParam: 'destinationId' } : {}; const newCopy = () => ({ successParam: 'createNewCopy' }); -const ambiguousConflict = (suffix: string) => ({ - failure: 409 as 409, - fail409Param: `ambiguous_conflict_${suffix}`, -}); const createNewCopiesTestCases = () => { - // for each outcome, if failure !== undefined then we expect to receive + // for each outcome, if failureType !== undefined then we expect to receive // an error; otherwise, we expect to receive a success result - const cases = Object.entries(CASES).filter(([key]) => key !== 'HIDDEN'); return [ - ...cases.map(([, val]) => ({ ...val, successParam: 'createNewCopies' })), - { ...CASES.HIDDEN, ...fail400() }, + ...Object.entries(CASES).map(([, val]) => ({ ...val, successParam: 'createNewCopies' })), + { ...SPECIAL_TEST_CASES.HIDDEN, ...failUnsupportedType() }, // unsupported_type is an "unresolvable" error + // Other special test cases are excluded because they can result in "resolvable" errors that will prevent the rest of the objects from + // being created. The test suite assumes that when the createNewCopies option is enabled, all non-error results are actually created, + // and it makes assertions based on that. ]; }; const createTestCases = (overwrite: boolean, spaceId: string) => { - // for each outcome, if failure !== undefined then we expect to receive + // for each outcome, if failureType !== undefined then we expect to receive // an error; otherwise, we expect to receive a success result const group1 = [ // when overwrite=true, all of the objects in this group are created successfully, so we can check the created object attributes { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, - ...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID), + ...failConflict(!overwrite && spaceId === DEFAULT_SPACE_ID), }, - { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail409(!overwrite && spaceId === SPACE_1_ID) }, - { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail409(!overwrite && spaceId === SPACE_2_ID) }, - { ...CASES.MULTI_NAMESPACE_ALL_SPACES, ...fail409(!overwrite) }, + { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...failConflict(!overwrite && spaceId === SPACE_1_ID) }, + { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...failConflict(!overwrite && spaceId === SPACE_2_ID) }, + { ...CASES.MULTI_NAMESPACE_ALL_SPACES, ...failConflict(!overwrite) }, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, - ...fail409(!overwrite && (spaceId === DEFAULT_SPACE_ID || spaceId === SPACE_1_ID)), + ...failConflict(!overwrite && (spaceId === DEFAULT_SPACE_ID || spaceId === SPACE_1_ID)), ...destinationId(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, - ...fail409(!overwrite && spaceId === SPACE_1_ID), + ...failConflict(!overwrite && spaceId === SPACE_1_ID), ...destinationId(spaceId !== SPACE_1_ID), }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, - ...fail409(!overwrite && spaceId === SPACE_2_ID), + ...failConflict(!overwrite && spaceId === SPACE_2_ID), ...destinationId(spaceId !== SPACE_2_ID), }, { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, - ...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID), + ...failConflict(!overwrite && spaceId === DEFAULT_SPACE_ID), ...destinationId(spaceId !== DEFAULT_SPACE_ID), }, { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, - ...fail409(!overwrite && spaceId === SPACE_1_ID), + ...failConflict(!overwrite && spaceId === SPACE_1_ID), ...destinationId(spaceId !== SPACE_1_ID), }, - { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, - { ...CASES.HIDDEN, ...fail400() }, + { ...CASES.NAMESPACE_AGNOSTIC, ...failConflict(!overwrite) }, + { ...SPECIAL_TEST_CASES.HIDDEN, ...failUnsupportedType() }, { ...CASES.CONFLICT_1A_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID { ...CASES.CONFLICT_1B_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID - { ...CASES.CONFLICT_3A_OBJ, ...fail409(!overwrite), ...destinationId() }, // "inexact match" conflict - { ...CASES.CONFLICT_4_OBJ, ...fail409(!overwrite), ...destinationId() }, // "inexact match" conflict + { ...CASES.CONFLICT_3A_OBJ, ...failConflict(!overwrite), ...destinationId() }, // "inexact match" conflict + { ...CASES.CONFLICT_4_OBJ, ...failConflict(!overwrite), ...destinationId() }, // "inexact match" conflict CASES.NEW_SINGLE_NAMESPACE_OBJ, CASES.NEW_MULTI_NAMESPACE_OBJ, CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, @@ -84,29 +88,36 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { const group2 = [ // when overwrite=true, all of the objects in this group are errors, so we cannot check the created object attributes // grouping errors together simplifies the test suite code - { ...CASES.CONFLICT_2C_OBJ, ...ambiguousConflict('2c') }, // "ambiguous destination" conflict + { ...CASES.CONFLICT_2C_OBJ, ...failAmbiguousConflict() }, // "ambiguous destination" conflict ]; const group3 = [ // This group needs to be executed *after* the previous test case, because those error assertions include metadata of the destinations, // and *these* test cases would change that metadata. - { ...CASES.CONFLICT_2A_OBJ, ...fail409(!overwrite) }, // "exact match" conflict with 2a + { ...CASES.CONFLICT_2A_OBJ, ...failConflict(!overwrite) }, // "exact match" conflict with 2a { // "inexact match" conflict with 2b (since 2a already has a conflict source, this is not an ambiguous destination conflict) ...CASES.CONFLICT_2C_OBJ, - ...fail409(!overwrite), + ...failConflict(!overwrite), ...destinationId(), expectedNewId: 'conflict_2b', }, ]; const group4 = [ // when overwrite=true, all of the objects in this group are created successfully, so we can check the created object attributes - { ...CASES.CONFLICT_1_OBJ, ...fail409(!overwrite) }, // "exact match" conflict + { ...CASES.CONFLICT_1_OBJ, ...failConflict(!overwrite) }, // "exact match" conflict CASES.CONFLICT_1A_OBJ, // no conflict because CONFLICT_1_OBJ is an exact match CASES.CONFLICT_1B_OBJ, // no conflict because CONFLICT_1_OBJ is an exact match { ...CASES.CONFLICT_2C_OBJ, ...newCopy() }, // "ambiguous source and destination" conflict which results in a new destination ID and empty origin ID { ...CASES.CONFLICT_2D_OBJ, ...newCopy() }, // "ambiguous source and destination" conflict which results in a new destination ID and empty origin ID ]; - return { group1, group2, group3, group4 }; + const refOrigins = [ + // One of these cases will always generate a missing_references error, which is an "unresolvable" error that stops any other objects + // from being created in the import. Other test cases can have assertions based on the created objects' attributes when the overwrite + // option is enabled, but these test cases are simply asserting pass/fail, so this group needs to be tested separately. + { ...SPECIAL_TEST_CASES.OUTBOUND_REFERENCE_ORIGIN_MATCH_1_OBJ }, + { ...SPECIAL_TEST_CASES.OUTBOUND_REFERENCE_ORIGIN_MATCH_2_OBJ, ...failMissingReferences() }, + ]; + return { group1, group2, group3, group4, refOrigins }; }; export default function ({ getService }: FtrProviderContext) { @@ -122,13 +133,18 @@ export default function ({ getService }: FtrProviderContext) { return createTestDefinitions(cases, false, { createNewCopies, spaceId, singleRequest }); } - const { group1, group2, group3, group4 } = createTestCases(overwrite, spaceId); - return [ + const { group1, group2, group3, group4, refOrigins } = createTestCases(overwrite, spaceId); + const tests = [ createTestDefinitions(group1, false, { overwrite, spaceId, singleRequest }), createTestDefinitions(group2, false, { overwrite, spaceId, singleRequest }), createTestDefinitions(group3, false, { overwrite, spaceId, singleRequest }), createTestDefinitions(group4, false, { overwrite, spaceId, singleRequest }), - ].flat(); + ]; + if (!overwrite) { + // Only include this group of test cases if the overwrite option is not enabled + tests.push(createTestDefinitions(refOrigins, false, { overwrite, spaceId, singleRequest })); + } + return tests.flat(); }; describe('_import', () => { diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve_import_errors.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve_import_errors.ts index 131335c421f00..862e53d6e4663 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve_import_errors.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve_import_errors.ts @@ -7,11 +7,13 @@ import { v4 as uuidv4 } from 'uuid'; import { SPACES } from '../../common/lib/spaces'; -import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { FtrProviderContext } from '../../common/ftr_provider_context'; import { resolveImportErrorsTestSuiteFactory, + resolveImportErrorsTestCaseFailures, TEST_CASES as CASES, + SPECIAL_TEST_CASES, } from '../../common/suites/resolve_import_errors'; const { @@ -19,27 +21,28 @@ const { SPACE_1: { spaceId: SPACE_1_ID }, SPACE_2: { spaceId: SPACE_2_ID }, } = SPACES; -const { fail400, fail409 } = testCaseFailures; +const { failUnsupportedType, failConflict } = resolveImportErrorsTestCaseFailures; const destinationId = (condition?: boolean) => condition !== false ? { successParam: 'destinationId' } : {}; const newCopy = () => ({ successParam: 'createNewCopy' }); const createNewCopiesTestCases = () => { - // for each outcome, if failure !== undefined then we expect to receive + // for each outcome, if failureType !== undefined then we expect to receive // an error; otherwise, we expect to receive a success result - const cases = Object.entries(CASES).filter(([key]) => key !== 'HIDDEN'); return [ - ...cases.map(([, val]) => ({ + ...Object.entries(CASES).map(([, val]) => ({ ...val, successParam: 'createNewCopies', expectedNewId: uuidv4(), })), - { ...CASES.HIDDEN, ...fail400() }, + { ...SPECIAL_TEST_CASES.HIDDEN, ...failUnsupportedType() }, // unsupported_type is an "unresolvable" error + // Other special test cases are excluded here for simplicity and consistency with the resolveImportErrors "spaces_and_security" test + // suite and the import test suites. ]; }; const createTestCases = (overwrite: boolean, spaceId: string) => { - // for each outcome, if failure !== undefined then we expect to receive + // for each outcome, if failureType !== undefined then we expect to receive // an error; otherwise, we expect to receive a success result const singleNamespaceObject = spaceId === DEFAULT_SPACE_ID @@ -48,43 +51,45 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { ? CASES.SINGLE_NAMESPACE_SPACE_1 : CASES.SINGLE_NAMESPACE_SPACE_2; return [ - { ...singleNamespaceObject, ...fail409(!overwrite) }, - { ...CASES.MULTI_NAMESPACE_ALL_SPACES, ...fail409(!overwrite) }, + { ...singleNamespaceObject, ...failConflict(!overwrite) }, + { ...CASES.MULTI_NAMESPACE_ALL_SPACES, ...failConflict(!overwrite) }, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, - ...fail409(!overwrite && (spaceId === DEFAULT_SPACE_ID || spaceId === SPACE_1_ID)), + ...failConflict(!overwrite && (spaceId === DEFAULT_SPACE_ID || spaceId === SPACE_1_ID)), ...destinationId(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, - ...fail409(!overwrite && spaceId === SPACE_1_ID), + ...failConflict(!overwrite && spaceId === SPACE_1_ID), ...destinationId(spaceId !== SPACE_1_ID), }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, - ...fail409(!overwrite && spaceId === SPACE_2_ID), + ...failConflict(!overwrite && spaceId === SPACE_2_ID), ...destinationId(spaceId !== SPACE_2_ID), }, { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, - ...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID), + ...failConflict(!overwrite && spaceId === DEFAULT_SPACE_ID), ...destinationId(spaceId !== DEFAULT_SPACE_ID), }, { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, - ...fail409(!overwrite && spaceId === SPACE_1_ID), + ...failConflict(!overwrite && spaceId === SPACE_1_ID), ...destinationId(spaceId !== SPACE_1_ID), }, - { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, - { ...CASES.HIDDEN, ...fail400() }, + { ...CASES.NAMESPACE_AGNOSTIC, ...failConflict(!overwrite) }, + { ...SPECIAL_TEST_CASES.HIDDEN, ...failUnsupportedType() }, { ...CASES.CONFLICT_1A_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID { ...CASES.CONFLICT_1B_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID // all of the cases below represent imports that had an inexact match conflict or an ambiguous conflict // if we call _resolve_import_errors and don't specify overwrite, each of these will result in a conflict because an object with that // `expectedDestinationId` already exists - { ...CASES.CONFLICT_2C_OBJ, ...fail409(!overwrite), ...destinationId() }, // "ambiguous destination" conflict; if overwrite=true, will overwrite 'conflict_2a' - { ...CASES.CONFLICT_3A_OBJ, ...fail409(!overwrite), ...destinationId() }, // "inexact match" conflict; if overwrite=true, will overwrite 'conflict_3' - { ...CASES.CONFLICT_4_OBJ, ...fail409(!overwrite), ...destinationId() }, // "inexact match" conflict; if overwrite=true, will overwrite 'conflict_4a' + { ...CASES.CONFLICT_2C_OBJ, ...failConflict(!overwrite), ...destinationId() }, // "ambiguous destination" conflict; if overwrite=true, will overwrite 'conflict_2a' + { ...CASES.CONFLICT_3A_OBJ, ...failConflict(!overwrite), ...destinationId() }, // "inexact match" conflict; if overwrite=true, will overwrite 'conflict_3' + { ...CASES.CONFLICT_4_OBJ, ...failConflict(!overwrite), ...destinationId() }, // "inexact match" conflict; if overwrite=true, will overwrite 'conflict_4a' + { ...SPECIAL_TEST_CASES.OUTBOUND_REFERENCE_ORIGIN_MATCH_1_OBJ }, + { ...SPECIAL_TEST_CASES.OUTBOUND_REFERENCE_ORIGIN_MATCH_2_OBJ }, ]; }; From 662b306cd2c9671b793d3cda12dbb808526e0b1e Mon Sep 17 00:00:00 2001 From: Joe Portner Date: Mon, 10 Jan 2022 12:17:28 -0500 Subject: [PATCH 7/8] PR review feedback --- .../import/import_saved_objects.test.ts | 10 +- .../import/import_saved_objects.ts | 9 +- .../import/lib/validate_references.test.ts | 734 +++++------------- .../import/lib/validate_references.ts | 51 +- .../import/resolve_import_errors.test.ts | 20 +- .../import/resolve_import_errors.ts | 8 +- 6 files changed, 232 insertions(+), 600 deletions(-) diff --git a/src/core/server/saved_objects/import/import_saved_objects.test.ts b/src/core/server/saved_objects/import/import_saved_objects.test.ts index 63be39e3b6c17..2f31b4cf3ead3 100644 --- a/src/core/server/saved_objects/import/import_saved_objects.test.ts +++ b/src/core/server/saved_objects/import/import_saved_objects.test.ts @@ -176,16 +176,16 @@ describe('#importSavedObjectsFromStream', () => { }); await importSavedObjectsFromStream(options); - expect(mockValidateReferences).toHaveBeenCalledWith( - collectedObjects, + expect(mockValidateReferences).toHaveBeenCalledWith({ + objects: collectedObjects, savedObjectsClient, namespace, - new Map([ + importStateMap: new Map([ // This importStateMap is a combination of the other two [`${collectedObjects[0].type}:${collectedObjects[0].id}`, {}], [`foo:bar`, { isOnlyReference: true, id: 'baz' }], - ]) - ); + ]), + }); }); test('executes import hooks', async () => { diff --git a/src/core/server/saved_objects/import/import_saved_objects.ts b/src/core/server/saved_objects/import/import_saved_objects.ts index 43180524d6062..0631d97b58a72 100644 --- a/src/core/server/saved_objects/import/import_saved_objects.ts +++ b/src/core/server/saved_objects/import/import_saved_objects.ts @@ -88,12 +88,12 @@ export async function importSavedObjectsFromStream({ importStateMap = new Map([...importStateMap, ...checkReferenceOriginsResult.importStateMap]); // Validate references - const validateReferencesResult = await validateReferences( - collectSavedObjectsResult.collectedObjects, + const validateReferencesResult = await validateReferences({ + objects: collectSavedObjectsResult.collectedObjects, savedObjectsClient, namespace, - importStateMap - ); + importStateMap, + }); errorAccumulator = [...errorAccumulator, ...validateReferencesResult]; if (createNewCopies) { @@ -101,7 +101,6 @@ export async function importSavedObjectsFromStream({ ...importStateMap, // preserve any entries for references that aren't included in collectedObjects ...regenerateIds(collectSavedObjectsResult.collectedObjects), ]); - // TODO: check reference origins! } else { // Check single-namespace objects for conflicts in this namespace, and check multi-namespace objects for conflicts across all namespaces const checkConflictsParams = { diff --git a/src/core/server/saved_objects/import/lib/validate_references.test.ts b/src/core/server/saved_objects/import/lib/validate_references.test.ts index ca0269314c4bf..2e6f1a5e0a9a2 100644 --- a/src/core/server/saved_objects/import/lib/validate_references.test.ts +++ b/src/core/server/saved_objects/import/lib/validate_references.test.ts @@ -6,600 +6,251 @@ * Side Public License, v 1. */ -import { getNonExistingReferenceAsKeys, validateReferences } from './validate_references'; +import type { ValidateReferencesParams } from './validate_references'; +import { validateReferences } from './validate_references'; import { savedObjectsClientMock } from '../../../mocks'; import { SavedObjectsErrorHelpers } from '../../service'; -describe('getNonExistingReferenceAsKeys()', () => { +function setup({ + objects = [], + namespace, + importStateMap = new Map(), + retries, +}: Partial> = {}) { const savedObjectsClient = savedObjectsClientMock.create(); + return { objects, savedObjectsClient, namespace, importStateMap, retries }; +} - beforeEach(() => { - jest.resetAllMocks(); - }); - - test('returns empty response when no objects exist', async () => { - const result = await getNonExistingReferenceAsKeys( - [], - savedObjectsClient, - undefined, - new Map() - ); - expect(result).toEqual([]); - expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(0); - }); - - test('skips objects when ignoreMissingReferences is included in retry', async () => { - const savedObjects = [ - { - id: '2', - type: 'visualization', - attributes: {}, - references: [{ name: 'ref_0', type: 'index-pattern', id: '1' }], - }, - ]; - const retries = [ - { - type: 'visualization', - id: '2', - overwrite: false, - replaceReferences: [], - ignoreMissingReferences: true, - }, - ]; - const result = await getNonExistingReferenceAsKeys( - savedObjects, - savedObjectsClient, - undefined, - new Map(), - retries - ); - expect(result).toEqual([]); - expect(savedObjectsClient.bulkGet).not.toHaveBeenCalled(); - }); +function createNotFoundError({ type, id }: { type: string; id: string }) { + const error = SavedObjectsErrorHelpers.createGenericNotFoundError(type, id).output.payload; + return { type, id, error, attributes: {}, references: [] }; +} - test('skips references when an importStateMap entry indicates that we have already found an origin match with a different ID', async () => { - const savedObjects = [ - { - id: '2', - type: 'visualization', - attributes: {}, - references: [{ name: 'ref_0', type: 'index-pattern', id: '1' }], - }, - ]; - const result = await getNonExistingReferenceAsKeys( - savedObjects, - savedObjectsClient, - undefined, - new Map([[`index-pattern:1`, { isOnlyReference: true, destinationId: 'not-1' }]]) - ); - expect(result).toEqual([]); - expect(savedObjectsClient.bulkGet).not.toHaveBeenCalled(); - }); +describe('validateReferences()', () => { + test('does not call cluster and returns empty when no objects are passed in', async () => { + const params = setup(); - test('removes references that exist within savedObjects', async () => { - const savedObjects = [ - { - id: '1', - type: 'index-pattern', - attributes: {}, - references: [], - }, - { - id: '2', - type: 'visualization', - attributes: {}, - references: [ - { - name: 'ref_0', - type: 'index-pattern', - id: '1', - }, - ], - }, - ]; - const result = await getNonExistingReferenceAsKeys( - savedObjects, - savedObjectsClient, - undefined, - new Map() - ); + const result = await validateReferences(params); expect(result).toEqual([]); - expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(0); + expect(params.savedObjectsClient.bulkGet).not.toHaveBeenCalled(); }); - test('removes references that exist within es', async () => { - const savedObjects = [ - { - id: '2', - type: 'visualization', - attributes: {}, - references: [ - { - name: 'ref_0', - type: 'index-pattern', - id: '1', - }, - ], - }, - ]; - savedObjectsClient.bulkGet.mockResolvedValueOnce({ - saved_objects: [ + test('returns errors when references are missing', async () => { + const params = setup({ + objects: [ { id: '1', - type: 'index-pattern', + type: 'visualization', attributes: {}, references: [], }, + { + id: '2', + type: 'visualization', + attributes: { title: 'My Visualization 2' }, + references: [{ name: 'ref_0', type: 'index-pattern', id: '3' }], + }, + { + id: '4', + type: 'visualization', + attributes: {}, + references: [ + { name: 'ref_0', type: 'index-pattern', id: '5' }, + { name: 'ref_1', type: 'index-pattern', id: '6' }, + { name: 'ref_2', type: 'search', id: '7' }, + { name: 'ref_3', type: 'search', id: '8' }, + ], + }, + ], + }); + params.savedObjectsClient.bulkGet.mockResolvedValue({ + saved_objects: [ + createNotFoundError({ type: 'index-pattern', id: '3' }), + createNotFoundError({ type: 'index-pattern', id: '5' }), + createNotFoundError({ type: 'index-pattern', id: '6' }), + createNotFoundError({ type: 'search', id: '7' }), + { id: '8', type: 'search', attributes: {}, references: [] }, ], }); - const result = await getNonExistingReferenceAsKeys( - savedObjects, - savedObjectsClient, - undefined, - new Map() - ); - expect(result).toEqual([]); - expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "fields": Array [ - "id", - ], - "id": "1", - "type": "index-pattern", - }, - ], - Object { - "namespace": undefined, - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], - } - `); - }); - test(`doesn't handle saved object types outside of ENFORCED_TYPES`, async () => { - const savedObjects = [ - { + const result = await validateReferences(params); + expect(result).toEqual([ + expect.objectContaining({ + type: 'visualization', id: '2', + error: { + type: 'missing_references', + references: [{ type: 'index-pattern', id: '3' }], + }, + }), + expect.objectContaining({ type: 'visualization', - attributes: {}, - references: [ - { - name: 'ref_0', - type: 'foo', - id: '1', - }, - ], - }, - ]; - const result = await getNonExistingReferenceAsKeys( - savedObjects, - savedObjectsClient, - undefined, - new Map() + id: '4', + error: { + type: 'missing_references', + references: [ + { type: 'index-pattern', id: '5' }, + { type: 'index-pattern', id: '6' }, + { type: 'search', id: '7' }, + ], + }, + }), + ]); + expect(params.savedObjectsClient.bulkGet).toHaveBeenCalledTimes(1); + expect(params.savedObjectsClient.bulkGet).toHaveBeenCalledWith( + [ + { type: 'index-pattern', id: '3', fields: ['id'] }, + { type: 'index-pattern', id: '5', fields: ['id'] }, + { type: 'index-pattern', id: '6', fields: ['id'] }, + { type: 'search', id: '7', fields: ['id'] }, + { type: 'search', id: '8', fields: ['id'] }, + ], + { namespace: undefined } ); - expect(result).toEqual([]); - expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(0); }); - test('returns references within ENFORCED_TYPES when they are missing', async () => { - const savedObjects = [ - { - id: '2', - type: 'visualization', - attributes: {}, - references: [ - { - name: 'ref_0', - type: 'index-pattern', - id: '1', - }, - { - name: 'ref_1', - type: 'search', - id: '3', - }, - { - name: 'ref_2', - type: 'foo', - id: '4', - }, - ], - }, - ]; - savedObjectsClient.bulkGet.mockResolvedValueOnce({ - saved_objects: [ + test(`skips checking references when ignoreMissingReferences is included in retry`, async () => { + const params = setup({ + objects: [ { - id: '1', - type: 'index-pattern', - error: SavedObjectsErrorHelpers.createGenericNotFoundError('index-pattern', '1').output - .payload, + id: '2', + type: 'visualization', attributes: {}, - references: [], + references: [{ name: 'ref_0', type: 'index-pattern', id: '1' }], }, + ], + retries: [ { - id: '3', - type: 'search', - error: SavedObjectsErrorHelpers.createGenericNotFoundError('search', '3').output.payload, - attributes: {}, - references: [], + type: 'visualization', + id: '2', + overwrite: false, + replaceReferences: [], + ignoreMissingReferences: true, }, ], }); - const result = await getNonExistingReferenceAsKeys( - savedObjects, - savedObjectsClient, - undefined, - new Map() - ); - expect(result).toEqual(['index-pattern:1', 'search:3']); - expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "fields": Array [ - "id", - ], - "id": "1", - "type": "index-pattern", - }, - Object { - "fields": Array [ - "id", - ], - "id": "3", - "type": "search", - }, - ], - Object { - "namespace": undefined, - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], - } - `); - }); -}); -describe('validateReferences()', () => { - const savedObjectsClient = savedObjectsClientMock.create(); - - beforeEach(() => { - jest.resetAllMocks(); - }); - - test('returns empty when no objects are passed in', async () => { - const result = await validateReferences([], savedObjectsClient, undefined, new Map()); + const result = await validateReferences(params); expect(result).toEqual([]); - expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(0); + expect(params.savedObjectsClient.bulkGet).not.toHaveBeenCalled(); }); - test('returns errors when references are missing', async () => { - savedObjectsClient.bulkGet.mockResolvedValue({ - saved_objects: [ - { - type: 'index-pattern', - id: '3', - error: SavedObjectsErrorHelpers.createGenericNotFoundError('index-pattern', '3').output - .payload, - attributes: {}, - references: [], - }, + test(`doesn't return errors when references exist in Elasticsearch`, async () => { + const params = setup({ + objects: [ { - type: 'index-pattern', - id: '5', - error: SavedObjectsErrorHelpers.createGenericNotFoundError('index-pattern', '5').output - .payload, + id: '2', + type: 'visualization', attributes: {}, - references: [], + references: [{ name: 'ref_0', type: 'index-pattern', id: '1' }], }, + ], + }); + params.savedObjectsClient.bulkGet.mockResolvedValue({ + saved_objects: [{ id: '1', type: 'index-pattern', attributes: {}, references: [] }], + }); + + const result = await validateReferences(params); + expect(result).toEqual([]); + expect(params.savedObjectsClient.bulkGet).toHaveBeenCalledTimes(1); + expect(params.savedObjectsClient.bulkGet).toHaveBeenCalledWith( + [{ type: 'index-pattern', id: '1', fields: ['id'] }], + { namespace: undefined } + ); + }); + + test(`skips checking references that exist within the saved objects`, async () => { + const params = setup({ + objects: [ { + id: '1', type: 'index-pattern', - id: '6', - error: SavedObjectsErrorHelpers.createGenericNotFoundError('index-pattern', '6').output - .payload, - attributes: {}, - references: [], - }, - { - type: 'search', - id: '7', - error: SavedObjectsErrorHelpers.createGenericNotFoundError('search', '7').output.payload, attributes: {}, references: [], }, { - id: '8', - type: 'search', + id: '2', + type: 'visualization', attributes: {}, - references: [], + references: [{ name: 'ref_0', type: 'index-pattern', id: '1' }], }, ], }); - const savedObjects = [ - { - id: '1', - type: 'visualization', - attributes: {}, - references: [], - }, - { - id: '2', - type: 'visualization', - attributes: { - title: 'My Visualization 2', - }, - references: [ - { - name: 'ref_0', - type: 'index-pattern', - id: '3', - }, - ], - }, - { - id: '4', - type: 'visualization', - attributes: { - title: 'My Visualization 4', - }, - references: [ - { - name: 'ref_0', - type: 'index-pattern', - id: '5', - }, - { - name: 'ref_1', - type: 'index-pattern', - id: '6', - }, - { - name: 'ref_2', - type: 'search', - id: '7', - }, - { - name: 'ref_3', - type: 'search', - id: '8', - }, - ], - }, - ]; - const result = await validateReferences(savedObjects, savedObjectsClient, undefined, new Map()); - expect(result).toMatchInlineSnapshot(` - Array [ - Object { - "error": Object { - "references": Array [ - Object { - "id": "3", - "type": "index-pattern", - }, - ], - "type": "missing_references", - }, - "id": "2", - "meta": Object { - "title": "My Visualization 2", - }, - "title": "My Visualization 2", - "type": "visualization", - }, - Object { - "error": Object { - "references": Array [ - Object { - "id": "5", - "type": "index-pattern", - }, - Object { - "id": "6", - "type": "index-pattern", - }, - Object { - "id": "7", - "type": "search", - }, - ], - "type": "missing_references", - }, - "id": "4", - "meta": Object { - "title": "My Visualization 4", - }, - "title": "My Visualization 4", - "type": "visualization", - }, - ] - `); - expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "fields": Array [ - "id", - ], - "id": "3", - "type": "index-pattern", - }, - Object { - "fields": Array [ - "id", - ], - "id": "5", - "type": "index-pattern", - }, - Object { - "fields": Array [ - "id", - ], - "id": "6", - "type": "index-pattern", - }, - Object { - "fields": Array [ - "id", - ], - "id": "7", - "type": "search", - }, - Object { - "fields": Array [ - "id", - ], - "id": "8", - "type": "search", - }, - ], - Object { - "namespace": undefined, - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], - } - `); - }); - test(`doesn't return errors when ignoreMissingReferences is included in retry`, async () => { - const savedObjects = [ - { - id: '2', - type: 'visualization', - attributes: {}, - references: [{ name: 'ref_0', type: 'index-pattern', id: '1' }], - }, - ]; - const retries = [ - { - type: 'visualization', - id: '2', - overwrite: false, - replaceReferences: [], - ignoreMissingReferences: true, - }, - ]; - const result = await validateReferences( - savedObjects, - savedObjectsClient, - undefined, - new Map(), - retries - ); + const result = await validateReferences(params); + expect(result).toEqual([]); expect(result).toEqual([]); - expect(savedObjectsClient.bulkGet).not.toHaveBeenCalled(); + expect(params.savedObjectsClient.bulkGet).not.toHaveBeenCalled(); }); - test(`doesn't return errors when references exist in Elasticsearch`, async () => { - savedObjectsClient.bulkGet.mockResolvedValue({ - saved_objects: [ + test(`skips checking references that are not part of ENFORCED_TYPES`, async () => { + // this test case intentionally includes a mix of references that *will* be checked, and references that *won't* be checked + const params = setup({ + objects: [ { - id: '1', - type: 'index-pattern', + id: '2', + type: 'visualization', attributes: {}, - references: [], + references: [ + { name: 'ref_0', type: 'index-pattern', id: '1' }, + { name: 'ref_2', type: 'foo', id: '2' }, + { name: 'ref_1', type: 'search', id: '3' }, + ], }, ], }); - const savedObjects = [ - { - id: '2', - type: 'visualization', - attributes: {}, - references: [ - { - name: 'ref_0', - type: 'index-pattern', - id: '1', - }, - ], - }, - ]; - const result = await validateReferences(savedObjects, savedObjectsClient, undefined, new Map()); - expect(result).toEqual([]); - expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(1); - }); + params.savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [ + { type: 'index-pattern', id: '1', attributes: {}, references: [] }, + { type: 'search', id: '3', attributes: {}, references: [] }, + ], + }); - test(`doesn't return errors when references exist within the saved objects`, async () => { - const savedObjects = [ - { - id: '1', - type: 'index-pattern', - attributes: {}, - references: [], - }, - { - id: '2', - type: 'visualization', - attributes: {}, - references: [ - { - name: 'ref_0', - type: 'index-pattern', - id: '1', - }, - ], - }, - ]; - const result = await validateReferences(savedObjects, savedObjectsClient, undefined, new Map()); + const result = await validateReferences(params); expect(result).toEqual([]); - expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(0); + expect(params.savedObjectsClient.bulkGet).toHaveBeenCalledTimes(1); + expect(params.savedObjectsClient.bulkGet).toHaveBeenCalledWith( + [ + { type: 'index-pattern', id: '1', fields: ['id'] }, + // foo:2 is not included in the cluster call + { type: 'search', id: '3', fields: ['id'] }, + ], + { namespace: undefined } + ); }); - test(`doesn't validate references on types not part of ENFORCED_TYPES`, async () => { - const savedObjects = [ - { - id: '1', - type: 'dashboard', - attributes: {}, - references: [ - { - name: 'ref_0', - type: 'visualization', - id: '2', - }, - { - name: 'ref_1', - type: 'other-type', - id: '3', - }, - ], - }, - ]; - const result = await validateReferences(savedObjects, savedObjectsClient, undefined, new Map()); + test('skips checking references when an importStateMap entry indicates that we have already found an origin match with a different ID', async () => { + const params = setup({ + objects: [ + { + id: '2', + type: 'visualization', + attributes: {}, + references: [{ name: 'ref_0', type: 'index-pattern', id: '1' }], + }, + ], + importStateMap: new Map([ + [`index-pattern:1`, { isOnlyReference: true, destinationId: 'not-1' }], + ]), + }); + + const result = await validateReferences(params); expect(result).toEqual([]); - expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(0); + expect(params.savedObjectsClient.bulkGet).not.toHaveBeenCalled(); }); - test('throws when bulkGet fails', async () => { - savedObjectsClient.bulkGet.mockResolvedValue({ + test('throws when bulkGet encounters an unexpected error', async () => { + const params = setup({ + objects: [ + { + id: '2', + type: 'visualization', + attributes: {}, + references: [{ name: 'ref_0', type: 'index-pattern', id: '1' }], + }, + ], + }); + params.savedObjectsClient.bulkGet.mockResolvedValue({ saved_objects: [ { id: '1', @@ -610,24 +261,9 @@ describe('validateReferences()', () => { }, ], }); - const savedObjects = [ - { - id: '2', - type: 'visualization', - attributes: {}, - references: [ - { - name: 'ref_0', - type: 'index-pattern', - id: '1', - }, - ], - }, - ]; - await expect( - validateReferences(savedObjects, savedObjectsClient, undefined, new Map()) - ).rejects.toThrowErrorMatchingInlineSnapshot( - `"Error fetching references for imported objects"` + + await expect(() => validateReferences(params)).rejects.toThrowError( + 'Error fetching references for imported objects' ); }); }); diff --git a/src/core/server/saved_objects/import/lib/validate_references.ts b/src/core/server/saved_objects/import/lib/validate_references.ts index b6c7ebecd1aa3..69e036cf77a3a 100644 --- a/src/core/server/saved_objects/import/lib/validate_references.ts +++ b/src/core/server/saved_objects/import/lib/validate_references.ts @@ -23,22 +23,30 @@ const getObjectsToSkip = (retries: SavedObjectsImportRetry[] = []) => new Set() ); -export async function getNonExistingReferenceAsKeys( - savedObjects: SavedObject[], - savedObjectsClient: SavedObjectsClientContract, - namespace: string | undefined, - importStateMap: ImportStateMap, - retries?: SavedObjectsImportRetry[] -) { +export interface ValidateReferencesParams { + objects: Array>; + savedObjectsClient: SavedObjectsClientContract; + namespace: string | undefined; + importStateMap: ImportStateMap; + retries?: SavedObjectsImportRetry[]; +} + +async function getNonExistingReferenceAsKeys({ + objects, + savedObjectsClient, + namespace, + importStateMap, + retries, +}: ValidateReferencesParams) { const objectsToSkip = getObjectsToSkip(retries); const collector = new Map(); // Collect all references within objects - for (const savedObject of savedObjects) { - if (objectsToSkip.has(`${savedObject.type}:${savedObject.id}`)) { + for (const object of objects) { + if (objectsToSkip.has(`${object.type}:${object.id}`)) { // skip objects with retries that have specified `ignoreMissingReferences`, or that share an origin with an existing object that has a different ID continue; } - const filteredReferences = (savedObject.references || []).filter(filterReferencesToValidate); + const filteredReferences = (object.references || []).filter(filterReferencesToValidate); for (const { type, id } of filteredReferences) { const key = `${type}:${id}`; const { isOnlyReference, destinationId } = importStateMap.get(key) ?? {}; @@ -51,8 +59,8 @@ export async function getNonExistingReferenceAsKeys( } // Remove objects that could be references - for (const savedObject of savedObjects) { - collector.delete(`${savedObject.type}:${savedObject.id}`); + for (const object of objects) { + collector.delete(`${object.type}:${object.id}`); } if (collector.size === 0) { return []; @@ -81,25 +89,14 @@ export async function getNonExistingReferenceAsKeys( return [...collector.keys()]; } -export async function validateReferences( - savedObjects: Array>, - savedObjectsClient: SavedObjectsClientContract, - namespace: string | undefined, - importStateMap: ImportStateMap, - retries?: SavedObjectsImportRetry[] -) { +export async function validateReferences(params: ValidateReferencesParams) { + const { objects, retries } = params; const objectsToSkip = getObjectsToSkip(retries); const errorMap: { [key: string]: SavedObjectsImportFailure } = {}; - const nonExistingReferenceKeys = await getNonExistingReferenceAsKeys( - savedObjects, - savedObjectsClient, - namespace, - importStateMap, - retries - ); + const nonExistingReferenceKeys = await getNonExistingReferenceAsKeys(params); // Filter out objects with missing references, add to error object - savedObjects.forEach(({ type, id, references, attributes }) => { + objects.forEach(({ type, id, references, attributes }) => { if (objectsToSkip.has(`${type}:${id}`)) { // skip objects with retries that have specified `ignoreMissingReferences` return; diff --git a/src/core/server/saved_objects/import/resolve_import_errors.test.ts b/src/core/server/saved_objects/import/resolve_import_errors.test.ts index c7bf097241846..d950545de54f9 100644 --- a/src/core/server/saved_objects/import/resolve_import_errors.test.ts +++ b/src/core/server/saved_objects/import/resolve_import_errors.test.ts @@ -215,17 +215,17 @@ describe('#importSavedObjectsFromStream', () => { }); await resolveSavedObjectsImportErrors(options); - expect(mockValidateReferences).toHaveBeenCalledWith( - collectedObjects, + expect(mockValidateReferences).toHaveBeenCalledWith({ + objects: collectedObjects, savedObjectsClient, namespace, - new Map([ + importStateMap: new Map([ // This importStateMap is a combination of the other two [`${collectedObjects[0].type}:${collectedObjects[0].id}`, {}], [`foo:bar`, { isOnlyReference: true, id: 'baz' }], ]), - retries - ); + retries, + }); }); test('execute import hooks', async () => { @@ -273,13 +273,13 @@ describe('#importSavedObjectsFromStream', () => { ...object, references: [{ ...object.references[0], id: 'def' }], }; - expect(mockValidateReferences).toHaveBeenCalledWith( - [objectWithReplacedReferences], + expect(mockValidateReferences).toHaveBeenCalledWith({ + objects: [objectWithReplacedReferences], savedObjectsClient, namespace, - new Map(), // doesn't matter - retries - ); + importStateMap: new Map(), // doesn't matter + retries, + }); }); test('checks conflicts', async () => { diff --git a/src/core/server/saved_objects/import/resolve_import_errors.ts b/src/core/server/saved_objects/import/resolve_import_errors.ts index 7257692c455a1..61fbde5bb9d87 100644 --- a/src/core/server/saved_objects/import/resolve_import_errors.ts +++ b/src/core/server/saved_objects/import/resolve_import_errors.ts @@ -122,13 +122,13 @@ export async function resolveSavedObjectsImportErrors({ importStateMap = new Map([...importStateMap, ...checkReferenceOriginsResult.importStateMap]); // Validate references - const validateReferencesResult = await validateReferences( - collectSavedObjectsResult.collectedObjects, + const validateReferencesResult = await validateReferences({ + objects: collectSavedObjectsResult.collectedObjects, savedObjectsClient, namespace, importStateMap, - retries - ); + retries, + }); errorAccumulator = [...errorAccumulator, ...validateReferencesResult]; if (createNewCopies) { From c81a43a9a70e310904fa64b3fa96dc81f3d8296d Mon Sep 17 00:00:00 2001 From: Joe Portner Date: Mon, 10 Jan 2022 12:25:39 -0500 Subject: [PATCH 8/8] Remove unneeded export --- src/core/server/saved_objects/import/lib/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/server/saved_objects/import/lib/index.ts b/src/core/server/saved_objects/import/lib/index.ts index 14883786085fa..7d0c2fb2147e3 100644 --- a/src/core/server/saved_objects/import/lib/index.ts +++ b/src/core/server/saved_objects/import/lib/index.ts @@ -18,7 +18,7 @@ export { getImportStateMapForRetries } from './get_import_state_map_for_retries' export { getNonUniqueEntries } from './get_non_unique_entries'; export { regenerateIds } from './regenerate_ids'; export { splitOverwrites } from './split_overwrites'; -export { getNonExistingReferenceAsKeys, validateReferences } from './validate_references'; +export { validateReferences } from './validate_references'; export { validateRetries } from './validate_retries'; export { executeImportHooks } from './execute_import_hooks'; export type { ImportStateMap, ImportStateValue } from './types';