From c56050945fb643333c44f3488869903942db3cbe Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Tue, 30 Jun 2020 12:02:30 -0400 Subject: [PATCH] Server changes snapshot (2020-06-30) --- .../core/public/kibana-plugin-core-public.md | 2 + ...na-plugin-core-public.savedobject.error.md | 5 +- .../kibana-plugin-core-public.savedobject.md | 3 +- ...plugin-core-public.savedobject.originid.md | 13 + ...gin-core-public.savedobjectsfindoptions.md | 1 + ...savedobjectsfindoptions.rawsearchfields.md | 13 + ...portambiguousconflicterror.destinations.md | 15 + ...avedobjectsimportambiguousconflicterror.md | 21 + ...bjectsimportambiguousconflicterror.type.md | 11 + ...bjectsimportconflicterror.destinationid.md | 11 + ...-public.savedobjectsimportconflicterror.md | 1 + ...re-public.savedobjectsimporterror.error.md | 2 +- ...gin-core-public.savedobjectsimporterror.md | 2 +- ...-core-public.savedobjectsimportresponse.md | 1 + ...vedobjectsimportresponse.successresults.md | 11 + ...c.savedobjectsimportretry.destinationid.md | 13 + ...gin-core-public.savedobjectsimportretry.md | 2 + ...public.savedobjectsimportretry.truecopy.md | 16 + ...savedobjectsimportsuccess.destinationid.md | 13 + ...ore-public.savedobjectsimportsuccess.id.md | 11 + ...n-core-public.savedobjectsimportsuccess.md | 23 + ...blic.savedobjectsimportsuccess.truecopy.md | 16 + ...e-public.savedobjectsimportsuccess.type.md | 11 + ...ore-server.importsavedobjectsfromstream.md | 4 +- .../core/server/kibana-plugin-core-server.md | 8 +- ...-server.resolvesavedobjectsimporterrors.md | 4 +- ...na-plugin-core-server.savedobject.error.md | 5 +- .../kibana-plugin-core-server.savedobject.md | 3 +- ...plugin-core-server.savedobject.originid.md | 13 + ...ore-server.savedobjectsbulkcreateobject.md | 1 + ...r.savedobjectsbulkcreateobject.originid.md | 13 + ...ver.savedobjectscheckconflictsobject.id.md | 11 + ...server.savedobjectscheckconflictsobject.md | 20 + ...r.savedobjectscheckconflictsobject.type.md | 11 + ...vedobjectscheckconflictsresponse.errors.md | 15 + ...rver.savedobjectscheckconflictsresponse.md | 19 + ...erver.savedobjectsclient.checkconflicts.md | 25 + ...a-plugin-core-server.savedobjectsclient.md | 1 + ...n-core-server.savedobjectscreateoptions.md | 1 + ...rver.savedobjectscreateoptions.originid.md | 13 + ...gin-core-server.savedobjectsfindoptions.md | 1 + ...savedobjectsfindoptions.rawsearchfields.md | 13 + ...portambiguousconflicterror.destinations.md | 15 + ...avedobjectsimportambiguousconflicterror.md | 21 + ...bjectsimportambiguousconflicterror.type.md | 11 + ...bjectsimportconflicterror.destinationid.md | 11 + ...-server.savedobjectsimportconflicterror.md | 1 + ...re-server.savedobjectsimporterror.error.md | 2 +- ...gin-core-server.savedobjectsimporterror.md | 2 +- ...n-core-server.savedobjectsimportoptions.md | 5 +- ...ver.savedobjectsimportoptions.overwrite.md | 7 +- ...avedobjectsimportoptions.supportedtypes.md | 13 - ...rver.savedobjectsimportoptions.truecopy.md | 16 + ....savedobjectsimportoptions.typeregistry.md | 13 + ...-core-server.savedobjectsimportresponse.md | 1 + ...vedobjectsimportresponse.successresults.md | 11 + ...r.savedobjectsimportretry.destinationid.md | 13 + ...gin-core-server.savedobjectsimportretry.md | 2 + ...server.savedobjectsimportretry.truecopy.md | 16 + ...savedobjectsimportsuccess.destinationid.md | 13 + ...ore-server.savedobjectsimportsuccess.id.md | 11 + ...n-core-server.savedobjectsimportsuccess.md | 23 + ...rver.savedobjectsimportsuccess.truecopy.md | 16 + ...e-server.savedobjectsimportsuccess.type.md | 11 + ...r.savedobjectsrepository.checkconflicts.md | 25 + ...core-server.savedobjectsrepository.find.md | 4 +- ...savedobjectsrepository.incrementcounter.md | 18 +- ...ugin-core-server.savedobjectsrepository.md | 3 +- ....savedobjectsresolveimporterrorsoptions.md | 3 +- ...ectsresolveimporterrorsoptions.truecopy.md | 16 + ...esolveimporterrorsoptions.typeregistry.md} | 8 +- src/core/public/index.ts | 2 + src/core/public/public.api.md | 41 +- src/core/public/saved_objects/index.ts | 2 + .../saved_objects/saved_objects_client.ts | 5 +- src/core/server/index.ts | 4 + .../get_sorted_objects_for_export.test.ts | 29 + .../export/get_sorted_objects_for_export.ts | 9 +- .../inject_nested_depdendencies.test.ts | 7 +- .../saved_objects/import/__mocks__/index.ts | 25 + .../import/check_conflicts.test.ts | 173 ++++ .../saved_objects/import/check_conflicts.ts | 83 ++ .../import/check_origin_conflicts.test.ts | 581 ++++++++++++ .../import/check_origin_conflicts.ts | 246 +++++ .../import/collect_saved_objects.test.ts | 260 ++++-- .../import/collect_saved_objects.ts | 13 + .../import/create_saved_objects.test.ts | 289 ++++++ .../import/create_saved_objects.ts | 105 +++ .../import/extract_errors.test.ts | 31 +- .../saved_objects/import/extract_errors.ts | 9 +- .../import/import_saved_objects.test.ts | 868 ++++++------------ .../import/import_saved_objects.ts | 83 +- src/core/server/saved_objects/import/index.ts | 2 + .../import/regenerate_ids.test.ts | 52 ++ .../saved_objects/import/regenerate_ids.ts | 33 + .../import/resolve_import_errors.test.ts | 823 ++++++----------- .../import/resolve_import_errors.ts | 89 +- src/core/server/saved_objects/import/types.ts | 71 +- .../saved_objects/import/utilities.test.ts | 38 + .../server/saved_objects/import/utilities.ts | 35 + .../import/validate_references.test.ts | 40 +- .../import/validate_retries.test.ts | 97 ++ .../saved_objects/import/validate_retries.ts | 41 + .../build_active_mappings.test.ts.snap | 8 + .../migrations/core/build_active_mappings.ts | 3 + .../migrations/core/index_migrator.test.ts | 4 + .../kibana_migrator.test.ts.snap | 4 + .../server/saved_objects/routes/import.ts | 25 +- .../routes/integration_tests/import.test.ts | 253 +++-- .../resolve_import_errors.test.ts | 327 +++---- .../routes/resolve_import_errors.ts | 12 +- .../serialization/serializer.test.ts | 43 + .../saved_objects/serialization/serializer.ts | 5 +- .../saved_objects/serialization/types.ts | 2 + .../service/lib/included_fields.test.ts | 30 +- .../service/lib/included_fields.ts | 1 + .../service/lib/repository.mock.ts | 1 + .../service/lib/repository.test.js | 285 +++++- .../saved_objects/service/lib/repository.ts | 130 ++- .../lib/search_dsl/query_params.test.ts | 60 +- .../service/lib/search_dsl/query_params.ts | 22 +- .../service/lib/search_dsl/search_dsl.test.ts | 4 +- .../service/lib/search_dsl/search_dsl.ts | 3 + .../service/saved_objects_client.mock.ts | 1 + .../service/saved_objects_client.test.js | 15 + .../service/saved_objects_client.ts | 40 + src/core/server/saved_objects/types.ts | 6 + src/core/server/server.api.md | 87 +- src/core/types/saved_objects.ts | 19 +- .../lib/process_import_response.test.ts | 41 +- .../public/lib/process_import_response.ts | 8 +- .../apis/saved_objects/import.js | 10 + .../apis/saved_objects/migrations.js | 11 +- .../saved_objects/resolve_import_errors.js | 24 +- ...ypted_saved_objects_client_wrapper.test.ts | 13 + .../encrypted_saved_objects_client_wrapper.ts | 8 + .../services/epm/packages/get_objects.ts | 6 +- ...ecure_saved_objects_client_wrapper.test.ts | 42 +- .../secure_saved_objects_client_wrapper.ts | 13 + .../lib/copy_to_spaces/copy_to_spaces.test.ts | 87 +- .../lib/copy_to_spaces/copy_to_spaces.ts | 7 +- .../resolve_copy_conflicts.test.ts | 87 +- .../copy_to_spaces/resolve_copy_conflicts.ts | 20 +- .../spaces/server/lib/copy_to_spaces/types.ts | 11 +- .../__fixtures__/create_mock_so_service.ts | 35 - .../routes/api/external/copy_to_space.test.ts | 103 +-- .../routes/api/external/copy_to_space.ts | 91 +- .../spaces_saved_objects_client.test.ts | 28 + .../spaces_saved_objects_client.ts | 20 + .../saved_objects/spaces/data.json | 88 ++ .../saved_objects/spaces/mappings.json | 3 + .../saved_object_test_plugin/server/plugin.ts | 1 + .../common/lib/saved_object_test_utils.ts | 4 +- .../common/suites/bulk_create.ts | 11 + .../common/suites/export.ts | 64 +- .../common/suites/find.ts | 22 +- .../common/suites/import.ts | 169 +++- .../common/suites/resolve_import_errors.ts | 169 +++- .../security_and_spaces/apis/bulk_create.ts | 15 +- .../security_and_spaces/apis/export.ts | 9 +- .../security_and_spaces/apis/import.ts | 153 ++- .../apis/resolve_import_errors.ts | 161 +++- .../security_only/apis/bulk_create.ts | 5 +- .../security_only/apis/export.ts | 9 +- .../security_only/apis/import.ts | 135 ++- .../apis/resolve_import_errors.ts | 99 +- .../spaces_only/apis/bulk_create.ts | 15 +- .../spaces_only/apis/import.ts | 111 ++- .../spaces_only/apis/resolve_import_errors.ts | 93 +- .../saved_objects/spaces/data.json | 133 ++- .../saved_objects/spaces/mappings.json | 3 + .../spaces_test_plugin/server/plugin.ts | 6 + .../common/lib/saved_object_test_cases.ts | 4 +- .../common/suites/copy_to_space.ts | 565 +++++++----- .../common/suites/delete.ts | 17 +- .../suites/resolve_copy_to_space_conflicts.ts | 354 ++++--- .../security_and_spaces/apis/copy_to_space.ts | 384 +++----- .../apis/resolve_copy_to_space_conflicts.ts | 232 ++--- .../security_and_spaces/apis/share_add.ts | 4 +- .../security_and_spaces/apis/share_remove.ts | 2 +- .../spaces_only/apis/copy_to_space.ts | 6 +- .../apis/resolve_copy_to_space_conflicts.ts | 2 + .../spaces_only/apis/share_add.ts | 4 +- .../spaces_only/apis/share_remove.ts | 4 +- 184 files changed, 6817 insertions(+), 3189 deletions(-) create mode 100644 docs/development/core/public/kibana-plugin-core-public.savedobject.originid.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.rawsearchfields.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.savedobjectsimportambiguousconflicterror.destinations.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.savedobjectsimportambiguousconflicterror.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.savedobjectsimportambiguousconflicterror.type.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.savedobjectsimportconflicterror.destinationid.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.savedobjectsimportresponse.successresults.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.savedobjectsimportretry.destinationid.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.savedobjectsimportretry.truecopy.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.savedobjectsimportsuccess.destinationid.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.savedobjectsimportsuccess.id.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.savedobjectsimportsuccess.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.savedobjectsimportsuccess.truecopy.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.savedobjectsimportsuccess.type.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobject.originid.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.originid.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectscheckconflictsobject.id.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectscheckconflictsobject.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectscheckconflictsobject.type.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectscheckconflictsresponse.errors.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectscheckconflictsresponse.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.checkconflicts.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.originid.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.rawsearchfields.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsimportambiguousconflicterror.destinations.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsimportambiguousconflicterror.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsimportambiguousconflicterror.type.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsimportconflicterror.destinationid.md delete mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsimportoptions.supportedtypes.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsimportoptions.truecopy.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsimportoptions.typeregistry.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsimportresponse.successresults.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsimportretry.destinationid.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsimportretry.truecopy.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsimportsuccess.destinationid.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsimportsuccess.id.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsimportsuccess.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsimportsuccess.truecopy.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsimportsuccess.type.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.checkconflicts.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.truecopy.md rename docs/development/core/server/{kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.supportedtypes.md => kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.typeregistry.md} (54%) create mode 100644 src/core/server/saved_objects/import/__mocks__/index.ts create mode 100644 src/core/server/saved_objects/import/check_conflicts.test.ts create mode 100644 src/core/server/saved_objects/import/check_conflicts.ts create mode 100644 src/core/server/saved_objects/import/check_origin_conflicts.test.ts create mode 100644 src/core/server/saved_objects/import/check_origin_conflicts.ts create mode 100644 src/core/server/saved_objects/import/create_saved_objects.test.ts create mode 100644 src/core/server/saved_objects/import/create_saved_objects.ts create mode 100644 src/core/server/saved_objects/import/regenerate_ids.test.ts create mode 100644 src/core/server/saved_objects/import/regenerate_ids.ts create mode 100644 src/core/server/saved_objects/import/utilities.test.ts create mode 100644 src/core/server/saved_objects/import/utilities.ts create mode 100644 src/core/server/saved_objects/import/validate_retries.test.ts create mode 100644 src/core/server/saved_objects/import/validate_retries.ts diff --git a/docs/development/core/public/kibana-plugin-core-public.md b/docs/development/core/public/kibana-plugin-core-public.md index b0612ff4d5b65..26ca90da61cc8 100644 --- a/docs/development/core/public/kibana-plugin-core-public.md +++ b/docs/development/core/public/kibana-plugin-core-public.md @@ -115,11 +115,13 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectsCreateOptions](./kibana-plugin-core-public.savedobjectscreateoptions.md) | | | [SavedObjectsFindOptions](./kibana-plugin-core-public.savedobjectsfindoptions.md) | | | [SavedObjectsFindResponsePublic](./kibana-plugin-core-public.savedobjectsfindresponsepublic.md) | Return type of the Saved Objects find() method.\*Note\*: this type is different between the Public and Server Saved Objects clients. | +| [SavedObjectsImportAmbiguousConflictError](./kibana-plugin-core-public.savedobjectsimportambiguousconflicterror.md) | Represents a failure to import due to a conflict, which can be resolved in different ways with an overwrite. | | [SavedObjectsImportConflictError](./kibana-plugin-core-public.savedobjectsimportconflicterror.md) | Represents a failure to import due to a conflict. | | [SavedObjectsImportError](./kibana-plugin-core-public.savedobjectsimporterror.md) | Represents a failure to import. | | [SavedObjectsImportMissingReferencesError](./kibana-plugin-core-public.savedobjectsimportmissingreferenceserror.md) | Represents a failure to import due to missing references. | | [SavedObjectsImportResponse](./kibana-plugin-core-public.savedobjectsimportresponse.md) | The response describing the result of an import. | | [SavedObjectsImportRetry](./kibana-plugin-core-public.savedobjectsimportretry.md) | Describes a retry operation for importing a saved object. | +| [SavedObjectsImportSuccess](./kibana-plugin-core-public.savedobjectsimportsuccess.md) | Represents a successful import. | | [SavedObjectsImportUnknownError](./kibana-plugin-core-public.savedobjectsimportunknownerror.md) | Represents a failure to import due to an unknown reason. | | [SavedObjectsImportUnsupportedTypeError](./kibana-plugin-core-public.savedobjectsimportunsupportedtypeerror.md) | Represents a failure to import due to having an unsupported saved object type. | | [SavedObjectsMigrationVersion](./kibana-plugin-core-public.savedobjectsmigrationversion.md) | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. | diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobject.error.md b/docs/development/core/public/kibana-plugin-core-public.savedobject.error.md index f6ffa49c2e6b2..ab9a611fc3a5c 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobject.error.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobject.error.md @@ -7,8 +7,5 @@ Signature: ```typescript -error?: { - message: string; - statusCode: number; - }; +error?: SavedObjectError; ``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobject.md b/docs/development/core/public/kibana-plugin-core-public.savedobject.md index b67d0536fb336..eb6059747426d 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobject.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobject.md @@ -15,10 +15,11 @@ export interface SavedObject | Property | Type | Description | | --- | --- | --- | | [attributes](./kibana-plugin-core-public.savedobject.attributes.md) | T | The data for a Saved Object is stored as an object in the attributes property. | -| [error](./kibana-plugin-core-public.savedobject.error.md) | {
message: string;
statusCode: number;
} | | +| [error](./kibana-plugin-core-public.savedobject.error.md) | SavedObjectError | | | [id](./kibana-plugin-core-public.savedobject.id.md) | string | The ID of this Saved Object, guaranteed to be unique for all objects of the same type | | [migrationVersion](./kibana-plugin-core-public.savedobject.migrationversion.md) | SavedObjectsMigrationVersion | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. | | [namespaces](./kibana-plugin-core-public.savedobject.namespaces.md) | string[] | Namespace(s) that this saved object exists in. This attribute is only used for multi-namespace saved object types. | +| [originId](./kibana-plugin-core-public.savedobject.originid.md) | string | The ID of the saved object this originated from. This is set if this object's id was regenerated; that can happen during migration from a legacy single-namespace type, or during import. It is only set during migration or create operations. This is used during import to ensure that ID regeneration is deterministic, so saved objects will be overwritten if they are imported multiple times into a given space. | | [references](./kibana-plugin-core-public.savedobject.references.md) | SavedObjectReference[] | A reference to another saved object. | | [type](./kibana-plugin-core-public.savedobject.type.md) | string | The type of Saved Object. Each plugin can define it's own custom Saved Object types. | | [updated\_at](./kibana-plugin-core-public.savedobject.updated_at.md) | string | Timestamp of the last time this document had been updated. | diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobject.originid.md b/docs/development/core/public/kibana-plugin-core-public.savedobject.originid.md new file mode 100644 index 0000000000000..f5bab09b9bcc0 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobject.originid.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObject](./kibana-plugin-core-public.savedobject.md) > [originId](./kibana-plugin-core-public.savedobject.originid.md) + +## SavedObject.originId property + +The ID of the saved object this originated from. This is set if this object's `id` was regenerated; that can happen during migration from a legacy single-namespace type, or during import. It is only set during migration or create operations. This is used during import to ensure that ID regeneration is deterministic, so saved objects will be overwritten if they are imported multiple times into a given space. + +Signature: + +```typescript +originId?: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.md index 5f33d62382818..9e09bc3ace557 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.md @@ -22,6 +22,7 @@ export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions | [page](./kibana-plugin-core-public.savedobjectsfindoptions.page.md) | number | | | [perPage](./kibana-plugin-core-public.savedobjectsfindoptions.perpage.md) | number | | | [preference](./kibana-plugin-core-public.savedobjectsfindoptions.preference.md) | string | An optional ES preference value to be used for the query \* | +| [rawSearchFields](./kibana-plugin-core-public.savedobjectsfindoptions.rawsearchfields.md) | string[] | The fields to perform the parsed query against. Unlike the searchFields argument, these are expected to be raw and will not be modified. If used in conjunction with searchFields, both are concatenated together. | | [search](./kibana-plugin-core-public.savedobjectsfindoptions.search.md) | string | Search documents using the Elasticsearch Simple Query String syntax. See Elasticsearch Simple Query String query argument for more information | | [searchFields](./kibana-plugin-core-public.savedobjectsfindoptions.searchfields.md) | string[] | The fields to perform the parsed query against. See Elasticsearch Simple Query String fields argument for more information | | [sortField](./kibana-plugin-core-public.savedobjectsfindoptions.sortfield.md) | string | | diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.rawsearchfields.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.rawsearchfields.md new file mode 100644 index 0000000000000..b5861402a08ce --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.rawsearchfields.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsFindOptions](./kibana-plugin-core-public.savedobjectsfindoptions.md) > [rawSearchFields](./kibana-plugin-core-public.savedobjectsfindoptions.rawsearchfields.md) + +## SavedObjectsFindOptions.rawSearchFields property + +The fields to perform the parsed query against. Unlike the `searchFields` argument, these are expected to be raw and will not be modified. If used in conjunction with `searchFields`, both are concatenated together. + +Signature: + +```typescript +rawSearchFields?: string[]; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportambiguousconflicterror.destinations.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportambiguousconflicterror.destinations.md new file mode 100644 index 0000000000000..59ce43c4bea62 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportambiguousconflicterror.destinations.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsImportAmbiguousConflictError](./kibana-plugin-core-public.savedobjectsimportambiguousconflicterror.md) > [destinations](./kibana-plugin-core-public.savedobjectsimportambiguousconflicterror.destinations.md) + +## SavedObjectsImportAmbiguousConflictError.destinations property + +Signature: + +```typescript +destinations: Array<{ + id: string; + title?: string; + updatedAt?: string; + }>; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportambiguousconflicterror.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportambiguousconflicterror.md new file mode 100644 index 0000000000000..76dfacf132f0a --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportambiguousconflicterror.md @@ -0,0 +1,21 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsImportAmbiguousConflictError](./kibana-plugin-core-public.savedobjectsimportambiguousconflicterror.md) + +## SavedObjectsImportAmbiguousConflictError interface + +Represents a failure to import due to a conflict, which can be resolved in different ways with an overwrite. + +Signature: + +```typescript +export interface SavedObjectsImportAmbiguousConflictError +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [destinations](./kibana-plugin-core-public.savedobjectsimportambiguousconflicterror.destinations.md) | Array<{
id: string;
title?: string;
updatedAt?: string;
}> | | +| [type](./kibana-plugin-core-public.savedobjectsimportambiguousconflicterror.type.md) | 'ambiguous_conflict' | | + diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportambiguousconflicterror.type.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportambiguousconflicterror.type.md new file mode 100644 index 0000000000000..600c56988ac75 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportambiguousconflicterror.type.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsImportAmbiguousConflictError](./kibana-plugin-core-public.savedobjectsimportambiguousconflicterror.md) > [type](./kibana-plugin-core-public.savedobjectsimportambiguousconflicterror.type.md) + +## SavedObjectsImportAmbiguousConflictError.type property + +Signature: + +```typescript +type: 'ambiguous_conflict'; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportconflicterror.destinationid.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportconflicterror.destinationid.md new file mode 100644 index 0000000000000..ba4002d932f57 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportconflicterror.destinationid.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsImportConflictError](./kibana-plugin-core-public.savedobjectsimportconflicterror.md) > [destinationId](./kibana-plugin-core-public.savedobjectsimportconflicterror.destinationid.md) + +## SavedObjectsImportConflictError.destinationId property + +Signature: + +```typescript +destinationId?: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportconflicterror.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportconflicterror.md index a54cdac56c218..b0320b05ecadc 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportconflicterror.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportconflicterror.md @@ -16,5 +16,6 @@ export interface SavedObjectsImportConflictError | Property | Type | Description | | --- | --- | --- | +| [destinationId](./kibana-plugin-core-public.savedobjectsimportconflicterror.destinationid.md) | string | | | [type](./kibana-plugin-core-public.savedobjectsimportconflicterror.type.md) | 'conflict' | | diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimporterror.error.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimporterror.error.md index a76ab8e5c926a..201f56bf925d1 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimporterror.error.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimporterror.error.md @@ -7,5 +7,5 @@ Signature: ```typescript -error: SavedObjectsImportConflictError | SavedObjectsImportUnsupportedTypeError | SavedObjectsImportMissingReferencesError | SavedObjectsImportUnknownError; +error: SavedObjectsImportConflictError | SavedObjectsImportAmbiguousConflictError | SavedObjectsImportUnsupportedTypeError | SavedObjectsImportMissingReferencesError | SavedObjectsImportUnknownError; ``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimporterror.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimporterror.md index 5703c613adbd7..8e7f9d6ef347e 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimporterror.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimporterror.md @@ -16,7 +16,7 @@ export interface SavedObjectsImportError | Property | Type | Description | | --- | --- | --- | -| [error](./kibana-plugin-core-public.savedobjectsimporterror.error.md) | SavedObjectsImportConflictError | SavedObjectsImportUnsupportedTypeError | SavedObjectsImportMissingReferencesError | SavedObjectsImportUnknownError | | +| [error](./kibana-plugin-core-public.savedobjectsimporterror.error.md) | SavedObjectsImportConflictError | SavedObjectsImportAmbiguousConflictError | SavedObjectsImportUnsupportedTypeError | SavedObjectsImportMissingReferencesError | SavedObjectsImportUnknownError | | | [id](./kibana-plugin-core-public.savedobjectsimporterror.id.md) | string | | | [title](./kibana-plugin-core-public.savedobjectsimporterror.title.md) | string | | | [type](./kibana-plugin-core-public.savedobjectsimporterror.type.md) | string | | diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportresponse.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportresponse.md index 910de33c30e62..0aba4d517e43a 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportresponse.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportresponse.md @@ -19,4 +19,5 @@ export interface SavedObjectsImportResponse | [errors](./kibana-plugin-core-public.savedobjectsimportresponse.errors.md) | SavedObjectsImportError[] | | | [success](./kibana-plugin-core-public.savedobjectsimportresponse.success.md) | boolean | | | [successCount](./kibana-plugin-core-public.savedobjectsimportresponse.successcount.md) | number | | +| [successResults](./kibana-plugin-core-public.savedobjectsimportresponse.successresults.md) | SavedObjectsImportSuccess[] | | diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportresponse.successresults.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportresponse.successresults.md new file mode 100644 index 0000000000000..51a47b6c2d953 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportresponse.successresults.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsImportResponse](./kibana-plugin-core-public.savedobjectsimportresponse.md) > [successResults](./kibana-plugin-core-public.savedobjectsimportresponse.successresults.md) + +## SavedObjectsImportResponse.successResults property + +Signature: + +```typescript +successResults?: SavedObjectsImportSuccess[]; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportretry.destinationid.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportretry.destinationid.md new file mode 100644 index 0000000000000..5131d1d01ff02 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportretry.destinationid.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsImportRetry](./kibana-plugin-core-public.savedobjectsimportretry.md) > [destinationId](./kibana-plugin-core-public.savedobjectsimportretry.destinationid.md) + +## SavedObjectsImportRetry.destinationId property + +The object ID that will be created or overwritten. If not specified, the `id` field will be used. + +Signature: + +```typescript +destinationId?: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportretry.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportretry.md index d625302d97eed..148a7b146b47d 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportretry.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportretry.md @@ -16,8 +16,10 @@ export interface SavedObjectsImportRetry | Property | Type | Description | | --- | --- | --- | +| [destinationId](./kibana-plugin-core-public.savedobjectsimportretry.destinationid.md) | string | The object ID that will be created or overwritten. If not specified, the id field will be used. | | [id](./kibana-plugin-core-public.savedobjectsimportretry.id.md) | string | | | [overwrite](./kibana-plugin-core-public.savedobjectsimportretry.overwrite.md) | boolean | | | [replaceReferences](./kibana-plugin-core-public.savedobjectsimportretry.replacereferences.md) | Array<{
type: string;
from: string;
to: string;
}> | | +| [trueCopy](./kibana-plugin-core-public.savedobjectsimportretry.truecopy.md) | boolean | | | [type](./kibana-plugin-core-public.savedobjectsimportretry.type.md) | string | | diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportretry.truecopy.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportretry.truecopy.md new file mode 100644 index 0000000000000..df0be400f15f1 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportretry.truecopy.md @@ -0,0 +1,16 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsImportRetry](./kibana-plugin-core-public.savedobjectsimportretry.md) > [trueCopy](./kibana-plugin-core-public.savedobjectsimportretry.truecopy.md) + +## SavedObjectsImportRetry.trueCopy property + +> Warning: This API is now obsolete. +> +> If `trueCopy` is specified, the new object has a new (undefined) origin ID. This is only needed for the case where True Copy mode is disabled and ambiguous source conflicts are detected. When True Copy mode is permanently enabled, this field will be redundant and can be removed. +> + +Signature: + +```typescript +trueCopy?: boolean; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportsuccess.destinationid.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportsuccess.destinationid.md new file mode 100644 index 0000000000000..55611a77aeb67 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportsuccess.destinationid.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsImportSuccess](./kibana-plugin-core-public.savedobjectsimportsuccess.md) > [destinationId](./kibana-plugin-core-public.savedobjectsimportsuccess.destinationid.md) + +## SavedObjectsImportSuccess.destinationId property + +If `destinationId` is specified, the new object has a new ID that is different from the import ID. + +Signature: + +```typescript +destinationId?: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportsuccess.id.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportsuccess.id.md new file mode 100644 index 0000000000000..6d6271e37dffe --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportsuccess.id.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsImportSuccess](./kibana-plugin-core-public.savedobjectsimportsuccess.md) > [id](./kibana-plugin-core-public.savedobjectsimportsuccess.id.md) + +## SavedObjectsImportSuccess.id property + +Signature: + +```typescript +id: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportsuccess.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportsuccess.md new file mode 100644 index 0000000000000..92e6855e2f201 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportsuccess.md @@ -0,0 +1,23 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsImportSuccess](./kibana-plugin-core-public.savedobjectsimportsuccess.md) + +## SavedObjectsImportSuccess interface + +Represents a successful import. + +Signature: + +```typescript +export interface SavedObjectsImportSuccess +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [destinationId](./kibana-plugin-core-public.savedobjectsimportsuccess.destinationid.md) | string | If destinationId is specified, the new object has a new ID that is different from the import ID. | +| [id](./kibana-plugin-core-public.savedobjectsimportsuccess.id.md) | string | | +| [trueCopy](./kibana-plugin-core-public.savedobjectsimportsuccess.truecopy.md) | boolean | | +| [type](./kibana-plugin-core-public.savedobjectsimportsuccess.type.md) | string | | + diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportsuccess.truecopy.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportsuccess.truecopy.md new file mode 100644 index 0000000000000..6123830a65c5f --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportsuccess.truecopy.md @@ -0,0 +1,16 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsImportSuccess](./kibana-plugin-core-public.savedobjectsimportsuccess.md) > [trueCopy](./kibana-plugin-core-public.savedobjectsimportsuccess.truecopy.md) + +## SavedObjectsImportSuccess.trueCopy property + +> Warning: This API is now obsolete. +> +> If `trueCopy` is specified, the new object has a new (undefined) origin ID. This is only needed for the case where True Copy mode is disabled and ambiguous source conflicts are detected. When True Copy mode is permanently enabled, this field will be redundant and can be removed. +> + +Signature: + +```typescript +trueCopy?: boolean; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportsuccess.type.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportsuccess.type.md new file mode 100644 index 0000000000000..6ac14455d281f --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportsuccess.type.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsImportSuccess](./kibana-plugin-core-public.savedobjectsimportsuccess.md) > [type](./kibana-plugin-core-public.savedobjectsimportsuccess.type.md) + +## SavedObjectsImportSuccess.type property + +Signature: + +```typescript +type: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.importsavedobjectsfromstream.md b/docs/development/core/server/kibana-plugin-core-server.importsavedobjectsfromstream.md index 6fabfb7a321ae..bea33149927d7 100644 --- a/docs/development/core/server/kibana-plugin-core-server.importsavedobjectsfromstream.md +++ b/docs/development/core/server/kibana-plugin-core-server.importsavedobjectsfromstream.md @@ -9,14 +9,14 @@ Import saved objects from given stream. See the [options](./kibana-plugin-core-s Signature: ```typescript -export declare function importSavedObjectsFromStream({ readStream, objectLimit, overwrite, savedObjectsClient, supportedTypes, namespace, }: SavedObjectsImportOptions): Promise; +export declare function importSavedObjectsFromStream({ readStream, objectLimit, overwrite, trueCopy, savedObjectsClient, typeRegistry, namespace, }: SavedObjectsImportOptions): Promise; ``` ## Parameters | Parameter | Type | Description | | --- | --- | --- | -| { readStream, objectLimit, overwrite, savedObjectsClient, supportedTypes, namespace, } | SavedObjectsImportOptions | | +| { readStream, objectLimit, overwrite, trueCopy, savedObjectsClient, typeRegistry, namespace, } | SavedObjectsImportOptions | | Returns: diff --git a/docs/development/core/server/kibana-plugin-core-server.md b/docs/development/core/server/kibana-plugin-core-server.md index 7e777d51f147f..d31f91baf5e90 100644 --- a/docs/development/core/server/kibana-plugin-core-server.md +++ b/docs/development/core/server/kibana-plugin-core-server.md @@ -45,10 +45,10 @@ The plugin integrates with the core system via lifecycle events: `setup` | [deepFreeze(object)](./kibana-plugin-core-server.deepfreeze.md) | Apply Object.freeze to a value recursively and convert the return type to Readonly variant recursively | | [exportSavedObjectsToStream({ types, objects, search, savedObjectsClient, exportSizeLimit, includeReferencesDeep, excludeExportDetails, namespace, })](./kibana-plugin-core-server.exportsavedobjectstostream.md) | Generates sorted saved object stream to be used for export. See the [options](./kibana-plugin-core-server.savedobjectsexportoptions.md) for more detailed information. | | [getFlattenedObject(rootValue)](./kibana-plugin-core-server.getflattenedobject.md) | Flattens a deeply nested object to a map of dot-separated paths pointing to all primitive values \*\*and arrays\*\* from rootValue.example: getFlattenedObject({ a: { b: 1, c: \[2,3\] } }) // => { 'a.b': 1, 'a.c': \[2,3\] } | -| [importSavedObjectsFromStream({ readStream, objectLimit, overwrite, savedObjectsClient, supportedTypes, namespace, })](./kibana-plugin-core-server.importsavedobjectsfromstream.md) | Import saved objects from given stream. See the [options](./kibana-plugin-core-server.savedobjectsimportoptions.md) for more detailed information. | +| [importSavedObjectsFromStream({ readStream, objectLimit, overwrite, trueCopy, savedObjectsClient, typeRegistry, namespace, })](./kibana-plugin-core-server.importsavedobjectsfromstream.md) | Import saved objects from given stream. See the [options](./kibana-plugin-core-server.savedobjectsimportoptions.md) for more detailed information. | | [isRelativeUrl(candidatePath)](./kibana-plugin-core-server.isrelativeurl.md) | Determine if a url is relative. Any url including a protocol, hostname, or port is not considered relative. This means that absolute \*paths\* are considered to be relative \*urls\* | | [modifyUrl(url, urlModifier)](./kibana-plugin-core-server.modifyurl.md) | Takes a URL and a function that takes the meaningful parts of the URL as a key-value object, modifies some or all of the parts, and returns the modified parts formatted again as a url.Url Parts sent: - protocol - slashes (does the url have the //) - auth - hostname (just the name of the host, no port or auth information) - port - pathname (the path after the hostname, no query or hash, starts with a slash if there was a path) - query (always an object, even when no query on original url) - hashWhy? - The default url library in node produces several conflicting properties on the "parsed" output. Modifying any of these might lead to the modifications being ignored (depending on which property was modified) - It's not always clear whether to use path/pathname, host/hostname, so this tries to add helpful constraints | -| [resolveSavedObjectsImportErrors({ readStream, objectLimit, retries, savedObjectsClient, supportedTypes, namespace, })](./kibana-plugin-core-server.resolvesavedobjectsimporterrors.md) | Resolve and return saved object import errors. See the [options](./kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.md) for more detailed informations. | +| [resolveSavedObjectsImportErrors({ readStream, objectLimit, retries, savedObjectsClient, typeRegistry, namespace, trueCopy, })](./kibana-plugin-core-server.resolvesavedobjectsimporterrors.md) | Resolve and return saved object import errors. See the [options](./kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.md) for more detailed informations. | ## Interfaces @@ -148,6 +148,8 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectsBulkUpdateObject](./kibana-plugin-core-server.savedobjectsbulkupdateobject.md) | | | [SavedObjectsBulkUpdateOptions](./kibana-plugin-core-server.savedobjectsbulkupdateoptions.md) | | | [SavedObjectsBulkUpdateResponse](./kibana-plugin-core-server.savedobjectsbulkupdateresponse.md) | | +| [SavedObjectsCheckConflictsObject](./kibana-plugin-core-server.savedobjectscheckconflictsobject.md) | | +| [SavedObjectsCheckConflictsResponse](./kibana-plugin-core-server.savedobjectscheckconflictsresponse.md) | | | [SavedObjectsClientProviderOptions](./kibana-plugin-core-server.savedobjectsclientprovideroptions.md) | Options to control the creation of the Saved Objects Client. | | [SavedObjectsClientWrapperOptions](./kibana-plugin-core-server.savedobjectsclientwrapperoptions.md) | Options passed to each SavedObjectsClientWrapperFactory to aid in creating the wrapper instance. | | [SavedObjectsComplexFieldMapping](./kibana-plugin-core-server.savedobjectscomplexfieldmapping.md) | See [SavedObjectsFieldMapping](./kibana-plugin-core-server.savedobjectsfieldmapping.md) for documentation.Note: this type intentially doesn't include a type definition for defining the dynamic mapping parameter. Saved Object fields should always inherit the dynamic: 'strict' paramater. If you are unsure of the shape of your data use type: 'object', enabled: false instead. | @@ -161,12 +163,14 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectsFindOptions](./kibana-plugin-core-server.savedobjectsfindoptions.md) | | | [SavedObjectsFindResponse](./kibana-plugin-core-server.savedobjectsfindresponse.md) | Return type of the Saved Objects find() method.\*Note\*: this type is different between the Public and Server Saved Objects clients. | | [SavedObjectsFindResult](./kibana-plugin-core-server.savedobjectsfindresult.md) | | +| [SavedObjectsImportAmbiguousConflictError](./kibana-plugin-core-server.savedobjectsimportambiguousconflicterror.md) | Represents a failure to import due to a conflict, which can be resolved in different ways with an overwrite. | | [SavedObjectsImportConflictError](./kibana-plugin-core-server.savedobjectsimportconflicterror.md) | Represents a failure to import due to a conflict. | | [SavedObjectsImportError](./kibana-plugin-core-server.savedobjectsimporterror.md) | Represents a failure to import. | | [SavedObjectsImportMissingReferencesError](./kibana-plugin-core-server.savedobjectsimportmissingreferenceserror.md) | Represents a failure to import due to missing references. | | [SavedObjectsImportOptions](./kibana-plugin-core-server.savedobjectsimportoptions.md) | Options to control the import operation. | | [SavedObjectsImportResponse](./kibana-plugin-core-server.savedobjectsimportresponse.md) | The response describing the result of an import. | | [SavedObjectsImportRetry](./kibana-plugin-core-server.savedobjectsimportretry.md) | Describes a retry operation for importing a saved object. | +| [SavedObjectsImportSuccess](./kibana-plugin-core-server.savedobjectsimportsuccess.md) | Represents a successful import. | | [SavedObjectsImportUnknownError](./kibana-plugin-core-server.savedobjectsimportunknownerror.md) | Represents a failure to import due to an unknown reason. | | [SavedObjectsImportUnsupportedTypeError](./kibana-plugin-core-server.savedobjectsimportunsupportedtypeerror.md) | Represents a failure to import due to having an unsupported saved object type. | | [SavedObjectsIncrementCounterOptions](./kibana-plugin-core-server.savedobjectsincrementcounteroptions.md) | | diff --git a/docs/development/core/server/kibana-plugin-core-server.resolvesavedobjectsimporterrors.md b/docs/development/core/server/kibana-plugin-core-server.resolvesavedobjectsimporterrors.md index c7f30b0533d04..9be4db86a328d 100644 --- a/docs/development/core/server/kibana-plugin-core-server.resolvesavedobjectsimporterrors.md +++ b/docs/development/core/server/kibana-plugin-core-server.resolvesavedobjectsimporterrors.md @@ -9,14 +9,14 @@ Resolve and return saved object import errors. See the [options](./kibana-plugin Signature: ```typescript -export declare function resolveSavedObjectsImportErrors({ readStream, objectLimit, retries, savedObjectsClient, supportedTypes, namespace, }: SavedObjectsResolveImportErrorsOptions): Promise; +export declare function resolveSavedObjectsImportErrors({ readStream, objectLimit, retries, savedObjectsClient, typeRegistry, namespace, trueCopy, }: SavedObjectsResolveImportErrorsOptions): Promise; ``` ## Parameters | Parameter | Type | Description | | --- | --- | --- | -| { readStream, objectLimit, retries, savedObjectsClient, supportedTypes, namespace, } | SavedObjectsResolveImportErrorsOptions | | +| { readStream, objectLimit, retries, savedObjectsClient, typeRegistry, namespace, trueCopy, } | SavedObjectsResolveImportErrorsOptions | | Returns: diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobject.error.md b/docs/development/core/server/kibana-plugin-core-server.savedobject.error.md index dffef4392c85c..ef42053e38626 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobject.error.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobject.error.md @@ -7,8 +7,5 @@ Signature: ```typescript -error?: { - message: string; - statusCode: number; - }; +error?: SavedObjectError; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobject.md b/docs/development/core/server/kibana-plugin-core-server.savedobject.md index 94d1c378899df..5aefc55736cd1 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobject.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobject.md @@ -15,10 +15,11 @@ export interface SavedObject | Property | Type | Description | | --- | --- | --- | | [attributes](./kibana-plugin-core-server.savedobject.attributes.md) | T | The data for a Saved Object is stored as an object in the attributes property. | -| [error](./kibana-plugin-core-server.savedobject.error.md) | {
message: string;
statusCode: number;
} | | +| [error](./kibana-plugin-core-server.savedobject.error.md) | SavedObjectError | | | [id](./kibana-plugin-core-server.savedobject.id.md) | string | The ID of this Saved Object, guaranteed to be unique for all objects of the same type | | [migrationVersion](./kibana-plugin-core-server.savedobject.migrationversion.md) | SavedObjectsMigrationVersion | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. | | [namespaces](./kibana-plugin-core-server.savedobject.namespaces.md) | string[] | Namespace(s) that this saved object exists in. This attribute is only used for multi-namespace saved object types. | +| [originId](./kibana-plugin-core-server.savedobject.originid.md) | string | The ID of the saved object this originated from. This is set if this object's id was regenerated; that can happen during migration from a legacy single-namespace type, or during import. It is only set during migration or create operations. This is used during import to ensure that ID regeneration is deterministic, so saved objects will be overwritten if they are imported multiple times into a given space. | | [references](./kibana-plugin-core-server.savedobject.references.md) | SavedObjectReference[] | A reference to another saved object. | | [type](./kibana-plugin-core-server.savedobject.type.md) | string | The type of Saved Object. Each plugin can define it's own custom Saved Object types. | | [updated\_at](./kibana-plugin-core-server.savedobject.updated_at.md) | string | Timestamp of the last time this document had been updated. | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobject.originid.md b/docs/development/core/server/kibana-plugin-core-server.savedobject.originid.md new file mode 100644 index 0000000000000..95bcad7ce8b1b --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobject.originid.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObject](./kibana-plugin-core-server.savedobject.md) > [originId](./kibana-plugin-core-server.savedobject.originid.md) + +## SavedObject.originId property + +The ID of the saved object this originated from. This is set if this object's `id` was regenerated; that can happen during migration from a legacy single-namespace type, or during import. It is only set during migration or create operations. This is used during import to ensure that ID regeneration is deterministic, so saved objects will be overwritten if they are imported multiple times into a given space. + +Signature: + +```typescript +originId?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.md index 5a9ca36ba56f4..fed7ef5e9df58 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.md @@ -18,6 +18,7 @@ export interface SavedObjectsBulkCreateObject | [attributes](./kibana-plugin-core-server.savedobjectsbulkcreateobject.attributes.md) | T | | | [id](./kibana-plugin-core-server.savedobjectsbulkcreateobject.id.md) | string | | | [migrationVersion](./kibana-plugin-core-server.savedobjectsbulkcreateobject.migrationversion.md) | SavedObjectsMigrationVersion | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. | +| [originId](./kibana-plugin-core-server.savedobjectsbulkcreateobject.originid.md) | string | Optional ID of the original saved object, if this object's id was regenerated | | [references](./kibana-plugin-core-server.savedobjectsbulkcreateobject.references.md) | SavedObjectReference[] | | | [type](./kibana-plugin-core-server.savedobjectsbulkcreateobject.type.md) | string | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.originid.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.originid.md new file mode 100644 index 0000000000000..c182a47891f62 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.originid.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsBulkCreateObject](./kibana-plugin-core-server.savedobjectsbulkcreateobject.md) > [originId](./kibana-plugin-core-server.savedobjectsbulkcreateobject.originid.md) + +## SavedObjectsBulkCreateObject.originId property + +Optional ID of the original saved object, if this object's `id` was regenerated + +Signature: + +```typescript +originId?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectscheckconflictsobject.id.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectscheckconflictsobject.id.md new file mode 100644 index 0000000000000..2b7cd5cc486a8 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectscheckconflictsobject.id.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsCheckConflictsObject](./kibana-plugin-core-server.savedobjectscheckconflictsobject.md) > [id](./kibana-plugin-core-server.savedobjectscheckconflictsobject.id.md) + +## SavedObjectsCheckConflictsObject.id property + +Signature: + +```typescript +id: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectscheckconflictsobject.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectscheckconflictsobject.md new file mode 100644 index 0000000000000..c327cc4a20551 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectscheckconflictsobject.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsCheckConflictsObject](./kibana-plugin-core-server.savedobjectscheckconflictsobject.md) + +## SavedObjectsCheckConflictsObject interface + + +Signature: + +```typescript +export interface SavedObjectsCheckConflictsObject +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [id](./kibana-plugin-core-server.savedobjectscheckconflictsobject.id.md) | string | | +| [type](./kibana-plugin-core-server.savedobjectscheckconflictsobject.type.md) | string | | + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectscheckconflictsobject.type.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectscheckconflictsobject.type.md new file mode 100644 index 0000000000000..82f89536e4189 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectscheckconflictsobject.type.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsCheckConflictsObject](./kibana-plugin-core-server.savedobjectscheckconflictsobject.md) > [type](./kibana-plugin-core-server.savedobjectscheckconflictsobject.type.md) + +## SavedObjectsCheckConflictsObject.type property + +Signature: + +```typescript +type: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectscheckconflictsresponse.errors.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectscheckconflictsresponse.errors.md new file mode 100644 index 0000000000000..80bd61d8906e3 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectscheckconflictsresponse.errors.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsCheckConflictsResponse](./kibana-plugin-core-server.savedobjectscheckconflictsresponse.md) > [errors](./kibana-plugin-core-server.savedobjectscheckconflictsresponse.errors.md) + +## SavedObjectsCheckConflictsResponse.errors property + +Signature: + +```typescript +errors: Array<{ + id: string; + type: string; + error: SavedObjectError; + }>; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectscheckconflictsresponse.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectscheckconflictsresponse.md new file mode 100644 index 0000000000000..499398586e7dd --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectscheckconflictsresponse.md @@ -0,0 +1,19 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsCheckConflictsResponse](./kibana-plugin-core-server.savedobjectscheckconflictsresponse.md) + +## SavedObjectsCheckConflictsResponse interface + + +Signature: + +```typescript +export interface SavedObjectsCheckConflictsResponse +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [errors](./kibana-plugin-core-server.savedobjectscheckconflictsresponse.errors.md) | Array<{
id: string;
type: string;
error: SavedObjectError;
}> | | + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.checkconflicts.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.checkconflicts.md new file mode 100644 index 0000000000000..5cffb0c498b0b --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.checkconflicts.md @@ -0,0 +1,25 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsClient](./kibana-plugin-core-server.savedobjectsclient.md) > [checkConflicts](./kibana-plugin-core-server.savedobjectsclient.checkconflicts.md) + +## SavedObjectsClient.checkConflicts() method + +Check what conflicts will result when creating a given array of saved objects. This includes "unresolvable conflicts", which are multi-namespace objects that exist in a different namespace; such conflicts cannot be resolved/overwritten. + +Signature: + +```typescript +checkConflicts(objects?: SavedObjectsCheckConflictsObject[], options?: SavedObjectsBaseOptions): Promise; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| objects | SavedObjectsCheckConflictsObject[] | | +| options | SavedObjectsBaseOptions | | + +Returns: + +`Promise` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md index 7038c0c07012f..7c1273e63d24b 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md @@ -29,6 +29,7 @@ The constructor for this class is marked as internal. Third-party code should no | [bulkCreate(objects, options)](./kibana-plugin-core-server.savedobjectsclient.bulkcreate.md) | | Persists multiple documents batched together as a single request | | [bulkGet(objects, options)](./kibana-plugin-core-server.savedobjectsclient.bulkget.md) | | Returns an array of objects by id | | [bulkUpdate(objects, options)](./kibana-plugin-core-server.savedobjectsclient.bulkupdate.md) | | Bulk Updates multiple SavedObject at once | +| [checkConflicts(objects, options)](./kibana-plugin-core-server.savedobjectsclient.checkconflicts.md) | | Check what conflicts will result when creating a given array of saved objects. This includes "unresolvable conflicts", which are multi-namespace objects that exist in a different namespace; such conflicts cannot be resolved/overwritten. | | [create(type, attributes, options)](./kibana-plugin-core-server.savedobjectsclient.create.md) | | Persists a SavedObject | | [delete(type, id, options)](./kibana-plugin-core-server.savedobjectsclient.delete.md) | | Deletes a SavedObject | | [deleteFromNamespaces(type, id, namespaces, options)](./kibana-plugin-core-server.savedobjectsclient.deletefromnamespaces.md) | | Removes namespaces from a SavedObject | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.md index 5e9433c5c9196..95f27f5140fba 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.md @@ -17,6 +17,7 @@ export interface SavedObjectsCreateOptions extends SavedObjectsBaseOptions | --- | --- | --- | | [id](./kibana-plugin-core-server.savedobjectscreateoptions.id.md) | string | (not recommended) Specify an id for the document | | [migrationVersion](./kibana-plugin-core-server.savedobjectscreateoptions.migrationversion.md) | SavedObjectsMigrationVersion | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. | +| [originId](./kibana-plugin-core-server.savedobjectscreateoptions.originid.md) | string | Optional ID of the original saved object, if this object's id was regenerated | | [overwrite](./kibana-plugin-core-server.savedobjectscreateoptions.overwrite.md) | boolean | Overwrite existing documents (defaults to false) | | [references](./kibana-plugin-core-server.savedobjectscreateoptions.references.md) | SavedObjectReference[] | | | [refresh](./kibana-plugin-core-server.savedobjectscreateoptions.refresh.md) | MutatingOperationRefreshSetting | The Elasticsearch Refresh setting for this operation | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.originid.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.originid.md new file mode 100644 index 0000000000000..14333079f7440 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.originid.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md) > [originId](./kibana-plugin-core-server.savedobjectscreateoptions.originid.md) + +## SavedObjectsCreateOptions.originId property + +Optional ID of the original saved object, if this object's `id` was regenerated + +Signature: + +```typescript +originId?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.md index 6db16d979f1fe..4d6b91d16e706 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.md @@ -22,6 +22,7 @@ export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions | [page](./kibana-plugin-core-server.savedobjectsfindoptions.page.md) | number | | | [perPage](./kibana-plugin-core-server.savedobjectsfindoptions.perpage.md) | number | | | [preference](./kibana-plugin-core-server.savedobjectsfindoptions.preference.md) | string | An optional ES preference value to be used for the query \* | +| [rawSearchFields](./kibana-plugin-core-server.savedobjectsfindoptions.rawsearchfields.md) | string[] | The fields to perform the parsed query against. Unlike the searchFields argument, these are expected to be raw and will not be modified. If used in conjunction with searchFields, both are concatenated together. | | [search](./kibana-plugin-core-server.savedobjectsfindoptions.search.md) | string | Search documents using the Elasticsearch Simple Query String syntax. See Elasticsearch Simple Query String query argument for more information | | [searchFields](./kibana-plugin-core-server.savedobjectsfindoptions.searchfields.md) | string[] | The fields to perform the parsed query against. See Elasticsearch Simple Query String fields argument for more information | | [sortField](./kibana-plugin-core-server.savedobjectsfindoptions.sortfield.md) | string | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.rawsearchfields.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.rawsearchfields.md new file mode 100644 index 0000000000000..11626ac040595 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.rawsearchfields.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsFindOptions](./kibana-plugin-core-server.savedobjectsfindoptions.md) > [rawSearchFields](./kibana-plugin-core-server.savedobjectsfindoptions.rawsearchfields.md) + +## SavedObjectsFindOptions.rawSearchFields property + +The fields to perform the parsed query against. Unlike the `searchFields` argument, these are expected to be raw and will not be modified. If used in conjunction with `searchFields`, both are concatenated together. + +Signature: + +```typescript +rawSearchFields?: string[]; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportambiguousconflicterror.destinations.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportambiguousconflicterror.destinations.md new file mode 100644 index 0000000000000..445979dd740d3 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportambiguousconflicterror.destinations.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsImportAmbiguousConflictError](./kibana-plugin-core-server.savedobjectsimportambiguousconflicterror.md) > [destinations](./kibana-plugin-core-server.savedobjectsimportambiguousconflicterror.destinations.md) + +## SavedObjectsImportAmbiguousConflictError.destinations property + +Signature: + +```typescript +destinations: Array<{ + id: string; + title?: string; + updatedAt?: string; + }>; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportambiguousconflicterror.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportambiguousconflicterror.md new file mode 100644 index 0000000000000..d2c0a397ebe8a --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportambiguousconflicterror.md @@ -0,0 +1,21 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsImportAmbiguousConflictError](./kibana-plugin-core-server.savedobjectsimportambiguousconflicterror.md) + +## SavedObjectsImportAmbiguousConflictError interface + +Represents a failure to import due to a conflict, which can be resolved in different ways with an overwrite. + +Signature: + +```typescript +export interface SavedObjectsImportAmbiguousConflictError +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [destinations](./kibana-plugin-core-server.savedobjectsimportambiguousconflicterror.destinations.md) | Array<{
id: string;
title?: string;
updatedAt?: string;
}> | | +| [type](./kibana-plugin-core-server.savedobjectsimportambiguousconflicterror.type.md) | 'ambiguous_conflict' | | + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportambiguousconflicterror.type.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportambiguousconflicterror.type.md new file mode 100644 index 0000000000000..ca98682873033 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportambiguousconflicterror.type.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsImportAmbiguousConflictError](./kibana-plugin-core-server.savedobjectsimportambiguousconflicterror.md) > [type](./kibana-plugin-core-server.savedobjectsimportambiguousconflicterror.type.md) + +## SavedObjectsImportAmbiguousConflictError.type property + +Signature: + +```typescript +type: 'ambiguous_conflict'; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportconflicterror.destinationid.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportconflicterror.destinationid.md new file mode 100644 index 0000000000000..858f171223472 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportconflicterror.destinationid.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsImportConflictError](./kibana-plugin-core-server.savedobjectsimportconflicterror.md) > [destinationId](./kibana-plugin-core-server.savedobjectsimportconflicterror.destinationid.md) + +## SavedObjectsImportConflictError.destinationId property + +Signature: + +```typescript +destinationId?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportconflicterror.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportconflicterror.md index a3e946eccb984..153cd55c9199e 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportconflicterror.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportconflicterror.md @@ -16,5 +16,6 @@ export interface SavedObjectsImportConflictError | Property | Type | Description | | --- | --- | --- | +| [destinationId](./kibana-plugin-core-server.savedobjectsimportconflicterror.destinationid.md) | string | | | [type](./kibana-plugin-core-server.savedobjectsimportconflicterror.type.md) | 'conflict' | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimporterror.error.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimporterror.error.md index a5d33de32d594..6fc0c86b2fafc 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimporterror.error.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimporterror.error.md @@ -7,5 +7,5 @@ Signature: ```typescript -error: SavedObjectsImportConflictError | SavedObjectsImportUnsupportedTypeError | SavedObjectsImportMissingReferencesError | SavedObjectsImportUnknownError; +error: SavedObjectsImportConflictError | SavedObjectsImportAmbiguousConflictError | SavedObjectsImportUnsupportedTypeError | SavedObjectsImportMissingReferencesError | SavedObjectsImportUnknownError; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimporterror.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimporterror.md index 473812fcbfd72..7383ebdb8192b 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimporterror.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimporterror.md @@ -16,7 +16,7 @@ export interface SavedObjectsImportError | Property | Type | Description | | --- | --- | --- | -| [error](./kibana-plugin-core-server.savedobjectsimporterror.error.md) | SavedObjectsImportConflictError | SavedObjectsImportUnsupportedTypeError | SavedObjectsImportMissingReferencesError | SavedObjectsImportUnknownError | | +| [error](./kibana-plugin-core-server.savedobjectsimporterror.error.md) | SavedObjectsImportConflictError | SavedObjectsImportAmbiguousConflictError | SavedObjectsImportUnsupportedTypeError | SavedObjectsImportMissingReferencesError | SavedObjectsImportUnknownError | | | [id](./kibana-plugin-core-server.savedobjectsimporterror.id.md) | string | | | [title](./kibana-plugin-core-server.savedobjectsimporterror.title.md) | string | | | [type](./kibana-plugin-core-server.savedobjectsimporterror.type.md) | string | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportoptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportoptions.md index f9da9956772bb..ad2930a999490 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportoptions.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportoptions.md @@ -18,8 +18,9 @@ export interface SavedObjectsImportOptions | --- | --- | --- | | [namespace](./kibana-plugin-core-server.savedobjectsimportoptions.namespace.md) | string | if specified, will import in given namespace, else will import as global object | | [objectLimit](./kibana-plugin-core-server.savedobjectsimportoptions.objectlimit.md) | number | The maximum number of object to import | -| [overwrite](./kibana-plugin-core-server.savedobjectsimportoptions.overwrite.md) | boolean | if true, will override existing object if present | +| [overwrite](./kibana-plugin-core-server.savedobjectsimportoptions.overwrite.md) | boolean | | | [readStream](./kibana-plugin-core-server.savedobjectsimportoptions.readstream.md) | Readable | The stream of [saved objects](./kibana-plugin-core-server.savedobject.md) to import | | [savedObjectsClient](./kibana-plugin-core-server.savedobjectsimportoptions.savedobjectsclient.md) | SavedObjectsClientContract | [client](./kibana-plugin-core-server.savedobjectsclientcontract.md) to use to perform the import operation | -| [supportedTypes](./kibana-plugin-core-server.savedobjectsimportoptions.supportedtypes.md) | string[] | the list of allowed types to import | +| [trueCopy](./kibana-plugin-core-server.savedobjectsimportoptions.truecopy.md) | boolean | | +| [typeRegistry](./kibana-plugin-core-server.savedobjectsimportoptions.typeregistry.md) | ISavedObjectTypeRegistry | The registry of all known saved object types | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportoptions.overwrite.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportoptions.overwrite.md index e42d04c5a9180..e38e370d601ff 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportoptions.overwrite.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportoptions.overwrite.md @@ -4,7 +4,12 @@ ## SavedObjectsImportOptions.overwrite property -if true, will override existing object if present +> Warning: This API is now obsolete. +> +> If true, will override existing object if present. This option will be removed and permanently disabled in a future release. +> +> Note: this has no effect when used with the `trueCopy` option. +> Signature: diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportoptions.supportedtypes.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportoptions.supportedtypes.md deleted file mode 100644 index 999cb73cbdfba..0000000000000 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportoptions.supportedtypes.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsImportOptions](./kibana-plugin-core-server.savedobjectsimportoptions.md) > [supportedTypes](./kibana-plugin-core-server.savedobjectsimportoptions.supportedtypes.md) - -## SavedObjectsImportOptions.supportedTypes property - -the list of allowed types to import - -Signature: - -```typescript -supportedTypes: string[]; -``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportoptions.truecopy.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportoptions.truecopy.md new file mode 100644 index 0000000000000..89918e1ffc59e --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportoptions.truecopy.md @@ -0,0 +1,16 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsImportOptions](./kibana-plugin-core-server.savedobjectsimportoptions.md) > [trueCopy](./kibana-plugin-core-server.savedobjectsimportoptions.truecopy.md) + +## SavedObjectsImportOptions.trueCopy property + +> Warning: This API is now obsolete. +> +> If true, will create new copies of import objects, each with a random `id` and undefined `originId`. This option will be removed and permanently enabled in a future release. +> + +Signature: + +```typescript +trueCopy: boolean; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportoptions.typeregistry.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportoptions.typeregistry.md new file mode 100644 index 0000000000000..89c49471d24ef --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportoptions.typeregistry.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsImportOptions](./kibana-plugin-core-server.savedobjectsimportoptions.md) > [typeRegistry](./kibana-plugin-core-server.savedobjectsimportoptions.typeregistry.md) + +## SavedObjectsImportOptions.typeRegistry property + +The registry of all known saved object types + +Signature: + +```typescript +typeRegistry: ISavedObjectTypeRegistry; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportresponse.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportresponse.md index 641934d43eddf..52d39d981d0c2 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportresponse.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportresponse.md @@ -19,4 +19,5 @@ export interface SavedObjectsImportResponse | [errors](./kibana-plugin-core-server.savedobjectsimportresponse.errors.md) | SavedObjectsImportError[] | | | [success](./kibana-plugin-core-server.savedobjectsimportresponse.success.md) | boolean | | | [successCount](./kibana-plugin-core-server.savedobjectsimportresponse.successcount.md) | number | | +| [successResults](./kibana-plugin-core-server.savedobjectsimportresponse.successresults.md) | SavedObjectsImportSuccess[] | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportresponse.successresults.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportresponse.successresults.md new file mode 100644 index 0000000000000..63951d3a0b25f --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportresponse.successresults.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsImportResponse](./kibana-plugin-core-server.savedobjectsimportresponse.md) > [successResults](./kibana-plugin-core-server.savedobjectsimportresponse.successresults.md) + +## SavedObjectsImportResponse.successResults property + +Signature: + +```typescript +successResults?: SavedObjectsImportSuccess[]; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportretry.destinationid.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportretry.destinationid.md new file mode 100644 index 0000000000000..9a3ccf4442db7 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportretry.destinationid.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsImportRetry](./kibana-plugin-core-server.savedobjectsimportretry.md) > [destinationId](./kibana-plugin-core-server.savedobjectsimportretry.destinationid.md) + +## SavedObjectsImportRetry.destinationId property + +The object ID that will be created or overwritten. If not specified, the `id` field will be used. + +Signature: + +```typescript +destinationId?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportretry.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportretry.md index 64d8164a1c4a5..4053ec49961a2 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportretry.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportretry.md @@ -16,8 +16,10 @@ export interface SavedObjectsImportRetry | Property | Type | Description | | --- | --- | --- | +| [destinationId](./kibana-plugin-core-server.savedobjectsimportretry.destinationid.md) | string | The object ID that will be created or overwritten. If not specified, the id field will be used. | | [id](./kibana-plugin-core-server.savedobjectsimportretry.id.md) | string | | | [overwrite](./kibana-plugin-core-server.savedobjectsimportretry.overwrite.md) | boolean | | | [replaceReferences](./kibana-plugin-core-server.savedobjectsimportretry.replacereferences.md) | Array<{
type: string;
from: string;
to: string;
}> | | +| [trueCopy](./kibana-plugin-core-server.savedobjectsimportretry.truecopy.md) | boolean | | | [type](./kibana-plugin-core-server.savedobjectsimportretry.type.md) | string | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportretry.truecopy.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportretry.truecopy.md new file mode 100644 index 0000000000000..03e14f7e3cf59 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportretry.truecopy.md @@ -0,0 +1,16 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsImportRetry](./kibana-plugin-core-server.savedobjectsimportretry.md) > [trueCopy](./kibana-plugin-core-server.savedobjectsimportretry.truecopy.md) + +## SavedObjectsImportRetry.trueCopy property + +> Warning: This API is now obsolete. +> +> If `trueCopy` is specified, the new object has a new (undefined) origin ID. This is only needed for the case where True Copy mode is disabled and ambiguous source conflicts are detected. When True Copy mode is permanently enabled, this field will be redundant and can be removed. +> + +Signature: + +```typescript +trueCopy?: boolean; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportsuccess.destinationid.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportsuccess.destinationid.md new file mode 100644 index 0000000000000..c5acc51c3ec99 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportsuccess.destinationid.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsImportSuccess](./kibana-plugin-core-server.savedobjectsimportsuccess.md) > [destinationId](./kibana-plugin-core-server.savedobjectsimportsuccess.destinationid.md) + +## SavedObjectsImportSuccess.destinationId property + +If `destinationId` is specified, the new object has a new ID that is different from the import ID. + +Signature: + +```typescript +destinationId?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportsuccess.id.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportsuccess.id.md new file mode 100644 index 0000000000000..5b95f7f64bfac --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportsuccess.id.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsImportSuccess](./kibana-plugin-core-server.savedobjectsimportsuccess.md) > [id](./kibana-plugin-core-server.savedobjectsimportsuccess.id.md) + +## SavedObjectsImportSuccess.id property + +Signature: + +```typescript +id: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportsuccess.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportsuccess.md new file mode 100644 index 0000000000000..b511ae3635ff0 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportsuccess.md @@ -0,0 +1,23 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsImportSuccess](./kibana-plugin-core-server.savedobjectsimportsuccess.md) + +## SavedObjectsImportSuccess interface + +Represents a successful import. + +Signature: + +```typescript +export interface SavedObjectsImportSuccess +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [destinationId](./kibana-plugin-core-server.savedobjectsimportsuccess.destinationid.md) | string | If destinationId is specified, the new object has a new ID that is different from the import ID. | +| [id](./kibana-plugin-core-server.savedobjectsimportsuccess.id.md) | string | | +| [trueCopy](./kibana-plugin-core-server.savedobjectsimportsuccess.truecopy.md) | boolean | | +| [type](./kibana-plugin-core-server.savedobjectsimportsuccess.type.md) | string | | + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportsuccess.truecopy.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportsuccess.truecopy.md new file mode 100644 index 0000000000000..9e9edc92d58b5 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportsuccess.truecopy.md @@ -0,0 +1,16 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsImportSuccess](./kibana-plugin-core-server.savedobjectsimportsuccess.md) > [trueCopy](./kibana-plugin-core-server.savedobjectsimportsuccess.truecopy.md) + +## SavedObjectsImportSuccess.trueCopy property + +> Warning: This API is now obsolete. +> +> If `trueCopy` is specified, the new object has a new (undefined) origin ID. This is only needed for the case where True Copy mode is disabled and ambiguous source conflicts are detected. When True Copy mode is permanently enabled, this field will be redundant and can be removed. +> + +Signature: + +```typescript +trueCopy?: boolean; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportsuccess.type.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportsuccess.type.md new file mode 100644 index 0000000000000..e6aa894cd0af9 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportsuccess.type.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsImportSuccess](./kibana-plugin-core-server.savedobjectsimportsuccess.md) > [type](./kibana-plugin-core-server.savedobjectsimportsuccess.type.md) + +## SavedObjectsImportSuccess.type property + +Signature: + +```typescript +type: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.checkconflicts.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.checkconflicts.md new file mode 100644 index 0000000000000..6e44bd704d6a7 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.checkconflicts.md @@ -0,0 +1,25 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsRepository](./kibana-plugin-core-server.savedobjectsrepository.md) > [checkConflicts](./kibana-plugin-core-server.savedobjectsrepository.checkconflicts.md) + +## SavedObjectsRepository.checkConflicts() method + +Check what conflicts will result when creating a given array of saved objects. This includes "unresolvable conflicts", which are multi-namespace objects that exist in a different namespace; such conflicts cannot be resolved/overwritten. + +Signature: + +```typescript +checkConflicts(objects?: SavedObjectsCheckConflictsObject[], options?: SavedObjectsBaseOptions): Promise; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| objects | SavedObjectsCheckConflictsObject[] | | +| options | SavedObjectsBaseOptions | | + +Returns: + +`Promise` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.find.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.find.md index 8b89c802ec9ce..cc5b5d3659c6b 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.find.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.find.md @@ -7,14 +7,14 @@ Signature: ```typescript -find({ search, defaultSearchOperator, searchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespace, type, filter, preference, }: SavedObjectsFindOptions): Promise>; +find({ search, defaultSearchOperator, searchFields, rawSearchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespace, type, filter, preference, }: SavedObjectsFindOptions): Promise>; ``` ## Parameters | Parameter | Type | Description | | --- | --- | --- | -| { search, defaultSearchOperator, searchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespace, type, filter, preference, } | SavedObjectsFindOptions | | +| { search, defaultSearchOperator, searchFields, rawSearchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespace, type, filter, preference, } | SavedObjectsFindOptions | | Returns: diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.incrementcounter.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.incrementcounter.md index 6b02cd910cdb1..f3a2ee38cbdbd 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.incrementcounter.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.incrementcounter.md @@ -9,14 +9,7 @@ Increases a counter field by one. Creates the document if one doesn't exist for Signature: ```typescript -incrementCounter(type: string, id: string, counterFieldName: string, options?: SavedObjectsIncrementCounterOptions): Promise<{ - id: string; - type: string; - updated_at: string; - references: any; - version: string; - attributes: any; - }>; +incrementCounter(type: string, id: string, counterFieldName: string, options?: SavedObjectsIncrementCounterOptions): Promise; ``` ## Parameters @@ -30,14 +23,7 @@ incrementCounter(type: string, id: string, counterFieldName: string, options?: S Returns: -`Promise<{ - id: string; - type: string; - updated_at: string; - references: any; - version: string; - attributes: any; - }>` +`Promise` {promise} diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md index b9a92561f29fb..63845498d5b05 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md @@ -19,11 +19,12 @@ export declare class SavedObjectsRepository | [bulkCreate(objects, options)](./kibana-plugin-core-server.savedobjectsrepository.bulkcreate.md) | | Creates multiple documents at once | | [bulkGet(objects, options)](./kibana-plugin-core-server.savedobjectsrepository.bulkget.md) | | Returns an array of objects by id | | [bulkUpdate(objects, options)](./kibana-plugin-core-server.savedobjectsrepository.bulkupdate.md) | | Updates multiple objects in bulk | +| [checkConflicts(objects, options)](./kibana-plugin-core-server.savedobjectsrepository.checkconflicts.md) | | Check what conflicts will result when creating a given array of saved objects. This includes "unresolvable conflicts", which are multi-namespace objects that exist in a different namespace; such conflicts cannot be resolved/overwritten. | | [create(type, attributes, options)](./kibana-plugin-core-server.savedobjectsrepository.create.md) | | Persists an object | | [delete(type, id, options)](./kibana-plugin-core-server.savedobjectsrepository.delete.md) | | Deletes an object | | [deleteByNamespace(namespace, options)](./kibana-plugin-core-server.savedobjectsrepository.deletebynamespace.md) | | Deletes all objects from the provided namespace. | | [deleteFromNamespaces(type, id, namespaces, options)](./kibana-plugin-core-server.savedobjectsrepository.deletefromnamespaces.md) | | Removes one or more namespaces from a given multi-namespace saved object. If no namespaces remain, the saved object is deleted entirely. This method and \[addToNamespaces\][SavedObjectsRepository.addToNamespaces()](./kibana-plugin-core-server.savedobjectsrepository.addtonamespaces.md) are the only ways to change which Spaces a multi-namespace saved object is shared to. | -| [find({ search, defaultSearchOperator, searchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespace, type, filter, preference, })](./kibana-plugin-core-server.savedobjectsrepository.find.md) | | | +| [find({ search, defaultSearchOperator, searchFields, rawSearchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespace, type, filter, preference, })](./kibana-plugin-core-server.savedobjectsrepository.find.md) | | | | [get(type, id, options)](./kibana-plugin-core-server.savedobjectsrepository.get.md) | | Gets a single object | | [incrementCounter(type, id, counterFieldName, options)](./kibana-plugin-core-server.savedobjectsrepository.incrementcounter.md) | | Increases a counter field by one. Creates the document if one doesn't exist for the given id. | | [update(type, id, attributes, options)](./kibana-plugin-core-server.savedobjectsrepository.update.md) | | Updates an object | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.md index c701b0a6d9bf7..8c3f2d446ffff 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.md @@ -21,5 +21,6 @@ export interface SavedObjectsResolveImportErrorsOptions | [readStream](./kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.readstream.md) | Readable | The stream of [saved objects](./kibana-plugin-core-server.savedobject.md) to resolve errors from | | [retries](./kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.retries.md) | SavedObjectsImportRetry[] | saved object import references to retry | | [savedObjectsClient](./kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.savedobjectsclient.md) | SavedObjectsClientContract | client to use to perform the import operation | -| [supportedTypes](./kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.supportedtypes.md) | string[] | the list of allowed types to import | +| [trueCopy](./kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.truecopy.md) | boolean | | +| [typeRegistry](./kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.typeregistry.md) | ISavedObjectTypeRegistry | The registry of all known saved object types | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.truecopy.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.truecopy.md new file mode 100644 index 0000000000000..394e332b208e6 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.truecopy.md @@ -0,0 +1,16 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsResolveImportErrorsOptions](./kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.md) > [trueCopy](./kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.truecopy.md) + +## SavedObjectsResolveImportErrorsOptions.trueCopy property + +> Warning: This API is now obsolete. +> +> If true, will create new copies of import objects, each with a random `id` and undefined `originId`. This option will be removed and permanently enabled in a future release. +> + +Signature: + +```typescript +trueCopy: boolean; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.supportedtypes.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.typeregistry.md similarity index 54% rename from docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.supportedtypes.md rename to docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.typeregistry.md index f5b7c3692b017..f06d3eb08c0ac 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.supportedtypes.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.typeregistry.md @@ -1,13 +1,13 @@ -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsResolveImportErrorsOptions](./kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.md) > [supportedTypes](./kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.supportedtypes.md) +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsResolveImportErrorsOptions](./kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.md) > [typeRegistry](./kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.typeregistry.md) -## SavedObjectsResolveImportErrorsOptions.supportedTypes property +## SavedObjectsResolveImportErrorsOptions.typeRegistry property -the list of allowed types to import +The registry of all known saved object types Signature: ```typescript -supportedTypes: string[]; +typeRegistry: ISavedObjectTypeRegistry; ``` diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 41af0f1b8395f..f27a923189b9d 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -149,7 +149,9 @@ export { SavedObjectsClient, SimpleSavedObject, SavedObjectsImportResponse, + SavedObjectsImportSuccess, SavedObjectsImportConflictError, + SavedObjectsImportAmbiguousConflictError, SavedObjectsImportUnsupportedTypeError, SavedObjectsImportMissingReferencesError, SavedObjectsImportUnknownError, diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index a65b9dd9d242a..49f92d107e7ce 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -1170,14 +1170,14 @@ export type RecursiveReadonly = T extends (...args: any[]) => any ? T : T ext // @public (undocumented) export interface SavedObject { attributes: T; + // Warning: (ae-forgotten-export) The symbol "SavedObjectError" needs to be exported by the entry point index.d.ts + // // (undocumented) - error?: { - message: string; - statusCode: number; - }; + error?: SavedObjectError; id: string; migrationVersion?: SavedObjectsMigrationVersion; namespaces?: string[]; + originId?: string; references: SavedObjectReference[]; type: string; updated_at?: string; @@ -1298,6 +1298,7 @@ export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions { // (undocumented) perPage?: number; preference?: string; + rawSearchFields?: string[]; search?: string; searchFields?: string[]; // (undocumented) @@ -1318,8 +1319,22 @@ export interface SavedObjectsFindResponsePublic extends SavedObject total: number; } +// @public +export interface SavedObjectsImportAmbiguousConflictError { + // (undocumented) + destinations: Array<{ + id: string; + title?: string; + updatedAt?: string; + }>; + // (undocumented) + type: 'ambiguous_conflict'; +} + // @public export interface SavedObjectsImportConflictError { + // (undocumented) + destinationId?: string; // (undocumented) type: 'conflict'; } @@ -1327,7 +1342,7 @@ export interface SavedObjectsImportConflictError { // @public export interface SavedObjectsImportError { // (undocumented) - error: SavedObjectsImportConflictError | SavedObjectsImportUnsupportedTypeError | SavedObjectsImportMissingReferencesError | SavedObjectsImportUnknownError; + error: SavedObjectsImportConflictError | SavedObjectsImportAmbiguousConflictError | SavedObjectsImportUnsupportedTypeError | SavedObjectsImportMissingReferencesError | SavedObjectsImportUnknownError; // (undocumented) id: string; // (undocumented) @@ -1360,10 +1375,13 @@ export interface SavedObjectsImportResponse { success: boolean; // (undocumented) successCount: number; + // (undocumented) + successResults?: SavedObjectsImportSuccess[]; } // @public export interface SavedObjectsImportRetry { + destinationId?: string; // (undocumented) id: string; // (undocumented) @@ -1374,6 +1392,19 @@ export interface SavedObjectsImportRetry { from: string; to: string; }>; + // @deprecated (undocumented) + trueCopy?: boolean; + // (undocumented) + type: string; +} + +// @public +export interface SavedObjectsImportSuccess { + destinationId?: string; + // (undocumented) + id: string; + // @deprecated (undocumented) + trueCopy?: boolean; // (undocumented) type: string; } diff --git a/src/core/public/saved_objects/index.ts b/src/core/public/saved_objects/index.ts index 13b4a12893666..e5e2d2c326af3 100644 --- a/src/core/public/saved_objects/index.ts +++ b/src/core/public/saved_objects/index.ts @@ -36,7 +36,9 @@ export { SavedObjectsFindOptions, SavedObjectsMigrationVersion, SavedObjectsImportResponse, + SavedObjectsImportSuccess, SavedObjectsImportConflictError, + SavedObjectsImportAmbiguousConflictError, SavedObjectsImportUnsupportedTypeError, SavedObjectsImportMissingReferencesError, SavedObjectsImportUnknownError, diff --git a/src/core/public/saved_objects/saved_objects_client.ts b/src/core/public/saved_objects/saved_objects_client.ts index cb279b2cc4c8f..4660e47b046f5 100644 --- a/src/core/public/saved_objects/saved_objects_client.ts +++ b/src/core/public/saved_objects/saved_objects_client.ts @@ -31,7 +31,10 @@ import { import { SimpleSavedObject } from './simple_saved_object'; import { HttpFetchOptions, HttpSetup } from '../http'; -type SavedObjectsFindOptions = Omit; +type SavedObjectsFindOptions = Omit< + SavedObjectFindOptionsServer, + 'namespace' | 'sortOrder' | 'rawSearchFields' +>; type PromiseType> = T extends Promise ? U : never; diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 91c33fac41646..c5cdeaf180aff 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -224,6 +224,8 @@ export { SavedObjectsBulkUpdateOptions, SavedObjectsBulkResponse, SavedObjectsBulkUpdateResponse, + SavedObjectsCheckConflictsObject, + SavedObjectsCheckConflictsResponse, SavedObjectsClient, SavedObjectsClientProviderOptions, SavedObjectsClientWrapperFactory, @@ -237,11 +239,13 @@ export { SavedObjectsFindResult, SavedObjectsFindResponse, SavedObjectsImportConflictError, + SavedObjectsImportAmbiguousConflictError, SavedObjectsImportError, SavedObjectsImportMissingReferencesError, SavedObjectsImportOptions, SavedObjectsImportResponse, SavedObjectsImportRetry, + SavedObjectsImportSuccess, SavedObjectsImportUnknownError, SavedObjectsImportUnsupportedTypeError, SavedObjectMigrationContext, diff --git a/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts b/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts index 5da2235828b5c..3cc54c911df69 100644 --- a/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts +++ b/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts @@ -573,6 +573,35 @@ describe('getSortedObjectsForExport()', () => { `); }); + test('modifies return results to redact `namespaces` attribute', async () => { + const createSavedObject = (obj: any) => ({ ...obj, attributes: {}, references: [] }); + savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [ + createSavedObject({ type: 'multi', id: '1', namespaces: ['foo'] }), + createSavedObject({ type: 'multi', id: '2', namespaces: ['bar'] }), + createSavedObject({ type: 'other', id: '3' }), + ], + }); + const exportStream = await exportSavedObjectsToStream({ + exportSizeLimit: 10000, + savedObjectsClient, + objects: [ + { type: 'multi', id: '1' }, + { type: 'multi', id: '2' }, + { type: 'other', id: '3' }, + ], + }); + const response = await readStreamToCompletion(exportStream); + expect(response).toEqual( + expect.arrayContaining([ + expect.not.objectContaining({ id: '1', namespaces: expect.anything() }), + expect.not.objectContaining({ id: '2', namespaces: expect.anything() }), + expect.not.objectContaining({ id: '3', namespaces: expect.anything() }), + expect.objectContaining({ exportedCount: 3 }), + ]) + ); + }); + test('includes nested dependencies when passed in', async () => { savedObjectsClient.bulkGet.mockResolvedValueOnce({ saved_objects: [ diff --git a/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts b/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts index 6e985c25aeaef..ffb7e575b3450 100644 --- a/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts +++ b/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts @@ -151,7 +151,7 @@ export async function exportSavedObjectsToStream({ exportSizeLimit, namespace, }); - let exportedObjects = []; + let exportedObjects: Array> = []; let missingReferences: SavedObjectsExportResultDetails['missingReferences'] = []; if (includeReferencesDeep) { @@ -162,10 +162,15 @@ export async function exportSavedObjectsToStream({ exportedObjects = sortObjects(rootObjects); } + // redact attributes that should not be exported + const redactedObjects = exportedObjects.map>( + ({ namespaces, ...object }) => object + ); + const exportDetails: SavedObjectsExportResultDetails = { exportedCount: exportedObjects.length, missingRefCount: missingReferences.length, missingReferences, }; - return createListStream([...exportedObjects, ...(excludeExportDetails ? [] : [exportDetails])]); + return createListStream([...redactedObjects, ...(excludeExportDetails ? [] : [exportDetails])]); } diff --git a/src/core/server/saved_objects/export/inject_nested_depdendencies.test.ts b/src/core/server/saved_objects/export/inject_nested_depdendencies.test.ts index a571f62e3d1c1..1d5ce5625bf48 100644 --- a/src/core/server/saved_objects/export/inject_nested_depdendencies.test.ts +++ b/src/core/server/saved_objects/export/inject_nested_depdendencies.test.ts @@ -20,6 +20,7 @@ import { SavedObject } from '../types'; import { savedObjectsClientMock } from '../../mocks'; import { getObjectReferencesToFetch, fetchNestedDependencies } from './inject_nested_depdendencies'; +import { SavedObjectsErrorHelpers } from '..'; describe('getObjectReferencesToFetch()', () => { test('works with no saved objects', () => { @@ -475,10 +476,8 @@ describe('injectNestedDependencies', () => { { id: '1', type: 'index-pattern', - error: { - statusCode: 404, - message: 'Not found', - }, + error: SavedObjectsErrorHelpers.createGenericNotFoundError('index-pattern', '1').output + .payload, attributes: {}, references: [], }, diff --git a/src/core/server/saved_objects/import/__mocks__/index.ts b/src/core/server/saved_objects/import/__mocks__/index.ts new file mode 100644 index 0000000000000..e2c48ee483ce4 --- /dev/null +++ b/src/core/server/saved_objects/import/__mocks__/index.ts @@ -0,0 +1,25 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +const mockUuidv4 = jest.fn().mockReturnValue('uuidv4'); +jest.mock('uuid', () => ({ + v4: mockUuidv4, +})); + +export { mockUuidv4 }; diff --git a/src/core/server/saved_objects/import/check_conflicts.test.ts b/src/core/server/saved_objects/import/check_conflicts.test.ts new file mode 100644 index 0000000000000..d2daab87aa0df --- /dev/null +++ b/src/core/server/saved_objects/import/check_conflicts.test.ts @@ -0,0 +1,173 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { mockUuidv4 } from './__mocks__'; +import { savedObjectsClientMock } from '../../mocks'; +import { SavedObjectReference } from 'kibana/public'; +import { SavedObjectsClientContract, SavedObject } from '../types'; +import { SavedObjectsErrorHelpers } from '..'; +import { checkConflicts } from './check_conflicts'; + +type SavedObjectType = SavedObject<{ title?: string }>; +type CheckConflictsOptions = Parameters[1]; + +/** + * Function to create a realistic-looking import object given a type and ID + */ +const createObject = (type: string, id: string): SavedObjectType => ({ + type, + id, + attributes: { title: 'some-title' }, + references: (Symbol() as unknown) as SavedObjectReference[], +}); + +const getResultMock = { + conflict: (type: string, id: string) => { + const error = SavedObjectsErrorHelpers.createConflictError(type, id).output.payload; + return { type, id, error }; + }, + unresolvableConflict: (type: string, id: string) => { + const conflictMock = getResultMock.conflict(type, id); + const metadata = { isNotOverwritable: true }; + return { ...conflictMock, error: { ...conflictMock.error, metadata } }; + }, + invalidType: (type: string, id: string) => { + const error = SavedObjectsErrorHelpers.createUnsupportedTypeError(type).output.payload; + return { type, id, error }; + }, +}; + +/** + * Create a variety of different objects to exercise different import / result scenarios + */ +const obj1 = createObject('type-1', 'id-1'); // -> success +const obj2 = createObject('type-2', 'id-2'); // -> conflict +const obj3 = createObject('type-3', 'id-3'); // -> unresolvable conflict +const obj4 = createObject('type-4', 'id-4'); // -> invalid type +const objects = [obj1, obj2, obj3, obj4]; +const obj2Error = getResultMock.conflict(obj2.type, obj2.id); +const obj3Error = getResultMock.unresolvableConflict(obj3.type, obj3.id); +const obj4Error = getResultMock.invalidType(obj4.type, obj4.id); + +describe('#checkConflicts', () => { + let savedObjectsClient: jest.Mocked; + let socCheckConflicts: typeof savedObjectsClient['checkConflicts']; + + /** + * Creates an options object to be used as an argument for createSavedObjects + * Includes mock savedObjectsClient + */ + const setupOptions = ( + options: { + namespace?: string; + ignoreRegularConflicts?: boolean; + trueCopy?: boolean; + } = {} + ): CheckConflictsOptions => { + const { namespace, ignoreRegularConflicts, trueCopy } = options; + savedObjectsClient = savedObjectsClientMock.create(); + socCheckConflicts = savedObjectsClient.checkConflicts; + socCheckConflicts.mockResolvedValue({ errors: [] }); // by default, mock to empty results + return { savedObjectsClient, namespace, ignoreRegularConflicts, trueCopy }; + }; + + beforeEach(() => { + mockUuidv4.mockReset(); + mockUuidv4.mockReturnValueOnce(`new-object-id`); + }); + + it('exits early if there are no objects to check', async () => { + const namespace = 'foo-namespace'; + const options = setupOptions({ namespace }); + + const checkConflictsResult = await checkConflicts([], options); + expect(socCheckConflicts).not.toHaveBeenCalled(); + expect(checkConflictsResult).toEqual({ + filteredObjects: [], + errors: [], + importIdMap: new Map(), + importIds: new Set(), + }); + }); + + it('calls checkConflicts with expected inputs', async () => { + const namespace = 'foo-namespace'; + const options = setupOptions({ namespace }); + + await checkConflicts(objects, options); + expect(socCheckConflicts).toHaveBeenCalledTimes(1); + expect(socCheckConflicts).toHaveBeenCalledWith(objects, { namespace }); + }); + + it('returns expected result', async () => { + const namespace = 'foo-namespace'; + const options = setupOptions({ namespace }); + socCheckConflicts.mockResolvedValue({ errors: [obj2Error, obj3Error, obj4Error] }); + + const checkConflictsResult = await checkConflicts(objects, options); + expect(checkConflictsResult).toEqual({ + filteredObjects: [obj1, obj3], + errors: [ + { ...obj2Error, title: obj2.attributes.title, error: { type: 'conflict' } }, + { + ...obj4Error, + title: obj4.attributes.title, + error: { ...obj4Error.error, type: 'unknown' }, + }, + ], + importIdMap: new Map([[`${obj3.type}:${obj3.id}`, { id: `new-object-id` }]]), + importIds: new Set(objects.map(({ type, id }) => `${type}:${id}`)), + }); + }); + + it('does not return errors for regular conflicts when ignoreRegularConflicts=true', async () => { + const namespace = 'foo-namespace'; + const options = setupOptions({ namespace, ignoreRegularConflicts: true }); + socCheckConflicts.mockResolvedValue({ errors: [obj2Error, obj3Error, obj4Error] }); + + const checkConflictsResult = await checkConflicts(objects, options); + expect(checkConflictsResult).toEqual( + expect.objectContaining({ + filteredObjects: [obj1, obj2, obj3], + errors: [ + { + ...obj4Error, + title: obj4.attributes.title, + error: { ...obj4Error.error, type: 'unknown' }, + }, + ], + }) + ); + }); + + it('adds `omitOriginId` field to `importIdMap` entries when trueCopy=true', async () => { + const namespace = 'foo-namespace'; + const options = setupOptions({ namespace, trueCopy: true }); + socCheckConflicts.mockResolvedValue({ errors: [obj2Error, obj3Error, obj4Error] }); + + const checkConflictsResult = await checkConflicts(objects, options); + expect(checkConflictsResult).toEqual( + expect.objectContaining({ + importIdMap: new Map([ + [`${obj3.type}:${obj3.id}`, { id: `new-object-id`, omitOriginId: true }], + ]), + }) + ); + }); +}); diff --git a/src/core/server/saved_objects/import/check_conflicts.ts b/src/core/server/saved_objects/import/check_conflicts.ts new file mode 100644 index 0000000000000..907b8dcc6d77c --- /dev/null +++ b/src/core/server/saved_objects/import/check_conflicts.ts @@ -0,0 +1,83 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { v4 as uuidv4 } from 'uuid'; +import { + SavedObject, + SavedObjectsClientContract, + SavedObjectsImportError, + SavedObjectError, +} from '../types'; + +interface CheckConflictsOptions { + savedObjectsClient: SavedObjectsClientContract; + namespace?: string; + ignoreRegularConflicts?: boolean; + trueCopy?: boolean; +} + +const isUnresolvableConflict = (error: SavedObjectError) => + error.statusCode === 409 && error.metadata?.isNotOverwritable; + +export async function checkConflicts( + objects: Array>, + options: CheckConflictsOptions +) { + const filteredObjects: Array> = []; + const errors: SavedObjectsImportError[] = []; + const importIdMap = new Map(); + const importIds = new Set(); + + // exit early if there are no objects to check + if (objects.length === 0) { + return { filteredObjects, errors, importIdMap, importIds }; + } + + const { savedObjectsClient, namespace, ignoreRegularConflicts, trueCopy } = options; + const checkConflictsResult = await savedObjectsClient.checkConflicts(objects, { namespace }); + const errorMap = checkConflictsResult.errors.reduce( + (acc, { type, id, error }) => acc.set(`${type}:${id}`, error), + new Map() + ); + + objects.forEach((object) => { + const { + type, + id, + attributes: { title }, + } = object; + importIds.add(`${type}:${id}`); + const errorObj = errorMap.get(`${type}:${id}`); + if (errorObj && isUnresolvableConflict(errorObj)) { + // Any object create attempt that would result in an unresolvable conflict should have its ID regenerated. This way, when an object + // with a "multi-namespace" type is exported from one namespace and imported to another, it does not result in an error, but instead a + // new object is created. + const destinationId = uuidv4(); + importIdMap.set(`${type}:${id}`, { id: destinationId, omitOriginId: trueCopy }); + filteredObjects.push(object); + } else if (errorObj && errorObj.statusCode !== 409) { + errors.push({ type, id, title, error: { ...errorObj, type: 'unknown' } }); + } else if (errorObj?.statusCode === 409 && !ignoreRegularConflicts) { + errors.push({ type, id, title, error: { type: 'conflict' } }); + } else { + filteredObjects.push(object); + } + }); + return { filteredObjects, errors, importIdMap, importIds }; +} diff --git a/src/core/server/saved_objects/import/check_origin_conflicts.test.ts b/src/core/server/saved_objects/import/check_origin_conflicts.test.ts new file mode 100644 index 0000000000000..05d08a6f8a1e2 --- /dev/null +++ b/src/core/server/saved_objects/import/check_origin_conflicts.test.ts @@ -0,0 +1,581 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { mockUuidv4 } from './__mocks__'; +import { + SavedObjectsClientContract, + SavedObjectReference, + SavedObject, + SavedObjectsImportRetry, + SavedObjectsImportError, +} from '../types'; +import { checkOriginConflicts, getImportIdMapForRetries } from './check_origin_conflicts'; +import { savedObjectsClientMock } from '../../mocks'; +import { typeRegistryMock } from '../saved_objects_type_registry.mock'; +import { ISavedObjectTypeRegistry } from '..'; + +type SavedObjectType = SavedObject<{ title?: string }>; +type CheckOriginConflictsOptions = Parameters[1]; +type GetImportIdMapForRetriesOptions = Parameters[1]; + +/** + * Function to create a realistic-looking import object given a type, ID, and optional originId + */ +const createObject = (type: string, id: string, originId?: string): SavedObjectType => ({ + type, + id, + attributes: { title: `Title for ${type}:${id}` }, + references: (Symbol() as unknown) as SavedObjectReference[], + ...(originId && { originId }), +}); + +const MULTI_NS_TYPE = 'multi'; +const OTHER_TYPE = 'other'; + +beforeEach(() => { + mockUuidv4.mockClear(); +}); + +describe('#checkOriginConflicts', () => { + let savedObjectsClient: jest.Mocked; + let typeRegistry: jest.Mocked; + let find: typeof savedObjectsClient['find']; + + const getResultMock = (...objects: SavedObjectType[]) => ({ + page: 1, + per_page: 10, + total: objects.length, + saved_objects: objects.map((object) => ({ ...object, score: 0 })), + }); + + const setupOptions = ( + options: { + namespace?: string; + importIds?: Set; + ignoreRegularConflicts?: boolean; + } = {} + ): CheckOriginConflictsOptions => { + const { namespace, importIds = new Set(), ignoreRegularConflicts } = options; + 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 { savedObjectsClient, typeRegistry, namespace, ignoreRegularConflicts, importIds }; + }; + + const mockFindResult = (...objects: SavedObjectType[]) => { + find.mockResolvedValueOnce(getResultMock(...objects)); + }; + + describe('cluster calls', () => { + 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'); + // 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 { type, id, originId } = object; + const search = `"${rawIdPrefix}${type}:${originId || id}" | "${originId || id}"`; // this template works for our basic test cases + const expectedOptions = expect.objectContaining({ type, search }); + // exclude rawSearchFields, page, perPage, and fields attributes from assertion -- these are constant + // exclude namespace from assertion -- a separate test covers that + expect(find).toHaveBeenNthCalledWith(n, expectedOptions); + }; + + test('does not execute searches for non-multi-namespace objects', async () => { + const objects = [otherObj, otherObjWithOriginId]; + const options = setupOptions(); + + await checkOriginConflicts(objects, options); + expect(find).not.toHaveBeenCalled(); + }); + + test('executes searches for multi-namespace objects', async () => { + const objects = [multiNsObj, otherObj, multiNsObjWithOriginId, otherObjWithOriginId]; + const options1 = setupOptions(); + + await checkOriginConflicts(objects, options1); + expect(find).toHaveBeenCalledTimes(2); + expectFindArgs(1, multiNsObj, ''); + expectFindArgs(2, multiNsObjWithOriginId, ''); + + find.mockClear(); + const options2 = setupOptions({ namespace: 'some-namespace' }); + await checkOriginConflicts(objects, options2); + expect(find).toHaveBeenCalledTimes(2); + expectFindArgs(1, multiNsObj, 'some-namespace:'); + expectFindArgs(2, multiNsObjWithOriginId, 'some-namespace:'); + }); + + test('searches within the current `namespace`', async () => { + const objects = [multiNsObj]; + const namespace = 'some-namespace'; + const options = setupOptions({ namespace }); + + await checkOriginConflicts(objects, options); + expect(find).toHaveBeenCalledTimes(1); + expect(find).toHaveBeenCalledWith(expect.objectContaining({ 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 options = setupOptions(); + + await checkOriginConflicts(objects, options); + 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', () => { + const getAmbiguousConflicts = (objects: SavedObjectType[]) => + objects + .map(({ id, attributes, updated_at: updatedAt }) => ({ + id, + title: attributes?.title, + updatedAt, + })) + .sort((a: { id: string }, b: { id: string }) => (a.id > b.id ? 1 : b.id > a.id ? -1 : 0)); + const createAmbiguousConflictError = ( + object: SavedObjectType, + destinations: SavedObjectType[] + ): SavedObjectsImportError => ({ + type: object.type, + id: object.id, + title: object.attributes?.title, + error: { + type: 'ambiguous_conflict', + destinations: getAmbiguousConflicts(destinations), + }, + }); + const createConflictError = ( + object: SavedObjectType, + destinationId?: string + ): SavedObjectsImportError => ({ + type: object.type, + id: object.id, + title: object.attributes?.title, + error: { + type: 'conflict', + ...(destinationId && { destinationId }), + }, + }); + + describe('object result without a `importIdMap` 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 + const obj1 = createObject(OTHER_TYPE, 'id-1'); // non-multi-namespace types are skipped when searching, so they will never have a match anyway + const obj2 = createObject(OTHER_TYPE, 'id-2', 'originId-foo'); // non-multi-namespace types are skipped when searching, so they will never have a match anyway + const obj3 = createObject(MULTI_NS_TYPE, 'id-3'); + const obj4 = createObject(MULTI_NS_TYPE, 'id-4', 'originId-bar'); + const options = setupOptions(); + + // don't need to mock find results for obj3 and obj4, "no match" is the default find result in this test suite + const checkOriginConflictsResult = await checkOriginConflicts( + [obj1, obj2, obj3, obj4], + options + ); + + const expectedResult = { + filteredObjects: [obj1, obj2, obj3, obj4], + importIdMap: new Map(), + errors: [], + }; + expect(checkOriginConflictsResult).toEqual(expectedResult); + }); + + test('returns object when an inexact match is detected (1 hit) with a destination that is exactly matched by another object', async () => { + // obj1 and obj3 exist in this space + // try to import obj1, obj2, obj3, and obj4; simulating a scenario where obj1 and obj3 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', obj1.id); + const obj3 = createObject(MULTI_NS_TYPE, 'id-3', 'originId-foo'); + const obj4 = createObject(MULTI_NS_TYPE, 'id-4', obj3.originId); + const options = setupOptions({ + importIds: new Set([ + `${obj1.type}:${obj1.id}`, + `${obj2.type}:${obj2.id}`, + `${obj3.type}:${obj3.id}`, + `${obj4.type}:${obj4.id}`, + ]), + }); + mockFindResult(obj1); // find for obj2: the result is an inexact match with one destination that is exactly matched by obj1 so it is ignored -- accordingly, obj2 has 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 + + const checkOriginConflictsResult = await checkOriginConflicts([obj2, obj4], options); + const expectedResult = { + filteredObjects: [obj2, obj4], + importIdMap: new Map(), + errors: [], + }; + expect(checkOriginConflictsResult).toEqual(expectedResult); + }); + + test('returns object when an inexact match is detected (2+ hits) with destinations that are all exactly matched by another object', async () => { + // obj1 and obj2 exist in this space + // try to import obj1, obj2, and obj3; 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', obj1.id); + const obj3 = createObject(MULTI_NS_TYPE, 'id-3', obj1.id); + const options = setupOptions({ + importIds: new Set([`${obj1.type}:${obj1.id}`, `${obj2.type}:${obj2.id}`]), + }); + mockFindResult(obj1, obj2); // find for obj3: the result is an inexact match with two destinations that are exactly matched by obj1 and obj2 so they are ignored -- accordingly, obj3 has no match + + const checkOriginConflictsResult = await checkOriginConflicts([obj3], options); + const expectedResult = { + filteredObjects: [obj3], + importIdMap: new Map(), + errors: [], + }; + expect(checkOriginConflictsResult).toEqual(expectedResult); + }); + }); + + describe('object result with a `importIdMap` 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 + const obj1 = createObject(MULTI_NS_TYPE, 'id-1'); + const objA = createObject(MULTI_NS_TYPE, 'id-A', obj1.id); + const obj2 = createObject(MULTI_NS_TYPE, 'id-2', 'originId-foo'); + const objB = createObject(MULTI_NS_TYPE, 'id-B', obj2.originId); + + const setup = (ignoreRegularConflicts: boolean) => { + const options = setupOptions({ ignoreRegularConflicts }); + mockFindResult(objA); // find for obj1: the result is an inexact match with one destination + mockFindResult(objB); // find for obj2: the result is an inexact match with one destination + return options; + }; + + test('returns conflict error when ignoreRegularConflicts=false', async () => { + const options = setup(false); + const checkOriginConflictsResult = await checkOriginConflicts([obj1, obj2], options); + const expectedResult = { + filteredObjects: [], + importIdMap: new Map(), + errors: [createConflictError(obj1, objA.id), createConflictError(obj2, objB.id)], + }; + expect(checkOriginConflictsResult).toEqual(expectedResult); + }); + + test('returns object with a `importIdMap` entry when ignoreRegularConflicts=true', async () => { + const options = setup(true); + const checkOriginConflictsResult = await checkOriginConflicts([obj1, obj2], options); + const expectedResult = { + filteredObjects: [obj1, obj2], + importIdMap: new Map([ + [`${obj1.type}:${obj1.id}`, { id: objA.id }], + [`${obj2.type}:${obj2.id}`, { id: objB.id }], + ]), + errors: [], + }; + expect(checkOriginConflictsResult).toEqual(expectedResult); + }); + }); + + describe('when an inexact match is detected (2+ hits), with n-1 destinations that are exactly matched by another object', () => { + // obj1, obj3, objA, and objB exist in this space + // try to import obj1, obj2, obj3, and obj4; simulating a scenario where obj1 and obj3 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', obj1.id); + const objA = createObject(MULTI_NS_TYPE, 'id-A', obj1.id); + const obj3 = createObject(MULTI_NS_TYPE, 'id-3', 'originId-foo'); + const obj4 = createObject(MULTI_NS_TYPE, 'id-4', obj3.originId); + const objB = createObject(MULTI_NS_TYPE, 'id-B', obj3.originId); + + const setup = (ignoreRegularConflicts: boolean) => { + const options = setupOptions({ + ignoreRegularConflicts, + importIds: new Set([ + `${obj1.type}:${obj1.id}`, + `${obj2.type}:${obj2.id}`, + `${obj3.type}:${obj3.id}`, + `${obj4.type}:${obj4.id}`, + ]), + }); + mockFindResult(obj1, objA); // find for obj2: the result is an inexact match with two destinations, but the first destination is exactly matched by obj1 so it is ignored -- accordingly, obj2 has an inexact match with one destination (objA) + mockFindResult(objB, obj3); // find for obj4: the result is an inexact match with two destinations, but the second destination is exactly matched by obj3 so it is ignored -- accordingly, obj4 has an inexact match with one destination (objB) + return options; + }; + + test('returns conflict error when ignoreRegularConflicts=false', async () => { + const options = setup(false); + const checkOriginConflictsResult = await checkOriginConflicts([obj2, obj4], options); + const expectedResult = { + filteredObjects: [], + importIdMap: new Map(), + errors: [createConflictError(obj2, objA.id), createConflictError(obj4, objB.id)], + }; + expect(checkOriginConflictsResult).toEqual(expectedResult); + }); + + test('returns object with a `importIdMap` entry when ignoreRegularConflicts=true', async () => { + const options = setup(true); + const checkOriginConflictsResult = await checkOriginConflicts([obj2, obj4], options); + const expectedResult = { + filteredObjects: [obj2, obj4], + importIdMap: new Map([ + [`${obj2.type}:${obj2.id}`, { id: objA.id }], + [`${obj4.type}:${obj4.id}`, { id: objB.id }], + ]), + errors: [], + }; + expect(checkOriginConflictsResult).toEqual(expectedResult); + }); + }); + }); + + describe('ambiguous conflicts', () => { + test('returns object with a `importIdMap` 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'); + const obj2 = createObject(MULTI_NS_TYPE, 'id-2', obj1.id); + const objA = createObject(MULTI_NS_TYPE, 'id-A', obj1.id); + const obj3 = createObject(MULTI_NS_TYPE, 'id-3', 'originId-foo'); + const obj4 = createObject(MULTI_NS_TYPE, 'id-4', obj3.originId); + const objB = createObject(MULTI_NS_TYPE, 'id-B', obj3.originId); + const options = setupOptions(); + mockFindResult(objA); // find for obj1: the result is an inexact match with one destination + mockFindResult(objA); // find for obj2: the result is an inexact match with one destination + mockFindResult(objB); // find for obj3: the result is an inexact match with one destination + mockFindResult(objB); // find for obj4: the result is an inexact match with one destination + + const checkOriginConflictsResult = await checkOriginConflicts( + [obj1, obj2, obj3, obj4], + options + ); + const expectedResult = { + filteredObjects: [obj1, obj2, obj3, obj4], + 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 }], + ]), + errors: [], + }; + expect(mockUuidv4).toHaveBeenCalledTimes(4); + expect(checkOriginConflictsResult).toEqual(expectedResult); + }); + + test('returns ambiguous_conflict error when an inexact match is detected (2+ hits)', async () => { + // objA, objB, objC, and objD exist in this space + // try to import obj1 and obj2 + const obj1 = createObject(MULTI_NS_TYPE, 'id-1'); + const obj2 = createObject(MULTI_NS_TYPE, 'id-2', 'originId-foo'); + const objA = createObject(MULTI_NS_TYPE, 'id-A', obj1.id); + const objB = createObject(MULTI_NS_TYPE, 'id-B', obj1.id); + const objC = createObject(MULTI_NS_TYPE, 'id-C', obj2.originId); + const objD = createObject(MULTI_NS_TYPE, 'id-D', obj2.originId); + const options = setupOptions(); + mockFindResult(objA, objB); // find for obj1: the result is an inexact match with two destinations + mockFindResult(objC, objD); // find for obj2: the result is an inexact match with two destinations + + const checkOriginConflictsResult = await checkOriginConflicts([obj1, obj2], options); + const expectedResult = { + filteredObjects: [], + importIdMap: new Map(), + errors: [ + createAmbiguousConflictError(obj1, [objA, objB]), + createAmbiguousConflictError(obj2, [objC, objD]), + ], + }; + expect(mockUuidv4).not.toHaveBeenCalled(); + expect(checkOriginConflictsResult).toEqual(expectedResult); + }); + + test('returns object with a `importIdMap` 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'); + const obj2 = createObject(MULTI_NS_TYPE, 'id-2', obj1.id); + const obj3 = createObject(MULTI_NS_TYPE, 'id-3', 'originId-foo'); + const obj4 = createObject(MULTI_NS_TYPE, 'id-4', obj3.originId); + const objA = createObject(MULTI_NS_TYPE, 'id-A', obj1.id); + const objB = createObject(MULTI_NS_TYPE, 'id-B', obj1.id); + const objC = createObject(MULTI_NS_TYPE, 'id-C', obj3.originId); + const objD = createObject(MULTI_NS_TYPE, 'id-D', obj3.originId); + const options = setupOptions(); + mockFindResult(objA, objB); // find for obj1: the result is an inexact match with two destinations + mockFindResult(objA, objB); // find for obj2: the result is an inexact match with two destinations + mockFindResult(objC, objD); // find for obj3: the result is an inexact match with two destinations + mockFindResult(objC, objD); // find for obj4: the result is an inexact match with two destinations + + const checkOriginConflictsResult = await checkOriginConflicts( + [obj1, obj2, obj3, obj4], + options + ); + const expectedResult = { + filteredObjects: [obj1, obj2, obj3, obj4], + 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 }], + ]), + errors: [], + }; + expect(mockUuidv4).toHaveBeenCalledTimes(4); + expect(checkOriginConflictsResult).toEqual(expectedResult); + }); + }); + + describe('mixed results', () => { + // obj3, objA, objB, objC, objD, and objE exist in this space + // try to import obj1, obj2, obj3, obj4, obj5, obj6, and obj7; simulating a scenario where obj3 was filtered out during `checkConflicts`, so we only call `checkOriginConflicts` with the remainder + // note: this test is non-exhaustive for different permutations of import objects and results, but prior tests exercise these more thoroughly + const obj1 = createObject(OTHER_TYPE, 'id-1'); + const obj2 = createObject(MULTI_NS_TYPE, 'id-2'); + const obj3 = createObject(MULTI_NS_TYPE, 'id-3'); + const obj4 = createObject(MULTI_NS_TYPE, 'id-4', obj3.id); + const obj5 = createObject(MULTI_NS_TYPE, 'id-5'); + const obj6 = createObject(MULTI_NS_TYPE, 'id-6'); + const obj7 = createObject(MULTI_NS_TYPE, 'id-7'); + const obj8 = createObject(MULTI_NS_TYPE, 'id-8', obj7.id); + const objA = createObject(MULTI_NS_TYPE, 'id-A', obj5.id); + const objB = createObject(MULTI_NS_TYPE, 'id-B', obj6.id); + const objC = createObject(MULTI_NS_TYPE, 'id-C', obj6.id); + const objD = createObject(MULTI_NS_TYPE, 'id-D', obj7.id); + const objE = createObject(MULTI_NS_TYPE, 'id-E', obj7.id); + const objects = [obj1, obj2, obj4, obj5, obj6, obj7, obj8]; + const importIds = new Set([...objects, obj3].map(({ type, id }) => `${type}:${id}`)); + + const setup = (ignoreRegularConflicts: boolean) => { + const options = setupOptions({ importIds, 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 + mockFindResult(objA); // find for obj5: the result is an inexact match with one destination + mockFindResult(objB, objC); // find for obj6: the result is an inexact match with two destinations + mockFindResult(objD, objE); // find for obj7: the result is an inexact match with two destinations + mockFindResult(objD, objE); // find for obj8: the result is an inexact match with two destinations + return options; + }; + + test('returns errors for regular conflicts when ignoreRegularConflicts=false', async () => { + const options = setup(false); + const checkOriginConflictsResult = await checkOriginConflicts(objects, options); + const expectedResult = { + filteredObjects: [obj1, obj2, obj4, obj7, obj8], + importIdMap: new Map([ + [`${obj7.type}:${obj7.id}`, { id: 'uuidv4', omitOriginId: true }], + [`${obj8.type}:${obj8.id}`, { id: 'uuidv4', omitOriginId: true }], + ]), + errors: [ + createConflictError(obj5, objA.id), + createAmbiguousConflictError(obj6, [objB, objC]), + ], + }; + expect(mockUuidv4).toHaveBeenCalledTimes(2); + expect(checkOriginConflictsResult).toEqual(expectedResult); + }); + + test('does not return errors for regular conflicts when ignoreRegularConflicts=true', async () => { + const options = setup(true); + const checkOriginConflictsResult = await checkOriginConflicts(objects, options); + const expectedResult = { + filteredObjects: [obj1, obj2, obj4, obj5, obj7, obj8], + 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 }], + ]), + errors: [createAmbiguousConflictError(obj6, [objB, objC])], + }; + expect(mockUuidv4).toHaveBeenCalledTimes(2); + expect(checkOriginConflictsResult).toEqual(expectedResult); + }); + }); + }); +}); + +describe('#getImportIdMapForRetries', () => { + const setupOptions = ( + retries: SavedObjectsImportRetry[], + trueCopy: boolean = false + ): GetImportIdMapForRetriesOptions => { + return { retries, trueCopy }; + }; + + const createRetry = ( + { type, id }: { type: string; id: string }, + options: { destinationId?: string; trueCopy?: boolean } = {} + ): SavedObjectsImportRetry => { + const { destinationId, trueCopy } = options; + return { type, id, overwrite: false, destinationId, replaceReferences: [], trueCopy }; + }; + + 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 retries = [createRetry(obj1)]; + const options = setupOptions(retries); + + expect(() => + getImportIdMapForRetries([obj1, obj2], options) + ).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', trueCopy: true }), // this retry will get added to the `importIdMap`! + ]; + const options = setupOptions(retries); + + const checkOriginConflictsResult = await getImportIdMapForRetries(objects, options); + 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 trueCopy=true', async () => { + const obj = createObject('type-1', 'id-1'); + const objects = [obj]; + const retries = [createRetry(obj, { destinationId: 'id-X' })]; + const options = setupOptions(retries, true); + + const checkOriginConflictsResult = await getImportIdMapForRetries(objects, options); + expect(checkOriginConflictsResult).toEqual( + new Map([[`${obj.type}:${obj.id}`, { id: 'id-X', omitOriginId: true }]]) + ); + }); +}); diff --git a/src/core/server/saved_objects/import/check_origin_conflicts.ts b/src/core/server/saved_objects/import/check_origin_conflicts.ts new file mode 100644 index 0000000000000..04364b367904b --- /dev/null +++ b/src/core/server/saved_objects/import/check_origin_conflicts.ts @@ -0,0 +1,246 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { v4 as uuidv4 } from 'uuid'; +import { + SavedObject, + SavedObjectsClientContract, + SavedObjectsImportError, + SavedObjectsImportRetry, +} from '../types'; +import { ISavedObjectTypeRegistry } from '..'; + +interface CheckOriginConflictsOptions { + savedObjectsClient: SavedObjectsClientContract; + typeRegistry: ISavedObjectTypeRegistry; + namespace?: string; + ignoreRegularConflicts?: boolean; + importIds: Set; +} + +interface GetImportIdMapForRetriesOptions { + retries: SavedObjectsImportRetry[]; + trueCopy: boolean; +} + +interface InexactMatch { + object: SavedObject; + destinations: Array<{ id: string; title?: string; updatedAt?: string }>; +} +interface Left { + tag: 'left'; + value: InexactMatch; +} +interface Right { + tag: 'right'; + value: SavedObject; +} +type Either = Left | Right; +const isLeft = (object: Either): object is Left => object.tag === 'left'; + +const createQueryTerm = (input: string) => input.replace(/\\/g, '\\\\').replace(/\"/g, '\\"'); +const createQuery = (type: string, id: string, rawIdPrefix: string) => + `"${createQueryTerm(`${rawIdPrefix}${type}:${id}`)}" | "${createQueryTerm(id)}"`; +const transformObjectsToAmbiguousConflictFields = ( + objects: Array> +) => + objects + .map(({ id, attributes, updated_at: updatedAt }) => ({ + id, + title: attributes?.title, + updatedAt, + })) + // Sort for two reasons: 1. consumers may want to identify multiple errors that have the same sources (by stringifying the `sources` + // array of each object they can be compared), and 2. it will be a less confusing experience for end-users if several ambiguous + // conflicts that share the same destinations all show those destinations in the same order. + .sort((a, b) => (a.id > b.id ? 1 : b.id > a.id ? -1 : 0)); +const getAmbiguousConflictSourceKey = ({ object }: InexactMatch) => + `${object.type}:${object.originId || object.id}`; + +/** + * Make a search request for an import object to check if any objects of this type that match this object's `originId` or `id` exist in the + * 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. + */ +const checkOriginConflict = async ( + object: SavedObject<{ title?: string }>, + options: CheckOriginConflictsOptions +): Promise> => { + const { savedObjectsClient, typeRegistry, namespace, importIds } = options; + const { type, originId } = object; + + if (!typeRegistry.isMultiNamespace(type)) { + // Skip the search request for non-multi-namespace types, since by definition they cannot have inexact matches or ambiguous conflicts. + return { tag: 'right', value: object }; + } + + const search = createQuery(type, originId || object.id, namespace ? `${namespace}:` : ''); + const findOptions = { + type, + search, + rawSearchFields: ['_id', 'originId'], + page: 1, + perPage: 10, + fields: ['title'], + namespace, + }; + const findResult = await savedObjectsClient.find<{ title?: string }>(findOptions); + const { total, saved_objects: savedObjects } = findResult; + if (total === 0) { + 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 destinations = transformObjectsToAmbiguousConflictFields(objects); + if (destinations.length === 0) { + // No conflict destinations remain after filtering, so this is a "no match" result. + return { tag: 'right', value: object }; + } + return { tag: 'left', value: { object, destinations } }; +}; + +/** + * This function takes all objects to import, and checks "multi-namespace" types for potential conflicts. An object with a multi-namespace + * type may include an `originId` field, which means that it should conflict with other objects that originate from the same source. + * Expected behavior of importing saved objects (single-namespace or multi-namespace): + * 1. The object 'foo' is exported from space A and imported to space B -- a new object 'bar' is created. + * 2. Then, the object 'bar' is exported from space B and imported to space C -- a new object 'baz' is created. + * 3. Then, the object 'baz' is exported from space C to space A -- the object conflicts with 'foo', which must be overwritten to continue. + * This behavior originated with "single-namespace" types, and this function was added to ensure importing objects of multi-namespace types + * will behave in the same way. + * + * To achieve this behavior for multi-namespace types, a search request is made for each object to determine if any objects of this type + * 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 + * 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. + */ +export async function checkOriginConflicts( + objects: Array>, + options: CheckOriginConflictsOptions +) { + // Check each object for possible destination conflicts. + const checkOriginConflictResults = await Promise.all( + objects.map((object) => checkOriginConflict(object, options)) + ); + + // Get a map of all inexact matches that share the same destination(s). + const ambiguousConflictSourcesMap = checkOriginConflictResults + .filter(isLeft) + .reduce((acc, cur) => { + const key = getAmbiguousConflictSourceKey(cur.value); + const value = acc.get(key) ?? []; + return acc.set(key, [...value, cur.value.object]); + }, new Map>>()); + + const errors: SavedObjectsImportError[] = []; + const filteredObjects: Array> = []; + const importIdMap = new Map(); + checkOriginConflictResults.forEach((result) => { + if (!isLeft(result)) { + filteredObjects.push(result.value); + return; + } + const key = getAmbiguousConflictSourceKey(result.value); + const sources = transformObjectsToAmbiguousConflictFields( + ambiguousConflictSourcesMap.get(key)! + ); + const { object, destinations } = result.value; + const { type, id, attributes } = object; + if (sources.length === 1 && destinations.length === 1) { + // This is a simple "inexact match" result -- a single import object has a single destination conflict. + if (options.ignoreRegularConflicts) { + importIdMap.set(`${type}:${id}`, { id: destinations[0].id }); + filteredObjects.push(object); + } else { + errors.push({ + type, + id, + title: attributes?.title, + error: { + type: 'conflict', + destinationId: destinations[0].id, + }, + }); + } + return; + } + // This is an ambiguous conflict error, which is one of the following cases: + // - a single import object has 2+ destination conflicts ("ambiguous destination") + // - 2+ import objects have the same single destination conflict ("ambiguous source") + // - 2+ import objects have the same 2+ destination conflicts ("ambiguous source and destination") + 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., make a "true copy"). + importIdMap.set(`${type}:${id}`, { id: uuidv4(), omitOriginId: true }); + filteredObjects.push(object); + return; + } + errors.push({ + type, + id, + title: attributes?.title, + error: { + type: 'ambiguous_conflict', + destinations, + }, + }); + }); + + return { + errors, + filteredObjects, + importIdMap, + }; +} + +/** + * Assume that all objects exist in the `retries` map (due to filtering at the beginnning of `resolveSavedObjectsImportErrors`). + */ +export function getImportIdMapForRetries( + objects: Array>, + options: GetImportIdMapForRetriesOptions +) { + const { retries, trueCopy } = options; + + 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 = trueCopy || Boolean(retry.trueCopy); + if (destinationId && destinationId !== id) { + importIdMap.set(`${type}:${id}`, { id: destinationId, omitOriginId }); + } + }); + + return importIdMap; +} diff --git a/src/core/server/saved_objects/import/collect_saved_objects.test.ts b/src/core/server/saved_objects/import/collect_saved_objects.test.ts index 9cccc3942f655..6f8a98e7e3216 100644 --- a/src/core/server/saved_objects/import/collect_saved_objects.test.ts +++ b/src/core/server/saved_objects/import/collect_saved_objects.test.ts @@ -17,121 +17,185 @@ * under the License. */ -import { Readable } from 'stream'; +import { Readable, PassThrough } from 'stream'; import { collectSavedObjects } from './collect_saved_objects'; +import { createLimitStream } from './create_limit_stream'; +import { getNonUniqueEntries } from './utilities'; + +jest.mock('./create_limit_stream'); +jest.mock('./utilities'); + +const getMockFn = any, U>(fn: (...args: Parameters) => U) => + fn as jest.MockedFunction<(...args: Parameters) => U>; + +let limitStreamPush: jest.SpyInstance; + +beforeEach(() => { + jest.clearAllMocks(); + const stream = new PassThrough({ objectMode: true }); + limitStreamPush = jest.spyOn(stream, 'push'); + getMockFn(createLimitStream).mockReturnValue(stream); + getMockFn(getNonUniqueEntries).mockReturnValue([]); +}); describe('collectSavedObjects()', () => { - test('collects nothing when stream is empty', async () => { - const readStream = new Readable({ + const objectLimit = 10; + const createReadStream = (...args: any[]) => + new Readable({ objectMode: true, read() { + args.forEach((arg) => this.push(arg)); this.push(null); }, }); - const result = await collectSavedObjects({ readStream, objectLimit: 10, supportedTypes: [] }); - expect(result).toMatchInlineSnapshot(` -Object { - "collectedObjects": Array [], - "errors": Array [], -} -`); - }); - test('collects objects from stream', async () => { - const readStream = new Readable({ - objectMode: true, - read() { - this.push({ foo: true, type: 'a' }); - this.push(null); - }, + const obj1 = { type: 'a', id: '1', attributes: { title: 'my title 1' } }; + const obj2 = { type: 'b', id: '2', attributes: { title: 'my title 2' } }; + + describe('module calls', () => { + test('limit stream with empty input stream is called with null', async () => { + const readStream = createReadStream(); + await collectSavedObjects({ readStream, supportedTypes: [], objectLimit }); + + expect(createLimitStream).toHaveBeenCalledWith(objectLimit); + expect(limitStreamPush).toHaveBeenCalledTimes(1); + expect(limitStreamPush).toHaveBeenLastCalledWith(null); }); - const result = await collectSavedObjects({ - readStream, - objectLimit: 1, - supportedTypes: ['a'], + + test('limit stream with non-empty input stream is called with all objects', async () => { + const readStream = createReadStream(obj1, obj2); + const supportedTypes = [obj2.type]; + await collectSavedObjects({ readStream, supportedTypes, objectLimit }); + + expect(createLimitStream).toHaveBeenCalledWith(objectLimit); + expect(limitStreamPush).toHaveBeenCalledTimes(3); + expect(limitStreamPush).toHaveBeenNthCalledWith(1, obj1); + expect(limitStreamPush).toHaveBeenNthCalledWith(2, obj2); + expect(limitStreamPush).toHaveBeenLastCalledWith(null); }); - expect(result).toMatchInlineSnapshot(` -Object { - "collectedObjects": Array [ - Object { - "foo": true, - "migrationVersion": Object {}, - "type": "a", - }, - ], - "errors": Array [], -} -`); - }); - test('throws error when object limit is reached', async () => { - const readStream = new Readable({ - objectMode: true, - read() { - this.push({ foo: true, type: 'a' }); - this.push({ bar: true, type: 'a' }); - this.push(null); - }, + test('get non-unique entries with empty input stream is called with empty array', async () => { + const readStream = createReadStream(); + await collectSavedObjects({ readStream, supportedTypes: [], objectLimit }); + + expect(getNonUniqueEntries).toHaveBeenCalledWith([]); }); - await expect( - collectSavedObjects({ - readStream, - objectLimit: 1, - supportedTypes: ['a'], - }) - ).rejects.toThrowErrorMatchingInlineSnapshot(`"Can't import more than 1 objects"`); - }); - test('unsupported types return as import errors', async () => { - const readStream = new Readable({ - objectMode: true, - read() { - this.push({ id: '1', type: 'a', attributes: { title: 'my title' } }); - this.push({ id: '2', type: 'b', attributes: { title: 'my title 2' } }); - this.push(null); - }, + test('get non-unique entries with non-empty input stream is called with all entries', async () => { + const readStream = createReadStream(obj1, obj2); + const supportedTypes = [obj2.type]; + await collectSavedObjects({ readStream, supportedTypes, objectLimit }); + + expect(getNonUniqueEntries).toHaveBeenCalledWith([ + { type: obj1.type, id: obj1.id }, + { type: obj2.type, id: obj2.id }, + ]); + }); + + test('filter with empty input stream is not called', async () => { + const readStream = createReadStream(); + const filter = jest.fn(); + await collectSavedObjects({ readStream, supportedTypes: [], objectLimit, filter }); + + expect(filter).not.toHaveBeenCalled(); + }); + + test('filter with non-empty input stream is called with all objects of supported types', async () => { + const readStream = createReadStream(obj1, obj2); + const filter = jest.fn(); + const supportedTypes = [obj2.type]; + await collectSavedObjects({ readStream, supportedTypes, objectLimit, filter }); + + expect(filter).toHaveBeenCalledTimes(1); + expect(filter).toHaveBeenCalledWith(obj2); }); - const result = await collectSavedObjects({ readStream, objectLimit: 2, supportedTypes: ['1'] }); - expect(result).toMatchInlineSnapshot(` -Object { - "collectedObjects": Array [], - "errors": Array [ - Object { - "error": Object { - "type": "unsupported_type", - }, - "id": "1", - "title": "my title", - "type": "a", - }, - Object { - "error": Object { - "type": "unsupported_type", - }, - "id": "2", - "title": "my title 2", - "type": "b", - }, - ], -} -`); }); - test('unsupported types still count towards object limit', async () => { - const readStream = new Readable({ - objectMode: true, - read() { - this.push({ foo: true, type: 'a' }); - this.push({ bar: true, type: 'b' }); - this.push(null); - }, + describe('results', () => { + test('throws Boom error if any import objects are not unique', async () => { + getMockFn(getNonUniqueEntries).mockReturnValue(['type1:id1', 'type2:id2']); + const readStream = createReadStream(); + expect.assertions(2); + try { + await collectSavedObjects({ readStream, supportedTypes: [], objectLimit }); + } catch ({ isBoom, message }) { + expect(isBoom).toBe(true); + expect(message).toMatchInlineSnapshot( + `"Non-unique import objects detected: [type1:id1,type2:id2]: Bad Request"` + ); + } + }); + + test('collects nothing when stream is empty', async () => { + const readStream = createReadStream(); + const result = await collectSavedObjects({ readStream, supportedTypes: [], objectLimit }); + + expect(result).toEqual({ collectedObjects: [], errors: [] }); + }); + + test('collects objects from stream', async () => { + const readStream = createReadStream(obj1); + const supportedTypes = [obj1.type]; + const result = await collectSavedObjects({ readStream, supportedTypes, objectLimit }); + + const collectedObjects = [{ ...obj1, migrationVersion: {} }]; + expect(result).toEqual({ collectedObjects, errors: [] }); + }); + + test('unsupported types return as import errors', async () => { + const readStream = createReadStream(obj1); + const supportedTypes = ['not-obj1-type']; + const result = await collectSavedObjects({ readStream, supportedTypes, objectLimit }); + + const error = { type: 'unsupported_type' }; + const errors = [{ error, type: obj1.type, id: obj1.id, title: obj1.attributes.title }]; + expect(result).toEqual({ collectedObjects: [], errors }); + }); + + test('returns mixed results', async () => { + const readStream = createReadStream(obj1, obj2); + const supportedTypes = [obj2.type]; + const result = await collectSavedObjects({ readStream, supportedTypes, objectLimit }); + + const collectedObjects = [{ ...obj2, migrationVersion: {} }]; + const error = { type: 'unsupported_type' }; + const errors = [{ error, type: obj1.type, id: obj1.id, title: obj1.attributes.title }]; + expect(result).toEqual({ collectedObjects, errors }); + }); + + describe('with optional filter', () => { + test('filters out objects when result === false', async () => { + const readStream = createReadStream(obj1, obj2); + const filter = jest.fn().mockReturnValue(false); + const supportedTypes = [obj2.type]; + const result = await collectSavedObjects({ + readStream, + supportedTypes, + objectLimit, + filter, + }); + + const error = { type: 'unsupported_type' }; + const errors = [{ error, type: obj1.type, id: obj1.id, title: obj1.attributes.title }]; + expect(result).toEqual({ collectedObjects: [], errors }); + }); + + test('does not filter out objects when result === true', async () => { + const readStream = createReadStream(obj1, obj2); + const filter = jest.fn().mockReturnValue(true); + const supportedTypes = [obj2.type]; + const result = await collectSavedObjects({ + readStream, + supportedTypes, + objectLimit, + filter, + }); + + const collectedObjects = [{ ...obj2, migrationVersion: {} }]; + const error = { type: 'unsupported_type' }; + const errors = [{ error, type: obj1.type, id: obj1.id, title: obj1.attributes.title }]; + expect(result).toEqual({ collectedObjects, errors }); + }); }); - await expect( - collectSavedObjects({ - readStream, - objectLimit: 1, - supportedTypes: ['a'], - }) - ).rejects.toThrowErrorMatchingInlineSnapshot(`"Can't import more than 1 objects"`); }); }); diff --git a/src/core/server/saved_objects/import/collect_saved_objects.ts b/src/core/server/saved_objects/import/collect_saved_objects.ts index 1b787c7d9dc10..6983da0231b25 100644 --- a/src/core/server/saved_objects/import/collect_saved_objects.ts +++ b/src/core/server/saved_objects/import/collect_saved_objects.ts @@ -27,6 +27,8 @@ import { import { SavedObject } from '../types'; import { createLimitStream } from './create_limit_stream'; import { SavedObjectsImportError } from './types'; +import { getNonUniqueEntries } from './utilities'; +import { SavedObjectsErrorHelpers } from '..'; interface CollectSavedObjectsOptions { readStream: Readable; @@ -42,10 +44,12 @@ export async function collectSavedObjects({ supportedTypes, }: CollectSavedObjectsOptions) { const errors: SavedObjectsImportError[] = []; + const entries: Array<{ type: string; id: string }> = []; const collectedObjects: Array> = await createPromiseFromStreams([ readStream, createLimitStream(objectLimit), createFilterStream>((obj) => { + entries.push({ type: obj.type, id: obj.id }); if (supportedTypes.includes(obj.type)) { return true; } @@ -66,6 +70,15 @@ export async function collectSavedObjects({ }), createConcatStream([]), ]); + + // throw a BadRequest error if we see the same import object type/id more than once + const nonUniqueEntries = getNonUniqueEntries(entries); + if (nonUniqueEntries.length > 0) { + throw SavedObjectsErrorHelpers.createBadRequestError( + `Non-unique import objects detected: [${nonUniqueEntries.join()}]` + ); + } + return { errors, collectedObjects, diff --git a/src/core/server/saved_objects/import/create_saved_objects.test.ts b/src/core/server/saved_objects/import/create_saved_objects.test.ts new file mode 100644 index 0000000000000..2996bd4c3e9fc --- /dev/null +++ b/src/core/server/saved_objects/import/create_saved_objects.test.ts @@ -0,0 +1,289 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { savedObjectsClientMock } from '../../mocks'; +import { createSavedObjects } from './create_saved_objects'; +import { SavedObjectsClientContract, SavedObject, SavedObjectsImportError } from '../types'; +import { SavedObjectsErrorHelpers } from '..'; +import { extractErrors } from './extract_errors'; + +type CreateSavedObjectsOptions = Parameters[2]; + +/** + * Function to create a realistic-looking import object given a type, ID, and optional originId + */ +const createObject = (type: string, id: string, originId?: string): SavedObject => ({ + type, + id, + 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 + ], + ...(originId && { originId }), +}); + +const MULTI_NS_TYPE = 'multi'; +const OTHER_TYPE = 'other'; +/** + * Create a variety of different objects to exercise different import / result scenarios + */ +const obj1 = createObject(MULTI_NS_TYPE, 'id-1', 'originId-a'); // -> success +const obj2 = createObject(MULTI_NS_TYPE, 'id-2', 'originId-b'); // -> conflict +const obj3 = createObject(MULTI_NS_TYPE, 'id-3', 'originId-c'); // -> conflict (with known importId and omitOriginId=true) +const obj4 = createObject(MULTI_NS_TYPE, 'id-4', 'originId-d'); // -> conflict (with known importId) +const obj5 = createObject(MULTI_NS_TYPE, 'id-5', 'originId-e'); // -> unresolvable conflict +const obj6 = createObject(MULTI_NS_TYPE, 'id-6'); // -> success +const obj7 = createObject(MULTI_NS_TYPE, 'id-7'); // -> conflict +const obj8 = createObject(MULTI_NS_TYPE, 'id-8'); // -> conflict (with known importId) +const obj9 = createObject(MULTI_NS_TYPE, 'id-9'); // -> unresolvable conflict +const obj10 = createObject(OTHER_TYPE, 'id-10', 'originId-f'); // -> success +const obj11 = createObject(OTHER_TYPE, 'id-11', 'originId-g'); // -> conflict +const obj12 = createObject(OTHER_TYPE, 'id-12'); // -> success +const obj13 = createObject(OTHER_TYPE, 'id-13'); // -> conflict +// non-multi-namespace types shouldn't have origin IDs, but we include test cases to ensure it's handled gracefully +// non-multi-namespace types by definition cannot result in an unresolvable conflict, so we don't include test cases for those +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 }], +]); + +describe('#createSavedObjects', () => { + let savedObjectsClient: jest.Mocked; + let bulkCreate: typeof savedObjectsClient['bulkCreate']; + + /** + * Creates an options object to be used as an argument for createSavedObjects + * Includes mock savedObjectsClient + */ + const setupOptions = ( + options: { + namespace?: string; + overwrite?: boolean; + } = {} + ): CreateSavedObjectsOptions => { + const { namespace, overwrite } = options; + savedObjectsClient = savedObjectsClientMock.create(); + bulkCreate = savedObjectsClient.bulkCreate; + return { savedObjectsClient, importIdMap, namespace, overwrite }; + }; + + const getExpectedBulkCreateArgsObjects = (objects: SavedObject[], retry?: boolean) => + objects.map(({ type, id, attributes, references, originId }) => ({ + type, + id: retry ? `new-id-for-${id}` : id, // if this was a retry, we regenerated the id -- this is mocked below + 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 + ], + // 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 }), + })); + + const expectBulkCreateArgs = { + objects: (n: number, objects: SavedObject[], retry?: boolean) => { + const expectedObjects = getExpectedBulkCreateArgsObjects(objects, retry); + const expectedOptions = expect.any(Object); + expect(bulkCreate).toHaveBeenNthCalledWith(n, expectedObjects, expectedOptions); + }, + options: (n: number, options: CreateSavedObjectsOptions) => { + const expectedObjects = expect.any(Array); + const expectedOptions = { namespace: options.namespace, overwrite: options.overwrite }; + expect(bulkCreate).toHaveBeenNthCalledWith(n, expectedObjects, expectedOptions); + }, + }; + + const getResultMock = { + success: ( + { type, id, attributes, references, originId }: SavedObject, + { namespace }: CreateSavedObjectsOptions + ): SavedObject => ({ + type, + id, + attributes, + references, + ...(originId && { originId }), + version: 'some-version', + updated_at: 'some-date', + namespaces: [namespace ?? 'default'], + }), + conflict: (type: string, id: string) => { + const error = SavedObjectsErrorHelpers.createConflictError(type, id).output.payload; + return ({ type, id, error } as unknown) as SavedObject; + }, + unresolvableConflict: (type: string, id: string) => { + const conflictMock = getResultMock.conflict(type, id); + conflictMock.error!.metadata = { isNotOverwritable: true }; + return conflictMock; + }, + }; + + /** + * Remap the bulkCreate results to ensure that each returned object reflects the ID of the imported object. + * This is needed because createSavedObjects may change the ID of the object to create, but this process is opaque to consumers of the + * API; we have to remap IDs of results so consumers can act upon them, as there is no guarantee that results will be returned in the same + * order as they were imported in. + * For the purposes of this test suite, the objects ARE guaranteed to be in the same order, so we do a simple loop to remap the IDs. + * In addition, extract the errors out of the created objects -- since we are testing with realistic objects/errors, we can use the real + * `extractErrors` module to do so. + */ + const getExpectedResults = (resultObjects: SavedObject[], objects: SavedObject[]) => { + const remappedResults = resultObjects.map((result, i) => ({ ...result, id: objects[i].id })); + return { + createdObjects: remappedResults.filter((obj) => !obj.error), + errors: extractErrors(remappedResults, objects), + }; + }; + + test('exits early if there are no objects to create', async () => { + const options = setupOptions(); + + const createSavedObjectsResult = await createSavedObjects([], [], options); + expect(bulkCreate).not.toHaveBeenCalled(); + expect(createSavedObjectsResult).toEqual({ createdObjects: [], errors: [] }); + }); + + const objs = [obj1, obj2, obj3, obj4, obj5, obj6, obj7, obj8, obj9, obj10, obj11, obj12, obj13]; + + const setupMockResults = (options: CreateSavedObjectsOptions) => { + bulkCreate.mockResolvedValue({ + saved_objects: [ + getResultMock.success(obj1, options), + getResultMock.conflict(obj2.type, obj2.id), + getResultMock.conflict(obj3.type, importId3), + getResultMock.conflict(obj4.type, importId4), + getResultMock.unresolvableConflict(obj5.type, obj5.id), + getResultMock.success(obj6, options), + getResultMock.conflict(obj7.type, obj7.id), + getResultMock.conflict(obj8.type, importId8), + getResultMock.unresolvableConflict(obj9.type, obj9.id), + getResultMock.success(obj10, options), + getResultMock.conflict(obj11.type, obj11.id), + getResultMock.success(obj12, options), + getResultMock.conflict(obj13.type, obj13.id), + ], + }); + }; + + describe('handles accumulated errors as expected', () => { + const resolvableErrors: SavedObjectsImportError[] = [ + { type: 'foo', id: 'foo-id', error: { type: 'conflict' } }, + { type: 'bar', id: 'bar-id', error: { type: 'ambiguous_conflict', destinations: [] } }, + { + type: 'baz', + id: 'baz-id', + error: { type: 'missing_references', references: [], blocking: [] }, + }, + ]; + const unresolvableErrors: SavedObjectsImportError[] = [ + { type: 'qux', id: 'qux-id', error: { type: 'unsupported_type' } }, + { type: 'quux', id: 'quux-id', error: { type: 'unknown', message: '', statusCode: 400 } }, + ]; + + test('does not call bulkCreate when resolvable errors are present', async () => { + for (const error of resolvableErrors) { + const options = setupOptions(); + await createSavedObjects(objs, [error], options); + expect(bulkCreate).not.toHaveBeenCalled(); + } + }); + + test('calls bulkCreate when unresolvable errors or no errors are present', async () => { + for (const error of unresolvableErrors) { + const options = setupOptions(); + setupMockResults(options); + await createSavedObjects(objs, [error], options); + expect(bulkCreate).toHaveBeenCalledTimes(1); + bulkCreate.mockClear(); + } + const options = setupOptions(); + setupMockResults(options); + await createSavedObjects(objs, [], options); + expect(bulkCreate).toHaveBeenCalledTimes(1); + }); + }); + + const testBulkCreateObjects = async (namespace?: string) => { + const options = setupOptions({ namespace }); + setupMockResults(options); + + await createSavedObjects(objs, [], options); + expect(bulkCreate).toHaveBeenCalledTimes(1); + // these three objects are transformed before being created, because they are included in the `importIdMap` + 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 + const argObjs = [obj1, obj2, x3, x4, obj5, obj6, obj7, x8, obj9, obj10, obj11, obj12, obj13]; + expectBulkCreateArgs.objects(1, argObjs); + }; + const testBulkCreateOptions = async (namespace?: string) => { + const overwrite = (Symbol() as unknown) as boolean; + const options = setupOptions({ namespace, overwrite }); + setupMockResults(options); + + await createSavedObjects(objs, [], options); + expect(bulkCreate).toHaveBeenCalledTimes(1); + expectBulkCreateArgs.options(1, options); + }; + const testReturnValue = async (namespace?: string) => { + const options = setupOptions({ namespace }); + setupMockResults(options); + + const results = await createSavedObjects(objs, [], options); + const resultSavedObjects = (await bulkCreate.mock.results[0].value).saved_objects; + const [r1, r2, r3, r4, r5, r6, r7, r8, r9, r10, r11, r12, r13] = resultSavedObjects; + // these three results are transformed before being returned, because the bulkCreate attempt used different IDs for them + const [x3, x4, x8] = [r3, r4, r8].map((x: SavedObject) => ({ ...x, destinationId: x.id })); + const transformedResults = [r1, r2, x3, x4, r5, r6, r7, x8, r9, r10, r11, r12, r13]; + const expectedResults = getExpectedResults(transformedResults, objs); + expect(results).toEqual(expectedResults); + }; + + describe('with an undefined namespace', () => { + test('calls bulkCreate once with input objects', async () => { + await testBulkCreateObjects(); + }); + test('calls bulkCreate once with input options', async () => { + await testBulkCreateOptions(); + }); + test('returns bulkCreate results that are remapped to IDs of imported objects', async () => { + await testReturnValue(); + }); + }); + + describe('with a defined namespace', () => { + const namespace = 'some-namespace'; + test('calls bulkCreate once with input objects', async () => { + await testBulkCreateObjects(namespace); + }); + test('calls bulkCreate once with input options', async () => { + await testBulkCreateOptions(namespace); + }); + test('returns bulkCreate results that are remapped to IDs of imported objects', async () => { + await testReturnValue(namespace); + }); + }); +}); diff --git a/src/core/server/saved_objects/import/create_saved_objects.ts b/src/core/server/saved_objects/import/create_saved_objects.ts new file mode 100644 index 0000000000000..53965ddef7857 --- /dev/null +++ b/src/core/server/saved_objects/import/create_saved_objects.ts @@ -0,0 +1,105 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SavedObject, SavedObjectsClientContract, SavedObjectsImportError } from '../types'; +import { extractErrors } from './extract_errors'; + +interface CreateSavedObjectsOptions { + savedObjectsClient: SavedObjectsClientContract; + importIdMap: Map; + namespace?: string; + overwrite?: boolean; +} +interface CreateSavedObjectsResult { + createdObjects: Array & { destinationId?: string }>; + errors: SavedObjectsImportError[]; +} + +/** + * This function abstracts the bulk creation of import objects. The main reason for this is that the import ID map should dictate the IDs of + * the objects we create, and the create results should be mapped to the original IDs that consumers will be able to understand. + */ +export const createSavedObjects = async ( + objects: Array>, + accumulatedErrors: SavedObjectsImportError[], + options: CreateSavedObjectsOptions +): Promise> => { + // exit early if there are no objects to create + if (objects.length === 0) { + return { createdObjects: [], errors: [] }; + } + + const { savedObjectsClient, importIdMap, namespace, overwrite } = options; + + // generate a map of the raw object IDs + const objectIdMap = objects.reduce( + (map, object) => map.set(`${object.type}:${object.id}`, object), + new Map>() + ); + + const objectsToCreate = objects + .map((object) => { + // 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) { + objectIdMap.set(`${object.type}:${importIdEntry.id}`, object); + const originId = importIdEntry.omitOriginId ? undefined : object.originId ?? object.id; + return { ...object, id: importIdEntry.id, originId }; + } + return object; + }) + .map((object) => { + // 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) { + return { ...reference, id: importIdEntry.id }; + } + return reference; + }); + return { ...object, ...(references && { references }) }; + }); + + const resolvableErrors = ['conflict', 'ambiguous_conflict', 'missing_references']; + let expectedResults = objectsToCreate; + if (!accumulatedErrors.some(({ error: { type } }) => resolvableErrors.includes(type))) { + const bulkCreateResponse = await savedObjectsClient.bulkCreate(objectsToCreate, { + namespace, + overwrite, + }); + expectedResults = bulkCreateResponse.saved_objects; + } + + // remap results to reflect the object IDs that were submitted for import + // this ensures that consumers understand the results + const remappedResults = expectedResults.map & { destinationId?: string }>( + (result) => { + const { id } = objectIdMap.get(`${result.type}:${result.id}`)!; + // also, include a `destinationId` field if the object create attempt was made with a different ID + return { ...result, id, ...(id !== result.id && { destinationId: result.id }) }; + } + ); + + return { + createdObjects: remappedResults.filter((obj) => !obj.error), + errors: extractErrors(remappedResults, objects), + }; +}; diff --git a/src/core/server/saved_objects/import/extract_errors.test.ts b/src/core/server/saved_objects/import/extract_errors.test.ts index f97cc661c0bca..1e061d9fb5055 100644 --- a/src/core/server/saved_objects/import/extract_errors.test.ts +++ b/src/core/server/saved_objects/import/extract_errors.test.ts @@ -19,6 +19,7 @@ import { SavedObject } from '../types'; import { extractErrors } from './extract_errors'; +import { SavedObjectsErrorHelpers } from '..'; describe('extractErrors()', () => { test('returns empty array when no errors exist', () => { @@ -28,7 +29,7 @@ describe('extractErrors()', () => { }); test('extracts errors from saved objects', () => { - const savedObjects: SavedObject[] = [ + const savedObjects: Array = [ { id: '1', type: 'dashboard', @@ -44,10 +45,7 @@ describe('extractErrors()', () => { title: 'My Dashboard 2', }, references: [], - error: { - statusCode: 409, - message: 'Conflict', - }, + error: SavedObjectsErrorHelpers.createConflictError('dashboard', '2').output.payload, }, { id: '3', @@ -56,10 +54,17 @@ describe('extractErrors()', () => { title: 'My Dashboard 3', }, references: [], - error: { - statusCode: 400, - message: 'Bad Request', + error: SavedObjectsErrorHelpers.createBadRequestError().output.payload, + }, + { + id: '4', + type: 'dashboard', + attributes: { + title: 'My Dashboard 4', }, + references: [], + error: SavedObjectsErrorHelpers.createConflictError('dashboard', '4').output.payload, + destinationId: 'foo', }, ]; const result = extractErrors(savedObjects, savedObjects); @@ -75,6 +80,7 @@ Array [ }, Object { "error": Object { + "error": "Bad Request", "message": "Bad Request", "statusCode": 400, "type": "unknown", @@ -83,6 +89,15 @@ Array [ "title": "My Dashboard 3", "type": "dashboard", }, + Object { + "error": Object { + "destinationId": "foo", + "type": "conflict", + }, + "id": "4", + "title": "My Dashboard 4", + "type": "dashboard", + }, ] `); }); diff --git a/src/core/server/saved_objects/import/extract_errors.ts b/src/core/server/saved_objects/import/extract_errors.ts index 5728ce8b7b59f..92196afc47ecd 100644 --- a/src/core/server/saved_objects/import/extract_errors.ts +++ b/src/core/server/saved_objects/import/extract_errors.ts @@ -21,7 +21,7 @@ import { SavedObjectsImportError } from './types'; export function extractErrors( // TODO: define saved object type - savedObjectResults: Array>, + savedObjectResults: Array & { destinationId?: string }>, savedObjectsToImport: Array> ) { const errors: SavedObjectsImportError[] = []; @@ -34,10 +34,8 @@ export function extractErrors( const originalSavedObject = originalSavedObjectsMap.get( `${savedObject.type}:${savedObject.id}` ); - const title = - originalSavedObject && - originalSavedObject.attributes && - originalSavedObject.attributes.title; + const title = originalSavedObject?.attributes?.title; + const { destinationId } = savedObject; if (savedObject.error.statusCode === 409) { errors.push({ id: savedObject.id, @@ -45,6 +43,7 @@ export function extractErrors( title, error: { type: 'conflict', + ...(destinationId && { destinationId }), }, }); continue; 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 e204cd7bddfc7..23271cc0248c3 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 @@ -18,602 +18,326 @@ */ import { Readable } from 'stream'; -import { SavedObject } from '../types'; -import { importSavedObjectsFromStream } from './import_saved_objects'; +import { v4 as uuidv4 } from 'uuid'; +import { + SavedObjectsClientContract, + SavedObjectsType, + SavedObject, + SavedObjectsImportError, +} from '../types'; import { savedObjectsClientMock } from '../../mocks'; +import { SavedObjectsImportOptions, ISavedObjectTypeRegistry } from '..'; +import { typeRegistryMock } from '../saved_objects_type_registry.mock'; +import { importSavedObjectsFromStream } from './import_saved_objects'; -const emptyResponse = { - saved_objects: [], - total: 0, - per_page: 0, - page: 0, -}; -describe('importSavedObjects()', () => { - const savedObjects: SavedObject[] = [ - { - id: '1', - type: 'index-pattern', - attributes: { - title: 'My Index Pattern', - }, - references: [], - }, - { - id: '2', - type: 'search', - attributes: { - title: 'My Search', - }, - references: [], - }, - { - id: '3', - type: 'visualization', - attributes: { - title: 'My Visualization', - }, - references: [], - }, - { - id: '4', - type: 'dashboard', - attributes: { - title: 'My Dashboard', - }, - references: [], - }, - ]; - const savedObjectsClient = savedObjectsClientMock.create(); +import { collectSavedObjects } from './collect_saved_objects'; +import { regenerateIds } from './regenerate_ids'; +import { validateReferences } from './validate_references'; +import { checkConflicts } from './check_conflicts'; +import { checkOriginConflicts } from './check_origin_conflicts'; +import { createSavedObjects } from './create_saved_objects'; - beforeEach(() => { - jest.resetAllMocks(); - }); +jest.mock('./collect_saved_objects'); +jest.mock('./regenerate_ids'); +jest.mock('./validate_references'); +jest.mock('./check_conflicts'); +jest.mock('./check_origin_conflicts'); +jest.mock('./create_saved_objects'); - test('returns early when no objects exist', async () => { - const readStream = new Readable({ - objectMode: true, - read() { - this.push(null); - }, - }); - const result = await importSavedObjectsFromStream({ - readStream, - objectLimit: 1, - overwrite: false, - savedObjectsClient, - supportedTypes: [], - }); - expect(result).toMatchInlineSnapshot(` - Object { - "success": true, - "successCount": 0, - } - `); - }); +const getMockFn = any, U>(fn: (...args: Parameters) => U) => + fn as jest.MockedFunction<(...args: Parameters) => U>; - test('calls bulkCreate without overwrite', async () => { - const readStream = new Readable({ - objectMode: true, - read() { - savedObjects.forEach((obj) => this.push(obj)); - this.push(null); - }, - }); - savedObjectsClient.find.mockResolvedValueOnce(emptyResponse); - savedObjectsClient.bulkCreate.mockResolvedValue({ - saved_objects: savedObjects, +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({ errors: [], collectedObjects: [] }); + getMockFn(regenerateIds).mockReturnValue({ importIdMap: new Map() }); + getMockFn(validateReferences).mockResolvedValue({ errors: [], filteredObjects: [] }); + getMockFn(checkConflicts).mockResolvedValue({ + errors: [], + filteredObjects: [], + importIdMap: new Map(), + importIds: new Set(), }); - const result = await importSavedObjectsFromStream({ - readStream, - objectLimit: 4, - overwrite: false, - savedObjectsClient, - supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'], + getMockFn(checkOriginConflicts).mockResolvedValue({ + errors: [], + filteredObjects: [], + importIdMap: new Map(), }); - expect(result).toMatchInlineSnapshot(` - Object { - "success": true, - "successCount": 4, - } - `); - expect(savedObjectsClient.bulkCreate).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "attributes": Object { - "title": "My Index Pattern", - }, - "id": "1", - "migrationVersion": Object {}, - "references": Array [], - "type": "index-pattern", - }, - Object { - "attributes": Object { - "title": "My Search", - }, - "id": "2", - "migrationVersion": Object {}, - "references": Array [], - "type": "search", - }, - Object { - "attributes": Object { - "title": "My Visualization", - }, - "id": "3", - "migrationVersion": Object {}, - "references": Array [], - "type": "visualization", - }, - Object { - "attributes": Object { - "title": "My Dashboard", - }, - "id": "4", - "migrationVersion": Object {}, - "references": Array [], - "type": "dashboard", - }, - ], - Object { - "namespace": undefined, - "overwrite": false, - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], - } - `); + getMockFn(createSavedObjects).mockResolvedValue({ errors: [], createdObjects: [] }); }); - test('uses the provided namespace when present', async () => { - const readStream = new Readable({ - objectMode: true, - read() { - savedObjects.forEach((obj) => this.push(obj)); - this.push(null); - }, - }); - savedObjectsClient.find.mockResolvedValueOnce(emptyResponse); - savedObjectsClient.bulkCreate.mockResolvedValue({ - saved_objects: savedObjects, - }); - const result = await importSavedObjectsFromStream({ - readStream, - objectLimit: 4, - overwrite: false, - savedObjectsClient, - supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'], - namespace: 'foo', - }); - expect(result).toMatchInlineSnapshot(` - Object { - "success": true, - "successCount": 4, - } - `); - expect(savedObjectsClient.bulkCreate).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "attributes": Object { - "title": "My Index Pattern", - }, - "id": "1", - "migrationVersion": Object {}, - "references": Array [], - "type": "index-pattern", - }, - Object { - "attributes": Object { - "title": "My Search", - }, - "id": "2", - "migrationVersion": Object {}, - "references": Array [], - "type": "search", - }, - Object { - "attributes": Object { - "title": "My Visualization", - }, - "id": "3", - "migrationVersion": Object {}, - "references": Array [], - "type": "visualization", - }, - Object { - "attributes": Object { - "title": "My Dashboard", - }, - "id": "4", - "migrationVersion": Object {}, - "references": Array [], - "type": "dashboard", - }, - ], - Object { - "namespace": "foo", - "overwrite": false, - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], - } - `); - }); + let readStream: Readable; + const objectLimit = 10; + const overwrite = (Symbol() as unknown) as boolean; + let savedObjectsClient: jest.Mocked; + let typeRegistry: jest.Mocked; + const namespace = 'some-namespace'; - test('calls bulkCreate with overwrite', async () => { - const readStream = new Readable({ - objectMode: true, - read() { - savedObjects.forEach((obj) => this.push(obj)); - this.push(null); - }, - }); - savedObjectsClient.find.mockResolvedValueOnce(emptyResponse); - savedObjectsClient.bulkCreate.mockResolvedValue({ - saved_objects: savedObjects, - }); - const result = await importSavedObjectsFromStream({ + const setupOptions = (trueCopy: boolean = false): SavedObjectsImportOptions => { + readStream = new Readable(); + savedObjectsClient = savedObjectsClientMock.create(); + typeRegistry = typeRegistryMock.create(); + return { readStream, - objectLimit: 4, - overwrite: true, + objectLimit, + overwrite, savedObjectsClient, - supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'], - }); - expect(result).toMatchInlineSnapshot(` - Object { - "success": true, - "successCount": 4, - } - `); - expect(savedObjectsClient.bulkCreate).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "attributes": Object { - "title": "My Index Pattern", - }, - "id": "1", - "migrationVersion": Object {}, - "references": Array [], - "type": "index-pattern", - }, - Object { - "attributes": Object { - "title": "My Search", - }, - "id": "2", - "migrationVersion": Object {}, - "references": Array [], - "type": "search", - }, - Object { - "attributes": Object { - "title": "My Visualization", - }, - "id": "3", - "migrationVersion": Object {}, - "references": Array [], - "type": "visualization", - }, - Object { - "attributes": Object { - "title": "My Dashboard", - }, - "id": "4", - "migrationVersion": Object {}, - "references": Array [], - "type": "dashboard", - }, - ], - Object { - "namespace": undefined, - "overwrite": true, - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], - } - `); - }); + typeRegistry, + namespace, + trueCopy, + }; + }; + const createObject = () => { + return ({ type: 'foo-type', id: uuidv4() } as unknown) as SavedObject<{ title: string }>; + }; + const createError = () => { + return ({ type: 'foo-type', id: uuidv4() } as unknown) as SavedObjectsImportError; + }; - test('extracts errors for conflicts', async () => { - const readStream = new Readable({ - objectMode: true, - read() { - savedObjects.forEach((obj) => this.push(obj)); - this.push(null); - }, - }); - savedObjectsClient.find.mockResolvedValueOnce(emptyResponse); - savedObjectsClient.bulkCreate.mockResolvedValue({ - saved_objects: savedObjects.map((savedObject) => ({ - type: savedObject.type, - id: savedObject.id, - error: { - statusCode: 409, - message: 'conflict', - }, - attributes: {}, - references: [], - })), + /** + * 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 + * `checkOriginConflicts`, 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', () => { + test('collects saved objects from stream', async () => { + const options = setupOptions(); + const supportedTypes = ['foo-type']; + typeRegistry.getImportableAndExportableTypes.mockReturnValue( + supportedTypes.map((name) => ({ name })) as SavedObjectsType[] + ); + + await importSavedObjectsFromStream(options); + expect(typeRegistry.getImportableAndExportableTypes).toHaveBeenCalled(); + const collectSavedObjectsOptions = { readStream, objectLimit, supportedTypes }; + expect(collectSavedObjects).toHaveBeenCalledWith(collectSavedObjectsOptions); }); - const result = await importSavedObjectsFromStream({ - readStream, - objectLimit: 4, - overwrite: false, - savedObjectsClient, - supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'], + + test('validates references', async () => { + const options = setupOptions(); + const collectedObjects = [createObject()]; + getMockFn(collectSavedObjects).mockResolvedValue({ errors: [], collectedObjects }); + + await importSavedObjectsFromStream(options); + expect(validateReferences).toHaveBeenCalledWith( + collectedObjects, + savedObjectsClient, + namespace + ); }); - expect(result).toMatchInlineSnapshot(` - Object { - "errors": Array [ - Object { - "error": Object { - "type": "conflict", - }, - "id": "1", - "title": "My Index Pattern", - "type": "index-pattern", - }, - Object { - "error": Object { - "type": "conflict", - }, - "id": "2", - "title": "My Search", - "type": "search", - }, - Object { - "error": Object { - "type": "conflict", - }, - "id": "3", - "title": "My Visualization", - "type": "visualization", - }, - Object { - "error": Object { - "type": "conflict", - }, - "id": "4", - "title": "My Dashboard", - "type": "dashboard", - }, - ], - "success": false, - "successCount": 0, - } - `); - }); - test('validates references', async () => { - const readStream = new Readable({ - objectMode: true, - read() { - this.push({ - id: '1', - type: 'search', - attributes: { - title: 'My Search', - }, - references: [ - { - name: 'ref_0', - type: 'index-pattern', - id: '2', - }, - ], + describe('with trueCopy disabled', () => { + test('does not regenerate object IDs', async () => { + const options = setupOptions(); + const collectedObjects = [createObject()]; + getMockFn(collectSavedObjects).mockResolvedValue({ errors: [], collectedObjects }); + + await importSavedObjectsFromStream(options); + expect(regenerateIds).not.toHaveBeenCalled(); + }); + + test('checks conflicts', async () => { + const options = setupOptions(); + const filteredObjects = [createObject()]; + getMockFn(validateReferences).mockResolvedValue({ errors: [], filteredObjects }); + + await importSavedObjectsFromStream(options); + const checkConflictsOptions = { + savedObjectsClient, + namespace, + ignoreRegularConflicts: overwrite, + }; + expect(checkConflicts).toHaveBeenCalledWith(filteredObjects, checkConflictsOptions); + }); + + test('checks origin conflicts', async () => { + const options = setupOptions(); + const filteredObjects = [createObject()]; + const importIds = new Set(); + getMockFn(checkConflicts).mockResolvedValue({ + errors: [], + filteredObjects, + importIdMap: new Map(), + importIds, + }); + + await importSavedObjectsFromStream(options); + const checkOriginConflictsOptions = { + savedObjectsClient, + typeRegistry, + namespace, + ignoreRegularConflicts: overwrite, + importIds, + }; + expect(checkOriginConflicts).toHaveBeenCalledWith( + filteredObjects, + checkOriginConflictsOptions + ); + }); + + test('creates saved objects', async () => { + const options = setupOptions(); + const filteredObjects = [createObject()]; + const errors = [createError(), createError(), createError(), createError()]; + getMockFn(collectSavedObjects).mockResolvedValue({ + errors: [errors[0]], + collectedObjects: [], // doesn't matter }); - this.push({ - id: '3', - type: 'visualization', - attributes: { - title: 'My Visualization', - }, - references: [ - { - name: 'ref_0', - type: 'search', - id: '1', - }, - ], + getMockFn(validateReferences).mockResolvedValue({ + errors: [errors[1]], + filteredObjects: [], // doesn't matter }); - this.push(null); - }, - }); - savedObjectsClient.bulkGet.mockResolvedValueOnce({ - saved_objects: [ - { - type: 'index-pattern', - id: '2', - error: { - statusCode: 404, - message: 'Not found', - }, - attributes: {}, - references: [], - }, - ], + getMockFn(checkConflicts).mockResolvedValue({ + errors: [errors[2]], + filteredObjects, + importIdMap: new Map().set(`id1`, { id: `newId1` }), + importIds: new Set(), + }); + getMockFn(checkOriginConflicts).mockResolvedValue({ + errors: [errors[3]], + filteredObjects, + importIdMap: new Map().set(`id2`, { id: `newId2` }), + }); + + await importSavedObjectsFromStream(options); + const importIdMap = new Map().set(`id1`, { id: `newId1` }).set(`id2`, { id: `newId2` }); + const createSavedObjectsOptions = { savedObjectsClient, importIdMap, overwrite, namespace }; + expect(createSavedObjects).toHaveBeenCalledWith( + filteredObjects, + errors, + createSavedObjectsOptions + ); + }); }); - const result = await importSavedObjectsFromStream({ - readStream, - objectLimit: 4, - overwrite: false, - savedObjectsClient, - supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'], + + describe('with trueCopy enabled', () => { + test('regenerates object IDs', async () => { + const options = setupOptions(true); + const collectedObjects = [createObject()]; + getMockFn(collectSavedObjects).mockResolvedValue({ errors: [], collectedObjects }); + + await importSavedObjectsFromStream(options); + expect(regenerateIds).toHaveBeenCalledWith(collectedObjects); + }); + + test('does not check conflicts or check origin conflicts', async () => { + const options = setupOptions(true); + const filteredObjects = [createObject()]; + getMockFn(validateReferences).mockResolvedValue({ errors: [], filteredObjects }); + + await importSavedObjectsFromStream(options); + expect(checkConflicts).not.toHaveBeenCalled(); + expect(checkOriginConflicts).not.toHaveBeenCalled(); + }); + + test('creates saved objects', async () => { + const options = setupOptions(true); + const filteredObjects = [createObject()]; + const errors = [createError(), createError()]; + getMockFn(collectSavedObjects).mockResolvedValue({ + errors: [errors[0]], + collectedObjects: [], // doesn't matter + }); + getMockFn(validateReferences).mockResolvedValue({ errors: [errors[1]], filteredObjects }); + const importIdMap = new Map().set(`id1`, { id: `newId1` }); + getMockFn(regenerateIds).mockReturnValue({ importIdMap }); + + await importSavedObjectsFromStream(options); + const createSavedObjectsOptions = { savedObjectsClient, importIdMap, overwrite, namespace }; + expect(createSavedObjects).toHaveBeenCalledWith( + filteredObjects, + errors, + createSavedObjectsOptions + ); + }); }); - expect(result).toMatchInlineSnapshot(` - Object { - "errors": Array [ - Object { - "error": Object { - "blocking": Array [ - Object { - "id": "3", - "type": "visualization", - }, - ], - "references": Array [ - Object { - "id": "2", - "type": "index-pattern", - }, - ], - "type": "missing_references", - }, - "id": "1", - "title": "My Search", - "type": "search", - }, - ], - "success": false, - "successCount": 0, - } - `); - expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "fields": Array [ - "id", - ], - "id": "2", - "type": "index-pattern", - }, - ], - Object { - "namespace": undefined, - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], - } - `); }); - test('validates supported types', async () => { - const readStream = new Readable({ - objectMode: true, - read() { - savedObjects.forEach((obj) => this.push(obj)); - this.push({ id: '1', type: 'wigwags', attributes: { title: 'my title' }, references: [] }); - this.push(null); - }, + describe('results', () => { + test('returns success=true if no errors occurred', async () => { + const options = setupOptions(); + + const result = await importSavedObjectsFromStream(options); + expect(result).toEqual({ success: true, successCount: 0 }); }); - savedObjectsClient.find.mockResolvedValueOnce(emptyResponse); - savedObjectsClient.bulkCreate.mockResolvedValue({ - saved_objects: savedObjects, + + test('returns success=false if an error occurred', async () => { + const options = setupOptions(); + const errors = [createError()]; + getMockFn(collectSavedObjects).mockResolvedValue({ errors, collectedObjects: [] }); + + const result = await importSavedObjectsFromStream(options); + expect(result).toEqual({ success: false, successCount: 0, errors }); }); - const result = await importSavedObjectsFromStream({ - readStream, - objectLimit: 5, - overwrite: false, - savedObjectsClient, - supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'], + + describe('handles a mix of successes and errors', () => { + const obj1 = createObject(); + const tmp = createObject(); + const obj2 = { ...tmp, destinationId: 'some-destinationId', originId: tmp.id }; + const obj3 = { ...createObject(), destinationId: 'another-destinationId' }; // empty originId + const createdObjects = [obj1, obj2, obj3]; + const errors = [createError()]; + + test('with trueCopy disabled', async () => { + const options = setupOptions(); + getMockFn(createSavedObjects).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) + const successResults = [ + { type: obj1.type, id: obj1.id }, + { type: obj2.type, id: obj2.id, destinationId: obj2.destinationId }, + // trueCopy mode is not enabled, but obj3 ran into an ambiguous source conflict and it was created with an empty originId; hence, + // this specific object is a true copy -- we would need this information for rendering the appropriate originId in the client UI, + // and we would need it to construct a retry for this object if other objects had errors that needed to be resolved + { type: obj3.type, id: obj3.id, destinationId: obj3.destinationId, trueCopy: true }, + ]; + expect(result).toEqual({ success: false, successCount: 3, successResults, errors }); + }); + + test('with trueCopy enabled', async () => { + // however, we include it here for posterity + const options = setupOptions(true); + getMockFn(createSavedObjects).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) + const successResults = [ + { type: obj1.type, id: obj1.id }, + // obj2 being created with trueCopy mode enabled isn't a realistic test case (all objects would have originId omitted) + { type: obj2.type, id: obj2.id, destinationId: obj2.destinationId }, + { type: obj3.type, id: obj3.id, destinationId: obj3.destinationId }, + ]; + expect(result).toEqual({ success: false, successCount: 3, successResults, errors }); + }); + }); + + test('accumulates multiple errors', async () => { + const options = setupOptions(); + const errors = [createError(), createError(), createError(), createError(), createError()]; + getMockFn(collectSavedObjects).mockResolvedValue({ + errors: [errors[0]], + collectedObjects: [], + }); + getMockFn(validateReferences).mockResolvedValue({ errors: [errors[1]], filteredObjects: [] }); + getMockFn(checkConflicts).mockResolvedValue({ + errors: [errors[2]], + filteredObjects: [], + importIdMap: new Map(), + importIds: new Set(), + }); + getMockFn(checkOriginConflicts).mockResolvedValue({ + errors: [errors[3]], + filteredObjects: [], + importIdMap: new Map(), + }); + getMockFn(createSavedObjects).mockResolvedValue({ errors: [errors[4]], createdObjects: [] }); + + const result = await importSavedObjectsFromStream(options); + expect(result).toEqual({ success: false, successCount: 0, errors }); }); - expect(result).toMatchInlineSnapshot(` - Object { - "errors": Array [ - Object { - "error": Object { - "type": "unsupported_type", - }, - "id": "1", - "title": "my title", - "type": "wigwags", - }, - ], - "success": false, - "successCount": 4, - } - `); - expect(savedObjectsClient.bulkCreate).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "attributes": Object { - "title": "My Index Pattern", - }, - "id": "1", - "migrationVersion": Object {}, - "references": Array [], - "type": "index-pattern", - }, - Object { - "attributes": Object { - "title": "My Search", - }, - "id": "2", - "migrationVersion": Object {}, - "references": Array [], - "type": "search", - }, - Object { - "attributes": Object { - "title": "My Visualization", - }, - "id": "3", - "migrationVersion": Object {}, - "references": Array [], - "type": "visualization", - }, - Object { - "attributes": Object { - "title": "My Dashboard", - }, - "id": "4", - "migrationVersion": Object {}, - "references": Array [], - "type": "dashboard", - }, - ], - Object { - "namespace": undefined, - "overwrite": false, - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], - } - `); }); }); 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 6065e03fb1628..bd18513c8d10d 100644 --- a/src/core/server/saved_objects/import/import_saved_objects.ts +++ b/src/core/server/saved_objects/import/import_saved_objects.ts @@ -18,13 +18,16 @@ */ import { collectSavedObjects } from './collect_saved_objects'; -import { extractErrors } from './extract_errors'; import { SavedObjectsImportError, SavedObjectsImportResponse, SavedObjectsImportOptions, } from './types'; import { validateReferences } from './validate_references'; +import { checkOriginConflicts } from './check_origin_conflicts'; +import { createSavedObjects } from './create_saved_objects'; +import { checkConflicts } from './check_conflicts'; +import { regenerateIds } from './regenerate_ids'; /** * Import saved objects from given stream. See the {@link SavedObjectsImportOptions | options} for more @@ -36,11 +39,14 @@ export async function importSavedObjectsFromStream({ readStream, objectLimit, overwrite, + trueCopy, savedObjectsClient, - supportedTypes, + typeRegistry, namespace, }: SavedObjectsImportOptions): Promise { let errorAccumulator: SavedObjectsImportError[] = []; + let importIdMap: Map = new Map(); + const supportedTypes = typeRegistry.getImportableAndExportableTypes().map((type) => type.name); // Get the objects to import const { @@ -50,35 +56,72 @@ export async function importSavedObjectsFromStream({ errorAccumulator = [...errorAccumulator, ...collectorErrors]; // Validate references - const { filteredObjects, errors: validationErrors } = await validateReferences( + const validateReferencesResult = await validateReferences( objectsFromStream, savedObjectsClient, namespace ); - errorAccumulator = [...errorAccumulator, ...validationErrors]; + errorAccumulator = [...errorAccumulator, ...validateReferencesResult.errors]; - // Exit early if no objects to import - if (filteredObjects.length === 0) { - return { - success: errorAccumulator.length === 0, - successCount: 0, - ...(errorAccumulator.length ? { errors: errorAccumulator } : {}), + let objectsToCreate = validateReferencesResult.filteredObjects; + if (trueCopy) { + const regenerateIdsResult = regenerateIds(objectsFromStream); + importIdMap = regenerateIdsResult.importIdMap; + } else { + // Check single-namespace objects for conflicts in this namespace, and check multi-namespace objects for conflicts across all namespaces + const checkConflictsOptions = { + savedObjectsClient, + namespace, + ignoreRegularConflicts: overwrite, }; + const checkConflictsResult = await checkConflicts( + validateReferencesResult.filteredObjects, + checkConflictsOptions + ); + errorAccumulator = [...errorAccumulator, ...checkConflictsResult.errors]; + importIdMap = new Map([...importIdMap, ...checkConflictsResult.importIdMap]); + + // Check multi-namespace object types for origin conflicts in this namespace + const checkOriginConflictsOptions = { + savedObjectsClient, + typeRegistry, + namespace, + ignoreRegularConflicts: overwrite, + importIds: checkConflictsResult.importIds, + }; + const checkOriginConflictsResult = await checkOriginConflicts( + checkConflictsResult.filteredObjects, + checkOriginConflictsOptions + ); + errorAccumulator = [...errorAccumulator, ...checkOriginConflictsResult.errors]; + importIdMap = new Map([...importIdMap, ...checkOriginConflictsResult.importIdMap]); + objectsToCreate = checkOriginConflictsResult.filteredObjects; } // Create objects in bulk - const bulkCreateResult = await savedObjectsClient.bulkCreate(filteredObjects, { - overwrite, - namespace, - }); - errorAccumulator = [ - ...errorAccumulator, - ...extractErrors(bulkCreateResult.saved_objects, filteredObjects), - ]; + const createSavedObjectsOptions = { savedObjectsClient, importIdMap, overwrite, namespace }; + const createSavedObjectsResult = await createSavedObjects( + objectsToCreate, + errorAccumulator, + createSavedObjectsOptions + ); + errorAccumulator = [...errorAccumulator, ...createSavedObjectsResult.errors]; + + const successResults = createSavedObjectsResult.createdObjects.map( + ({ type, id, destinationId, originId }) => { + return { + type, + id, + ...(destinationId && { destinationId }), + ...(destinationId && !originId && !trueCopy && { trueCopy: true }), + }; + } + ); return { + successCount: createSavedObjectsResult.createdObjects.length, success: errorAccumulator.length === 0, - successCount: bulkCreateResult.saved_objects.filter((obj) => !obj.error).length, - ...(errorAccumulator.length ? { errors: errorAccumulator } : {}), + ...(successResults.length && { successResults }), + ...(errorAccumulator.length && { errors: errorAccumulator }), }; } diff --git a/src/core/server/saved_objects/import/index.ts b/src/core/server/saved_objects/import/index.ts index e268e970b94ac..ab69e4fc44197 100644 --- a/src/core/server/saved_objects/import/index.ts +++ b/src/core/server/saved_objects/import/index.ts @@ -21,9 +21,11 @@ export { importSavedObjectsFromStream } from './import_saved_objects'; export { resolveSavedObjectsImportErrors } from './resolve_import_errors'; export { SavedObjectsImportResponse, + SavedObjectsImportSuccess, SavedObjectsImportError, SavedObjectsImportOptions, SavedObjectsImportConflictError, + SavedObjectsImportAmbiguousConflictError, SavedObjectsImportMissingReferencesError, SavedObjectsImportUnknownError, SavedObjectsImportUnsupportedTypeError, diff --git a/src/core/server/saved_objects/import/regenerate_ids.test.ts b/src/core/server/saved_objects/import/regenerate_ids.test.ts new file mode 100644 index 0000000000000..4a69f45dd52e5 --- /dev/null +++ b/src/core/server/saved_objects/import/regenerate_ids.test.ts @@ -0,0 +1,52 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { mockUuidv4 } from './__mocks__'; +import { regenerateIds } from './regenerate_ids'; +import { SavedObject } from '../types'; + +describe('#regenerateIds', () => { + const objects = ([ + { type: 'foo', id: '1' }, + { type: 'bar', id: '2' }, + { type: 'baz', id: '3' }, + ] as any) as Array>; + + test('returns expected values', () => { + expect(regenerateIds(objects)).toMatchInlineSnapshot(` + Object { + "importIdMap": Map { + "foo:1" => Object { + "id": "uuidv4", + "omitOriginId": true, + }, + "bar:2" => Object { + "id": "uuidv4", + "omitOriginId": true, + }, + "baz:3" => Object { + "id": "uuidv4", + "omitOriginId": true, + }, + }, + } + `); + expect(mockUuidv4).toHaveBeenCalledTimes(3); + }); +}); diff --git a/src/core/server/saved_objects/import/regenerate_ids.ts b/src/core/server/saved_objects/import/regenerate_ids.ts new file mode 100644 index 0000000000000..01e305785ef01 --- /dev/null +++ b/src/core/server/saved_objects/import/regenerate_ids.ts @@ -0,0 +1,33 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { v4 as uuidv4 } from 'uuid'; +import { SavedObject } from '../types'; + +/** + * Takes an array of saved objects and returns an importIdMap of randomly-generated new IDs. + * + * @param objects The saved objects to generate new IDs for. + */ +export const regenerateIds = (objects: Array>) => { + const importIdMap = objects.reduce((acc, object) => { + return acc.set(`${object.type}:${object.id}`, { id: uuidv4(), omitOriginId: true }); + }, new Map()); + return { importIdMap }; +}; 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 54ebecc7dca70..3f82f528159d8 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 @@ -18,567 +18,326 @@ */ import { Readable } from 'stream'; -import { SavedObject } from '../types'; -import { resolveSavedObjectsImportErrors } from './resolve_import_errors'; +import { v4 as uuidv4 } from 'uuid'; +import { + SavedObjectsClientContract, + SavedObjectsType, + SavedObject, + SavedObjectsImportError, + SavedObjectsImportRetry, + SavedObjectReference, +} from '../types'; import { savedObjectsClientMock } from '../../mocks'; +import { SavedObjectsResolveImportErrorsOptions, ISavedObjectTypeRegistry } from '..'; +import { typeRegistryMock } from '../saved_objects_type_registry.mock'; +import { resolveSavedObjectsImportErrors } from './resolve_import_errors'; -describe('resolveImportErrors()', () => { - const savedObjects: SavedObject[] = [ - { - id: '1', - type: 'index-pattern', - attributes: { - title: 'My Index Pattern', - }, - references: [], - }, - { - id: '2', - type: 'search', - attributes: { - title: 'My Search', - }, - references: [], - }, - { - id: '3', - type: 'visualization', - attributes: { - title: 'My Visualization', - }, - references: [], - }, - { - id: '4', - type: 'dashboard', - attributes: { - title: 'My Dashboard', - }, - references: [ - { - name: 'panel_0', - type: 'visualization', - id: '3', - }, - ], - }, - ]; - const savedObjectsClient = savedObjectsClientMock.create(); +import { validateRetries } from './validate_retries'; +import { collectSavedObjects } from './collect_saved_objects'; +import { validateReferences } from './validate_references'; +import { checkConflicts } from './check_conflicts'; +import { getImportIdMapForRetries } from './check_origin_conflicts'; +import { splitOverwrites } from './split_overwrites'; +import { createSavedObjects } from './create_saved_objects'; +import { createObjectsFilter } from './create_objects_filter'; - beforeEach(() => { - jest.resetAllMocks(); - }); +jest.mock('./validate_retries'); +jest.mock('./create_objects_filter'); +jest.mock('./collect_saved_objects'); +jest.mock('./validate_references'); +jest.mock('./check_conflicts'); +jest.mock('./check_origin_conflicts'); +jest.mock('./split_overwrites'); +jest.mock('./create_saved_objects'); - test('works with empty parameters', async () => { - const readStream = new Readable({ - objectMode: true, - read() { - savedObjects.forEach((obj) => this.push(obj)); - this.push(null); - }, - }); - savedObjectsClient.bulkCreate.mockResolvedValue({ - saved_objects: [], - }); - const result = await resolveSavedObjectsImportErrors({ - readStream, - objectLimit: 4, - retries: [], - savedObjectsClient, - supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'], - }); - expect(result).toMatchInlineSnapshot(` - Object { - "success": true, - "successCount": 0, - } - `); - expect(savedObjectsClient.bulkCreate).toMatchInlineSnapshot(`[MockFunction]`); - }); +const getMockFn = any, U>(fn: (...args: Parameters) => U) => + fn as jest.MockedFunction<(...args: Parameters) => U>; - test('works with retries', async () => { - const readStream = new Readable({ - objectMode: true, - read() { - savedObjects.forEach((obj) => this.push(obj)); - this.push(null); - }, - }); - savedObjectsClient.bulkCreate.mockResolvedValueOnce({ - saved_objects: savedObjects.filter((obj) => obj.type === 'visualization' && obj.id === '3'), +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({ errors: [], collectedObjects: [] }); + getMockFn(validateReferences).mockResolvedValue({ errors: [], filteredObjects: [] }); + getMockFn(checkConflicts).mockResolvedValue({ + errors: [], + filteredObjects: [], + importIdMap: new Map(), + importIds: new Set(), }); - const result = await resolveSavedObjectsImportErrors({ - readStream, - objectLimit: 4, - retries: [ - { - type: 'visualization', - id: '3', - replaceReferences: [], - overwrite: false, - }, - ], - savedObjectsClient, - supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'], + getMockFn(getImportIdMapForRetries).mockReturnValue(new Map()); + getMockFn(splitOverwrites).mockReturnValue({ + objectsToOverwrite: [], + objectsToNotOverwrite: [], }); - expect(result).toMatchInlineSnapshot(` - Object { - "success": true, - "successCount": 1, - } - `); - expect(savedObjectsClient.bulkCreate).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "attributes": Object { - "title": "My Visualization", - }, - "id": "3", - "migrationVersion": Object {}, - "references": Array [], - "type": "visualization", - }, - ], - Object { - "namespace": undefined, - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], - } - `); + getMockFn(createSavedObjects).mockResolvedValue({ errors: [], createdObjects: [] }); }); - test('works with overwrites', async () => { - const readStream = new Readable({ - objectMode: true, - read() { - savedObjects.forEach((obj) => this.push(obj)); - this.push(null); - }, - }); - savedObjectsClient.bulkCreate.mockResolvedValue({ - saved_objects: savedObjects.filter((obj) => obj.type === 'index-pattern' && obj.id === '1'), - }); - const result = await resolveSavedObjectsImportErrors({ + let readStream: Readable; + const objectLimit = 10; + let savedObjectsClient: jest.Mocked; + let typeRegistry: jest.Mocked; + const namespace = 'some-namespace'; + const trueCopy = false; + + const setupOptions = ( + retries: SavedObjectsImportRetry[] = [] + ): SavedObjectsResolveImportErrorsOptions => { + readStream = new Readable(); + savedObjectsClient = savedObjectsClientMock.create(); + typeRegistry = typeRegistryMock.create(); + return { readStream, - objectLimit: 4, - retries: [ - { - type: 'index-pattern', - id: '1', - overwrite: true, - replaceReferences: [], - }, - ], + objectLimit, + retries, savedObjectsClient, - supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'], - }); - expect(result).toMatchInlineSnapshot(` - Object { - "success": true, - "successCount": 1, - } - `); - expect(savedObjectsClient.bulkCreate).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "attributes": Object { - "title": "My Index Pattern", - }, - "id": "1", - "migrationVersion": Object {}, - "references": Array [], - "type": "index-pattern", - }, - ], - Object { - "namespace": undefined, - "overwrite": true, - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], - } - `); - }); + typeRegistry, + // namespace and trueCopy don't matter, as they don't change the logic in this module, they just get passed to sub-module methods + namespace, + trueCopy, + }; + }; + + const createRetry = (options?: { + id?: string; + overwrite?: boolean; + replaceReferences?: SavedObjectsImportRetry['replaceReferences']; + }) => { + const { id = uuidv4(), overwrite = false, replaceReferences = [] } = options ?? {}; + return { type: 'foo-type', id, overwrite, replaceReferences }; + }; + const createObject = (references?: SavedObjectReference[]) => { + return ({ type: 'foo-type', id: uuidv4(), references } as unknown) as SavedObject<{ + title: string; + }>; + }; + const createError = () => { + return ({ type: 'foo-type', id: uuidv4() } as unknown) as SavedObjectsImportError; + }; + + /** + * 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 + * intermediate steps in the interest of brevity. + */ + describe('module calls', () => { + test('validates retries', async () => { + const retry = createRetry(); + const options = setupOptions([retry]); - test('works wtih replaceReferences', async () => { - const readStream = new Readable({ - objectMode: true, - read() { - savedObjects.forEach((obj) => this.push(obj)); - this.push(null); - }, + await resolveSavedObjectsImportErrors(options); + expect(validateRetries).toHaveBeenCalledWith([retry]); }); - savedObjectsClient.bulkCreate.mockResolvedValue({ - saved_objects: savedObjects.filter((obj) => obj.type === 'dashboard' && obj.id === '4'), + + test('creates objects filter', async () => { + const retry = createRetry(); + const options = setupOptions([retry]); + + await resolveSavedObjectsImportErrors(options); + expect(createObjectsFilter).toHaveBeenCalledWith([retry]); }); - const result = await resolveSavedObjectsImportErrors({ - readStream, - objectLimit: 4, - retries: [ - { - type: 'dashboard', - id: '4', - overwrite: false, - replaceReferences: [ - { - type: 'visualization', - from: '3', - to: '13', - }, - ], - }, - ], - savedObjectsClient, - supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'], + + test('collects saved objects from stream', async () => { + const options = setupOptions(); + const supportedTypes = ['foo']; + typeRegistry.getImportableAndExportableTypes.mockReturnValue( + supportedTypes.map((name) => ({ name })) as SavedObjectsType[] + ); + + await resolveSavedObjectsImportErrors(options); + expect(typeRegistry.getImportableAndExportableTypes).toHaveBeenCalled(); + // expect(createObjectsFilter).toHaveBeenCalled(); + const filter = getMockFn(createObjectsFilter).mock.results[0].value; + const collectSavedObjectsOptions = { readStream, objectLimit, filter, supportedTypes }; + expect(collectSavedObjects).toHaveBeenCalledWith(collectSavedObjectsOptions); }); - expect(result).toMatchInlineSnapshot(` - Object { - "success": true, - "successCount": 1, - } - `); - expect(savedObjectsClient.bulkCreate).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "attributes": Object { - "title": "My Dashboard", - }, - "id": "4", - "migrationVersion": Object {}, - "references": Array [ - Object { - "id": "13", - "name": "panel_0", - "type": "visualization", - }, - ], - "type": "dashboard", - }, - ], - Object { - "namespace": undefined, - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], - } - `); - }); - test('extracts errors for conflicts', async () => { - const readStream = new Readable({ - objectMode: true, - read() { - savedObjects.forEach((obj) => this.push(obj)); - this.push(null); - }, + test('validates references', async () => { + const options = setupOptions(); + const collectedObjects = [createObject()]; + getMockFn(collectSavedObjects).mockResolvedValue({ errors: [], collectedObjects }); + + await resolveSavedObjectsImportErrors(options); + expect(validateReferences).toHaveBeenCalledWith( + collectedObjects, + savedObjectsClient, + namespace + ); }); - savedObjectsClient.bulkCreate.mockResolvedValue({ - saved_objects: savedObjects.map((savedObject) => ({ - type: savedObject.type, - id: savedObject.id, - error: { - statusCode: 409, - message: 'conflict', - }, - attributes: {}, - references: [], - })), + + test('uses `retries` to replace references of collected objects before validating', async () => { + const object = createObject([{ type: 'bar-type', id: 'abc', name: 'some name' }]); + const retry = createRetry({ + id: object.id, + replaceReferences: [{ type: 'bar-type', from: 'abc', to: 'def' }], + }); + const options = setupOptions([retry]); + getMockFn(collectSavedObjects).mockResolvedValue({ errors: [], collectedObjects: [object] }); + + await resolveSavedObjectsImportErrors(options); + const objectWithReplacedReferences = { + ...object, + references: [{ ...object.references[0], id: 'def' }], + }; + expect(validateReferences).toHaveBeenCalledWith( + [objectWithReplacedReferences], + savedObjectsClient, + namespace + ); }); - const result = await resolveSavedObjectsImportErrors({ - readStream, - objectLimit: 4, - retries: savedObjects.map((obj) => ({ - type: obj.type, - id: obj.id, - overwrite: false, - replaceReferences: [], - })), - savedObjectsClient, - supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'], + + test('checks conflicts', async () => { + const options = setupOptions(); + const filteredObjects = [createObject()]; + getMockFn(validateReferences).mockResolvedValue({ errors: [], filteredObjects }); + + await resolveSavedObjectsImportErrors(options); + const checkConflictsOptions = { + savedObjectsClient, + namespace, + ignoreRegularConflicts: true, + trueCopy, + }; + expect(checkConflicts).toHaveBeenCalledWith(filteredObjects, checkConflictsOptions); }); - expect(result).toMatchInlineSnapshot(` - Object { - "errors": Array [ - Object { - "error": Object { - "type": "conflict", - }, - "id": "1", - "title": "My Index Pattern", - "type": "index-pattern", - }, - Object { - "error": Object { - "type": "conflict", - }, - "id": "2", - "title": "My Search", - "type": "search", - }, - Object { - "error": Object { - "type": "conflict", - }, - "id": "3", - "title": "My Visualization", - "type": "visualization", - }, - Object { - "error": Object { - "type": "conflict", - }, - "id": "4", - "title": "My Dashboard", - "type": "dashboard", - }, - ], - "success": false, - "successCount": 0, - } - `); - }); - test('validates references', async () => { - const readStream = new Readable({ - objectMode: true, - read() { - this.push({ - id: '1', - type: 'search', - attributes: { - title: 'My Search', - }, - references: [ - { - name: 'ref_0', - type: 'index-pattern', - id: '2', - }, - ], - }); - this.push({ - id: '3', - type: 'visualization', - attributes: { - title: 'My Visualization', - }, - references: [ - { - name: 'ref_0', - type: 'search', - id: '1', - }, - ], - }); - this.push(null); - }, + test('gets import ID map for retries', async () => { + const retries = [createRetry()]; + const options = setupOptions(retries); + const filteredObjects = [createObject()]; + getMockFn(checkConflicts).mockResolvedValue({ + errors: [], + filteredObjects, + importIdMap: new Map(), + importIds: new Set(), + }); + + await resolveSavedObjectsImportErrors(options); + const opts = { retries, trueCopy }; + expect(getImportIdMapForRetries).toHaveBeenCalledWith(filteredObjects, opts); }); - savedObjectsClient.bulkGet.mockResolvedValueOnce({ - saved_objects: [ - { - type: 'index-pattern', - id: '2', - error: { - statusCode: 404, - message: 'Not found', - }, - attributes: {}, - references: [], - }, - ], + + test('splits objects to ovewrite from those not to overwrite', async () => { + const retries = [createRetry()]; + const options = setupOptions(retries); + const filteredObjects = [createObject()]; + getMockFn(checkConflicts).mockResolvedValue({ + errors: [], + filteredObjects, + importIdMap: new Map(), + importIds: new Set(), + }); + + await resolveSavedObjectsImportErrors(options); + expect(splitOverwrites).toHaveBeenCalledWith(filteredObjects, retries); }); - const result = await resolveSavedObjectsImportErrors({ - readStream, - objectLimit: 2, - retries: [ - { - type: 'search', - id: '1', - overwrite: false, - replaceReferences: [], - }, - { - type: 'visualization', - id: '3', - overwrite: false, - replaceReferences: [], - }, - ], - savedObjectsClient, - supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'], + + test('creates saved objects', async () => { + const options = setupOptions(); + const errors = [createError(), createError(), createError()]; + getMockFn(collectSavedObjects).mockResolvedValue({ + errors: [errors[0]], + collectedObjects: [], // doesn't matter + }); + getMockFn(validateReferences).mockResolvedValue({ + errors: [errors[1]], + filteredObjects: [], // doesn't matter + }); + getMockFn(checkConflicts).mockResolvedValue({ + errors: [errors[2]], + filteredObjects: [], + importIdMap: new Map().set(`id1`, { id: `newId1` }), + importIds: new Set(), + }); + getMockFn(getImportIdMapForRetries).mockReturnValue(new Map().set(`id2`, { id: `newId2` })); + const importIdMap = new Map().set(`id1`, { id: `newId1` }).set(`id2`, { id: `newId2` }); + 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 + createdObjects: [], + }); + + await resolveSavedObjectsImportErrors(options); + const createSavedObjectsOptions = { savedObjectsClient, importIdMap, namespace }; + expect(createSavedObjects).toHaveBeenNthCalledWith(1, objectsToOverwrite, errors, { + ...createSavedObjectsOptions, + overwrite: true, + }); + expect(createSavedObjects).toHaveBeenNthCalledWith( + 2, + objectsToNotOverwrite, + errors, + createSavedObjectsOptions + ); }); - expect(result).toMatchInlineSnapshot(` - Object { - "errors": Array [ - Object { - "error": Object { - "blocking": Array [ - Object { - "id": "3", - "type": "visualization", - }, - ], - "references": Array [ - Object { - "id": "2", - "type": "index-pattern", - }, - ], - "type": "missing_references", - }, - "id": "1", - "title": "My Search", - "type": "search", - }, - ], - "success": false, - "successCount": 0, - } - `); - expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "fields": Array [ - "id", - ], - "id": "2", - "type": "index-pattern", - }, - ], - Object { - "namespace": undefined, - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], - } - `); }); - test('validates object types', async () => { - const readStream = new Readable({ - objectMode: true, - read() { - savedObjects.forEach((obj) => this.push(obj)); - this.push({ id: '1', type: 'wigwags', attributes: { title: 'my title' }, references: [] }); - this.push(null); - }, - }); - savedObjectsClient.bulkCreate.mockResolvedValue({ - saved_objects: [], - }); - const result = await resolveSavedObjectsImportErrors({ - readStream, - objectLimit: 5, - retries: [ - { - id: 'i', - type: 'wigwags', - overwrite: false, - replaceReferences: [], - }, - ], - savedObjectsClient, - supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'], + describe('results', () => { + test('returns success=true if no errors occurred', async () => { + const options = setupOptions(); + + const result = await resolveSavedObjectsImportErrors(options); + expect(result).toEqual({ success: true, successCount: 0 }); }); - expect(result).toMatchInlineSnapshot(` - Object { - "errors": Array [ - Object { - "error": Object { - "type": "unsupported_type", - }, - "id": "1", - "title": "my title", - "type": "wigwags", - }, - ], - "success": false, - "successCount": 0, - } - `); - expect(savedObjectsClient.bulkCreate).toMatchInlineSnapshot(`[MockFunction]`); - }); - test('uses namespace when provided', async () => { - const readStream = new Readable({ - objectMode: true, - read() { - savedObjects.forEach((obj) => this.push(obj)); - this.push(null); - }, + test('returns success=false if an error occurred', async () => { + const options = setupOptions(); + const errors = [createError()]; + getMockFn(collectSavedObjects).mockResolvedValue({ errors, collectedObjects: [] }); + + const result = await resolveSavedObjectsImportErrors(options); + expect(result).toEqual({ success: false, successCount: 0, errors }); }); - savedObjectsClient.bulkCreate.mockResolvedValue({ - saved_objects: savedObjects.filter((obj) => obj.type === 'index-pattern' && obj.id === '1'), + + test('handles a mix of successes and errors', async () => { + const options = setupOptions(); + const errors = [createError()]; + const obj1 = createObject(); + const tmp = createObject(); + const obj2 = { ...tmp, destinationId: 'some-destinationId', originId: tmp.id }; + const obj3 = { ...createObject(), destinationId: 'another-destinationId' }; // empty originId; this is a true copy + getMockFn(createSavedObjects).mockResolvedValueOnce({ + errors, + createdObjects: [obj1], + }); + getMockFn(createSavedObjects).mockResolvedValueOnce({ + errors: [], + createdObjects: [obj2, obj3], + }); + + const result = await resolveSavedObjectsImportErrors(options); + // successResults only includes the imported object's type, id, and destinationId (if a new one was generated) + const successResults = [ + { type: obj1.type, id: obj1.id }, + { type: obj2.type, id: obj2.id, destinationId: obj2.destinationId }, + { type: obj3.type, id: obj3.id, destinationId: obj3.destinationId, trueCopy: true }, + ]; + expect(result).toEqual({ success: false, successCount: 3, successResults, errors }); }); - const result = await resolveSavedObjectsImportErrors({ - readStream, - objectLimit: 4, - retries: [ - { - type: 'index-pattern', - id: '1', - overwrite: true, - replaceReferences: [], - }, - ], - savedObjectsClient, - supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'], - namespace: 'foo', + + test('accumulates multiple errors', async () => { + const options = setupOptions(); + const errors = [createError(), createError(), createError(), createError()]; + getMockFn(collectSavedObjects).mockResolvedValue({ + errors: [errors[0]], + collectedObjects: [], + }); + getMockFn(validateReferences).mockResolvedValue({ errors: [errors[1]], filteredObjects: [] }); + getMockFn(createSavedObjects).mockResolvedValueOnce({ + errors: [errors[2]], + createdObjects: [], + }); + getMockFn(createSavedObjects).mockResolvedValueOnce({ + errors: [errors[3]], + createdObjects: [], + }); + + const result = await resolveSavedObjectsImportErrors(options); + expect(result).toEqual({ success: false, successCount: 0, errors }); }); - expect(result).toMatchInlineSnapshot(` - Object { - "success": true, - "successCount": 1, - } - `); - expect(savedObjectsClient.bulkCreate).toHaveBeenCalledWith( - [ - { - attributes: { title: 'My Index Pattern' }, - id: '1', - migrationVersion: {}, - references: [], - type: 'index-pattern', - }, - ], - { namespace: 'foo', overwrite: true } - ); }); }); 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 a5175aa080598..2592fa8a28a6d 100644 --- a/src/core/server/saved_objects/import/resolve_import_errors.ts +++ b/src/core/server/saved_objects/import/resolve_import_errors.ts @@ -18,7 +18,6 @@ */ import { collectSavedObjects } from './collect_saved_objects'; import { createObjectsFilter } from './create_objects_filter'; -import { extractErrors } from './extract_errors'; import { splitOverwrites } from './split_overwrites'; import { SavedObjectsImportError, @@ -26,6 +25,11 @@ import { SavedObjectsResolveImportErrorsOptions, } from './types'; import { validateReferences } from './validate_references'; +import { validateRetries } from './validate_retries'; +import { createSavedObjects } from './create_saved_objects'; +import { getImportIdMapForRetries } from './check_origin_conflicts'; +import { SavedObject } from '../types'; +import { checkConflicts } from './check_conflicts'; /** * Resolve and return saved object import errors. @@ -38,11 +42,17 @@ export async function resolveSavedObjectsImportErrors({ objectLimit, retries, savedObjectsClient, - supportedTypes, + typeRegistry, namespace, + trueCopy, }: SavedObjectsResolveImportErrorsOptions): Promise { + // throw a BadRequest error if we see invalid retries + validateRetries(retries); + let successCount = 0; let errorAccumulator: SavedObjectsImportError[] = []; + let importIdMap: Map = new Map(); + const supportedTypes = typeRegistry.getImportableAndExportableTypes().map((type) => type.name); const filter = createObjectsFilter(retries); // Get the objects to resolve errors @@ -81,40 +91,67 @@ export async function resolveSavedObjectsImportErrors({ } // Validate references - const { filteredObjects, errors: validationErrors } = await validateReferences( + const validateReferencesResult = await validateReferences( objectsToResolve, savedObjectsClient, namespace ); - errorAccumulator = [...errorAccumulator, ...validationErrors]; + errorAccumulator = [...errorAccumulator, ...validateReferencesResult.errors]; + + // Check single-namespace objects for conflicts in this namespace, and check multi-namespace objects for conflicts across all namespaces + const checkConflictsOptions = { + savedObjectsClient, + namespace, + ignoreRegularConflicts: true, + trueCopy, + }; + const checkConflictsResult = await checkConflicts( + validateReferencesResult.filteredObjects, + checkConflictsOptions + ); + errorAccumulator = [...errorAccumulator, ...checkConflictsResult.errors]; + importIdMap = new Map([...importIdMap, ...checkConflictsResult.importIdMap]); + + // Check multi-namespace object types for regular conflicts and ambiguous conflicts + const importIdMapForRetries = getImportIdMapForRetries(checkConflictsResult.filteredObjects, { + retries, + trueCopy, + }); + importIdMap = new Map([...importIdMap, ...importIdMapForRetries]); // Bulk create in two batches, overwrites and non-overwrites - const { objectsToOverwrite, objectsToNotOverwrite } = splitOverwrites(filteredObjects, retries); - if (objectsToOverwrite.length) { - const bulkCreateResult = await savedObjectsClient.bulkCreate(objectsToOverwrite, { - overwrite: true, - namespace, - }); - errorAccumulator = [ - ...errorAccumulator, - ...extractErrors(bulkCreateResult.saved_objects, objectsToOverwrite), + let successResults: Array<{ type: string; id: string; destinationId?: string }> = []; + const accumulatedErrors = [...errorAccumulator]; + const bulkCreateObjects = async (objects: Array>, overwrite?: boolean) => { + const options = { savedObjectsClient, importIdMap, namespace, overwrite }; + const { createdObjects, errors: bulkCreateErrors } = await createSavedObjects( + objects, + accumulatedErrors, + options + ); + errorAccumulator = [...errorAccumulator, ...bulkCreateErrors]; + successCount += createdObjects.length; + successResults = [ + ...successResults, + ...createdObjects.map(({ type, id, destinationId, originId }) => ({ + type, + id, + ...(destinationId && { destinationId }), + ...(destinationId && !originId && !trueCopy && { trueCopy: true }), + })), ]; - successCount += bulkCreateResult.saved_objects.filter((obj) => !obj.error).length; - } - if (objectsToNotOverwrite.length) { - const bulkCreateResult = await savedObjectsClient.bulkCreate(objectsToNotOverwrite, { - namespace, - }); - errorAccumulator = [ - ...errorAccumulator, - ...extractErrors(bulkCreateResult.saved_objects, objectsToNotOverwrite), - ]; - successCount += bulkCreateResult.saved_objects.filter((obj) => !obj.error).length; - } + }; + const { objectsToOverwrite, objectsToNotOverwrite } = splitOverwrites( + checkConflictsResult.filteredObjects, + retries + ); + await bulkCreateObjects(objectsToOverwrite, true); + await bulkCreateObjects(objectsToNotOverwrite); return { successCount, success: errorAccumulator.length === 0, - ...(errorAccumulator.length ? { errors: errorAccumulator } : {}), + ...(successResults.length && { successResults }), + ...(errorAccumulator.length && { errors: errorAccumulator }), }; } diff --git a/src/core/server/saved_objects/import/types.ts b/src/core/server/saved_objects/import/types.ts index 067579f54edac..aa8479d4874de 100644 --- a/src/core/server/saved_objects/import/types.ts +++ b/src/core/server/saved_objects/import/types.ts @@ -19,6 +19,7 @@ import { Readable } from 'stream'; import { SavedObjectsClientContract } from '../types'; +import { ISavedObjectTypeRegistry } from '..'; /** * Describes a retry operation for importing a saved object. @@ -28,11 +29,22 @@ export interface SavedObjectsImportRetry { type: string; id: string; overwrite: boolean; + /** + * The object ID that will be created or overwritten. If not specified, the `id` field will be used. + */ + destinationId?: string; replaceReferences: Array<{ type: string; from: string; to: string; }>; + /** + * @deprecated + * If `trueCopy` is specified, the new object has a new (undefined) origin ID. This is only needed for the case where True Copy mode is + * disabled and ambiguous source conflicts are detected. When True Copy mode is permanently enabled, this field will be redundant and can + * be removed. + */ + trueCopy?: boolean; } /** @@ -41,6 +53,16 @@ export interface SavedObjectsImportRetry { */ export interface SavedObjectsImportConflictError { type: 'conflict'; + destinationId?: string; +} + +/** + * Represents a failure to import due to a conflict, which can be resolved in different ways with an overwrite. + * @public + */ +export interface SavedObjectsImportAmbiguousConflictError { + type: 'ambiguous_conflict'; + destinations: Array<{ id: string; title?: string; updatedAt?: string }>; } /** @@ -87,11 +109,32 @@ export interface SavedObjectsImportError { title?: string; error: | SavedObjectsImportConflictError + | SavedObjectsImportAmbiguousConflictError | SavedObjectsImportUnsupportedTypeError | SavedObjectsImportMissingReferencesError | SavedObjectsImportUnknownError; } +/** + * Represents a successful import. + * @public + */ +export interface SavedObjectsImportSuccess { + id: string; + type: string; + /** + * If `destinationId` is specified, the new object has a new ID that is different from the import ID. + */ + destinationId?: string; + /** + * @deprecated + * If `trueCopy` is specified, the new object has a new (undefined) origin ID. This is only needed for the case where True Copy mode is + * disabled and ambiguous source conflicts are detected. When True Copy mode is permanently enabled, this field will be redundant and can + * be removed. + */ + trueCopy?: boolean; +} + /** * The response describing the result of an import. * @public @@ -99,6 +142,7 @@ export interface SavedObjectsImportError { export interface SavedObjectsImportResponse { success: boolean; successCount: number; + successResults?: SavedObjectsImportSuccess[]; errors?: SavedObjectsImportError[]; } @@ -111,14 +155,25 @@ export interface SavedObjectsImportOptions { readStream: Readable; /** The maximum number of object to import */ objectLimit: number; - /** if true, will override existing object if present */ + /** + * @deprecated + * If true, will override existing object if present. This option will be removed and permanently disabled in a future release. + * + * Note: this has no effect when used with the `trueCopy` option. + */ overwrite: boolean; /** {@link SavedObjectsClientContract | client} to use to perform the import operation */ savedObjectsClient: SavedObjectsClientContract; - /** the list of allowed types to import */ - supportedTypes: string[]; + /** The registry of all known saved object types */ + typeRegistry: ISavedObjectTypeRegistry; /** if specified, will import in given namespace, else will import as global object */ namespace?: string; + /** + * @deprecated + * If true, will create new copies of import objects, each with a random `id` and undefined `originId`. This option will be removed and + * permanently enabled in a future release. + */ + trueCopy: boolean; } /** @@ -132,10 +187,16 @@ export interface SavedObjectsResolveImportErrorsOptions { objectLimit: number; /** client to use to perform the import operation */ savedObjectsClient: SavedObjectsClientContract; + /** The registry of all known saved object types */ + typeRegistry: ISavedObjectTypeRegistry; /** saved object import references to retry */ retries: SavedObjectsImportRetry[]; - /** the list of allowed types to import */ - supportedTypes: string[]; /** if specified, will import in given namespace */ namespace?: string; + /** + * @deprecated + * If true, will create new copies of import objects, each with a random `id` and undefined `originId`. This option will be removed and + * permanently enabled in a future release. + */ + trueCopy: boolean; } diff --git a/src/core/server/saved_objects/import/utilities.test.ts b/src/core/server/saved_objects/import/utilities.test.ts new file mode 100644 index 0000000000000..ccd40fa6a5621 --- /dev/null +++ b/src/core/server/saved_objects/import/utilities.test.ts @@ -0,0 +1,38 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { getNonUniqueEntries } from './utilities'; + +const foo1 = { type: 'foo', id: '1' }; +const foo2 = { type: 'foo', id: '2' }; // same type as foo1, different ID +const bar1 = { type: 'bar', id: '1' }; // same ID as foo1, different type + +describe('#getNonUniqueEntries', () => { + test('returns empty array if entries are unique', () => { + const result = getNonUniqueEntries([foo1, foo2, bar1]); + expect(result).toEqual([]); + }); + + test('returns non-empty array for non-unique results', () => { + const result1 = getNonUniqueEntries([foo1, foo2, foo1]); + const result2 = getNonUniqueEntries([foo1, foo2, foo1, foo2]); + expect(result1).toEqual([`${foo1.type}:${foo1.id}`]); + expect(result2).toEqual([`${foo1.type}:${foo1.id}`, `${foo2.type}:${foo2.id}`]); + }); +}); diff --git a/src/core/server/saved_objects/import/utilities.ts b/src/core/server/saved_objects/import/utilities.ts new file mode 100644 index 0000000000000..468bf73d9b2db --- /dev/null +++ b/src/core/server/saved_objects/import/utilities.ts @@ -0,0 +1,35 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +type Entries = Array<{ type: string; id: string }>; + +export const getNonUniqueEntries = (objects: Entries) => { + const idCountMap = objects.reduce((acc, { type, id }) => { + const key = `${type}:${id}`; + const val = acc.get(key) ?? 0; + return acc.set(key, val + 1); + }, new Map()); + const nonUniqueEntries: string[] = []; + idCountMap.forEach((value, key) => { + if (value >= 2) { + nonUniqueEntries.push(key); + } + }); + return nonUniqueEntries; +}; diff --git a/src/core/server/saved_objects/import/validate_references.test.ts b/src/core/server/saved_objects/import/validate_references.test.ts index a9dce65b97d72..ec478895fedbb 100644 --- a/src/core/server/saved_objects/import/validate_references.test.ts +++ b/src/core/server/saved_objects/import/validate_references.test.ts @@ -19,6 +19,7 @@ import { getNonExistingReferenceAsKeys, validateReferences } from './validate_references'; import { savedObjectsClientMock } from '../../mocks'; +import { SavedObjectsErrorHelpers } from '..'; describe('getNonExistingReferenceAsKeys()', () => { const savedObjectsClient = savedObjectsClientMock.create(); @@ -164,20 +165,15 @@ describe('getNonExistingReferenceAsKeys()', () => { { id: '1', type: 'index-pattern', - error: { - statusCode: 404, - message: 'Not found', - }, + error: SavedObjectsErrorHelpers.createGenericNotFoundError('index-pattern', '1').output + .payload, attributes: {}, references: [], }, { id: '3', type: 'search', - error: { - statusCode: 404, - message: 'Not found', - }, + error: SavedObjectsErrorHelpers.createGenericNotFoundError('search', '3').output.payload, attributes: {}, references: [], }, @@ -245,40 +241,31 @@ describe('validateReferences()', () => { { type: 'index-pattern', id: '3', - error: { - statusCode: 404, - message: 'Not found', - }, + error: SavedObjectsErrorHelpers.createGenericNotFoundError('index-pattern', '3').output + .payload, attributes: {}, references: [], }, { type: 'index-pattern', id: '5', - error: { - statusCode: 404, - message: 'Not found', - }, + error: SavedObjectsErrorHelpers.createGenericNotFoundError('index-pattern', '5').output + .payload, attributes: {}, references: [], }, { type: 'index-pattern', id: '6', - error: { - statusCode: 404, - message: 'Not found', - }, + error: SavedObjectsErrorHelpers.createGenericNotFoundError('index-pattern', '6').output + .payload, attributes: {}, references: [], }, { type: 'search', id: '7', - error: { - statusCode: 404, - message: 'Not found', - }, + error: SavedObjectsErrorHelpers.createGenericNotFoundError('search', '7').output.payload, attributes: {}, references: [], }, @@ -602,10 +589,7 @@ describe('validateReferences()', () => { { id: '1', type: 'index-pattern', - error: { - statusCode: 400, - message: 'Error', - }, + error: SavedObjectsErrorHelpers.createBadRequestError().output.payload, attributes: {}, references: [], }, diff --git a/src/core/server/saved_objects/import/validate_retries.test.ts b/src/core/server/saved_objects/import/validate_retries.test.ts new file mode 100644 index 0000000000000..7861e60a323c5 --- /dev/null +++ b/src/core/server/saved_objects/import/validate_retries.test.ts @@ -0,0 +1,97 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { validateRetries } from './validate_retries'; +import { SavedObjectsImportRetry } from '.'; + +import { getNonUniqueEntries } from './utilities'; +jest.mock('./utilities'); +const mockGetNonUniqueEntries = getNonUniqueEntries as jest.MockedFunction< + typeof getNonUniqueEntries +>; + +beforeEach(() => { + jest.clearAllMocks(); + mockGetNonUniqueEntries.mockReturnValue([]); +}); + +describe('#validateRetries', () => { + const createRetry = (object: unknown) => object as SavedObjectsImportRetry; + + describe('module calls', () => { + test('empty retries', () => { + validateRetries([]); + expect(getNonUniqueEntries).toHaveBeenCalledTimes(2); + expect(getNonUniqueEntries).toHaveBeenNthCalledWith(1, []); + expect(getNonUniqueEntries).toHaveBeenNthCalledWith(2, []); + }); + + test('non-empty retries', () => { + const retry1 = createRetry({ type: 'foo', id: '1' }); + const retry2 = createRetry({ type: 'foo', id: '2', overwrite: true }); + const retry3 = createRetry({ type: 'foo', id: '3', destinationId: 'a' }); + const retry4 = createRetry({ type: 'foo', id: '4', overwrite: true, destinationId: 'b' }); + const retries = [retry1, retry2, retry3, retry4]; + validateRetries(retries); + expect(getNonUniqueEntries).toHaveBeenCalledTimes(2); + // check all retry objects for non-unique entries + expect(getNonUniqueEntries).toHaveBeenNthCalledWith(1, retries); + // check only retry objects with `destinationId` !== undefined for non-unique entries + const retryOverwriteEntries = [ + { type: retry3.type, id: retry3.destinationId }, + { type: retry4.type, id: retry4.destinationId }, + ]; + expect(getNonUniqueEntries).toHaveBeenNthCalledWith(2, retryOverwriteEntries); + }); + }); + + describe('results', () => { + test('throws Boom error if any retry objects are not unique', () => { + mockGetNonUniqueEntries.mockReturnValue(['type1:id1', 'type2:id2']); + expect.assertions(2); + try { + validateRetries([]); + } catch ({ isBoom, message }) { + expect(isBoom).toBe(true); + expect(message).toMatchInlineSnapshot( + `"Non-unique retry objects: [type1:id1,type2:id2]: Bad Request"` + ); + } + }); + + test('throws Boom error if any retry destinations are not unique', () => { + mockGetNonUniqueEntries.mockReturnValueOnce([]); + mockGetNonUniqueEntries.mockReturnValue(['type1:id1', 'type2:id2']); + expect.assertions(2); + try { + validateRetries([]); + } catch ({ isBoom, message }) { + expect(isBoom).toBe(true); + expect(message).toMatchInlineSnapshot( + `"Non-unique retry destinations: [type1:id1,type2:id2]: Bad Request"` + ); + } + }); + + test('does not throw error if retry objects and retry destinations are unique', () => { + // no need to mock return value, the mock `getNonUniqueEntries` function returns an empty array by default + expect(() => validateRetries([])).not.toThrowError(); + }); + }); +}); diff --git a/src/core/server/saved_objects/import/validate_retries.ts b/src/core/server/saved_objects/import/validate_retries.ts new file mode 100644 index 0000000000000..f32c5bf82f989 --- /dev/null +++ b/src/core/server/saved_objects/import/validate_retries.ts @@ -0,0 +1,41 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SavedObjectsImportRetry } from './types'; +import { getNonUniqueEntries } from './utilities'; +import { SavedObjectsErrorHelpers } from '..'; + +export const validateRetries = (retries: SavedObjectsImportRetry[]) => { + const nonUniqueRetryObjects = getNonUniqueEntries(retries); + if (nonUniqueRetryObjects.length > 0) { + throw SavedObjectsErrorHelpers.createBadRequestError( + `Non-unique retry objects: [${nonUniqueRetryObjects.join()}]` + ); + } + + const destinationEntries = retries + .filter((retry) => retry.destinationId !== undefined) + .map(({ type, destinationId }) => ({ type, id: destinationId! })); + const nonUniqueRetryDestinations = getNonUniqueEntries(destinationEntries); + if (nonUniqueRetryDestinations.length > 0) { + throw SavedObjectsErrorHelpers.createBadRequestError( + `Non-unique retry destinations: [${nonUniqueRetryDestinations.join()}]` + ); + } +}; diff --git a/src/core/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap b/src/core/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap index bc9a66926e880..f8ef47cae8944 100644 --- a/src/core/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap +++ b/src/core/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap @@ -9,6 +9,7 @@ Object { "migrationVersion": "4a1746014a75ade3a714e1db5763276f", "namespace": "2f4316de49999235636386fe51dc06c1", "namespaces": "2f4316de49999235636386fe51dc06c1", + "originId": "2f4316de49999235636386fe51dc06c1", "references": "7997cf5a56cc02bdc9c93361bde732b0", "type": "2f4316de49999235636386fe51dc06c1", "updated_at": "00da57df13e94e9d98437d13ace4bfe0", @@ -32,6 +33,9 @@ Object { "namespaces": Object { "type": "keyword", }, + "originId": Object { + "type": "keyword", + }, "references": Object { "properties": Object { "id": Object { @@ -64,6 +68,7 @@ Object { "migrationVersion": "4a1746014a75ade3a714e1db5763276f", "namespace": "2f4316de49999235636386fe51dc06c1", "namespaces": "2f4316de49999235636386fe51dc06c1", + "originId": "2f4316de49999235636386fe51dc06c1", "references": "7997cf5a56cc02bdc9c93361bde732b0", "secondType": "72d57924f415fbadb3ee293b67d233ab", "thirdType": "510f1f0adb69830cf8a1c5ce2923ed82", @@ -91,6 +96,9 @@ Object { "namespaces": Object { "type": "keyword", }, + "originId": Object { + "type": "keyword", + }, "references": Object { "properties": Object { "id": Object { diff --git a/src/core/server/saved_objects/migrations/core/build_active_mappings.ts b/src/core/server/saved_objects/migrations/core/build_active_mappings.ts index 4561f4d30e104..2f4427b27b6bf 100644 --- a/src/core/server/saved_objects/migrations/core/build_active_mappings.ts +++ b/src/core/server/saved_objects/migrations/core/build_active_mappings.ts @@ -144,6 +144,9 @@ function defaultMapping(): IndexMapping { namespaces: { type: 'keyword', }, + originId: { + type: 'keyword', + }, updated_at: { type: 'date', }, diff --git a/src/core/server/saved_objects/migrations/core/index_migrator.test.ts b/src/core/server/saved_objects/migrations/core/index_migrator.test.ts index 86c79cbfb5824..7d414f1b9c606 100644 --- a/src/core/server/saved_objects/migrations/core/index_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/core/index_migrator.test.ts @@ -62,6 +62,7 @@ describe('IndexMigrator', () => { migrationVersion: '4a1746014a75ade3a714e1db5763276f', namespace: '2f4316de49999235636386fe51dc06c1', namespaces: '2f4316de49999235636386fe51dc06c1', + originId: '2f4316de49999235636386fe51dc06c1', references: '7997cf5a56cc02bdc9c93361bde732b0', type: '2f4316de49999235636386fe51dc06c1', updated_at: '00da57df13e94e9d98437d13ace4bfe0', @@ -72,6 +73,7 @@ describe('IndexMigrator', () => { migrationVersion: { dynamic: 'true', type: 'object' }, namespace: { type: 'keyword' }, namespaces: { type: 'keyword' }, + originId: { type: 'keyword' }, type: { type: 'keyword' }, updated_at: { type: 'date' }, references: { @@ -181,6 +183,7 @@ describe('IndexMigrator', () => { migrationVersion: '4a1746014a75ade3a714e1db5763276f', namespace: '2f4316de49999235636386fe51dc06c1', namespaces: '2f4316de49999235636386fe51dc06c1', + originId: '2f4316de49999235636386fe51dc06c1', references: '7997cf5a56cc02bdc9c93361bde732b0', type: '2f4316de49999235636386fe51dc06c1', updated_at: '00da57df13e94e9d98437d13ace4bfe0', @@ -192,6 +195,7 @@ describe('IndexMigrator', () => { migrationVersion: { dynamic: 'true', type: 'object' }, namespace: { type: 'keyword' }, namespaces: { type: 'keyword' }, + originId: { type: 'keyword' }, type: { type: 'keyword' }, updated_at: { type: 'date' }, references: { diff --git a/src/core/server/saved_objects/migrations/kibana/__snapshots__/kibana_migrator.test.ts.snap b/src/core/server/saved_objects/migrations/kibana/__snapshots__/kibana_migrator.test.ts.snap index 3453f3fc80310..9311292a6a0ed 100644 --- a/src/core/server/saved_objects/migrations/kibana/__snapshots__/kibana_migrator.test.ts.snap +++ b/src/core/server/saved_objects/migrations/kibana/__snapshots__/kibana_migrator.test.ts.snap @@ -9,6 +9,7 @@ Object { "migrationVersion": "4a1746014a75ade3a714e1db5763276f", "namespace": "2f4316de49999235636386fe51dc06c1", "namespaces": "2f4316de49999235636386fe51dc06c1", + "originId": "2f4316de49999235636386fe51dc06c1", "references": "7997cf5a56cc02bdc9c93361bde732b0", "type": "2f4316de49999235636386fe51dc06c1", "updated_at": "00da57df13e94e9d98437d13ace4bfe0", @@ -40,6 +41,9 @@ Object { "namespaces": Object { "type": "keyword", }, + "originId": Object { + "type": "keyword", + }, "references": Object { "properties": Object { "id": Object { diff --git a/src/core/server/saved_objects/routes/import.ts b/src/core/server/saved_objects/routes/import.ts index 8fce6f49fb850..acdb00e6c12bf 100644 --- a/src/core/server/saved_objects/routes/import.ts +++ b/src/core/server/saved_objects/routes/import.ts @@ -45,32 +45,39 @@ export const registerImportRoute = (router: IRouter, config: SavedObjectConfig) }, }, validate: { - query: schema.object({ - overwrite: schema.boolean({ defaultValue: false }), - }), + query: schema.object( + { + overwrite: schema.boolean({ defaultValue: false }), + trueCopy: schema.boolean({ defaultValue: false }), + }, + { + validate: (object) => { + if (object.overwrite && object.trueCopy) { + return 'cannot use [overwrite] with [trueCopy]'; + } + }, + } + ), body: schema.object({ file: schema.stream(), }), }, }, router.handleLegacyErrors(async (context, req, res) => { - const { overwrite } = req.query; + const { overwrite, trueCopy } = req.query; const file = req.body.file as FileStream; const fileExtension = extname(file.hapi.filename).toLowerCase(); if (fileExtension !== '.ndjson') { return res.badRequest({ body: `Invalid file extension ${fileExtension}` }); } - const supportedTypes = context.core.savedObjects.typeRegistry - .getImportableAndExportableTypes() - .map((type) => type.name); - const result = await importSavedObjectsFromStream({ - supportedTypes, savedObjectsClient: context.core.savedObjects.client, + typeRegistry: context.core.savedObjects.typeRegistry, readStream: createSavedObjectsStreamFromNdJson(file), objectLimit: maxImportExportSize, overwrite, + trueCopy, }); return res.ok({ body: result }); 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 c4e304a3f892f..a9722c13c6563 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 @@ -17,35 +17,45 @@ * under the License. */ +import { mockUuidv4 } from '../../import/__mocks__'; import supertest from 'supertest'; import { UnwrapPromise } from '@kbn/utility-types'; import { registerImportRoute } from '../import'; import { savedObjectsClientMock } from '../../../../../core/server/mocks'; import { SavedObjectConfig } from '../../saved_objects_config'; import { setupServer, createExportableType } from '../test_utils'; +import { SavedObjectsErrorHelpers } from '../..'; +import { SavedObject } from '../../types'; type setupServerReturn = UnwrapPromise>; const allowedTypes = ['index-pattern', 'visualization', 'dashboard']; -const config = { - maxImportPayloadBytes: 10485760, - maxImportExportSize: 10000, -} as SavedObjectConfig; +const config = { maxImportPayloadBytes: 10485760, maxImportExportSize: 10000 } as SavedObjectConfig; +const URL = '/internal/saved_objects/_import'; -describe('POST /internal/saved_objects/_import', () => { +describe(`POST ${URL}`, () => { let server: setupServerReturn['server']; let httpSetup: setupServerReturn['httpSetup']; let handlerContext: setupServerReturn['handlerContext']; let savedObjectsClient: ReturnType; - const emptyResponse = { - saved_objects: [], - total: 0, - per_page: 0, - page: 0, + const emptyResponse = { saved_objects: [], total: 0, per_page: 0, page: 0 }; + const mockIndexPattern = { + type: 'index-pattern', + id: 'my-pattern', + attributes: { title: 'my-pattern-*' }, + references: [], + }; + const mockDashboard = { + type: 'dashboard', + id: 'my-dashboard', + attributes: { title: 'Look at my dashboard' }, + references: [], }; beforeEach(async () => { + mockUuidv4.mockReset(); + mockUuidv4.mockImplementation(() => jest.requireActual('uuidv4')); ({ server, httpSetup, handlerContext } = await setupServer()); handlerContext.savedObjects.typeRegistry.getImportableAndExportableTypes.mockReturnValue( allowedTypes.map(createExportableType) @@ -53,6 +63,7 @@ describe('POST /internal/saved_objects/_import', () => { savedObjectsClient = handlerContext.savedObjects.client; savedObjectsClient.find.mockResolvedValue(emptyResponse); + savedObjectsClient.checkConflicts.mockResolvedValue({ errors: [] }); const router = httpSetup.createRouter('/internal/saved_objects/'); registerImportRoute(router, config); @@ -66,7 +77,7 @@ describe('POST /internal/saved_objects/_import', () => { it('formats successful response', async () => { const result = await supertest(httpSetup.server.listener) - .post('/internal/saved_objects/_import') + .post(URL) .set('content-Type', 'multipart/form-data; boundary=BOUNDARY') .send( [ @@ -80,29 +91,15 @@ describe('POST /internal/saved_objects/_import', () => { ) .expect(200); - expect(result.body).toEqual({ - success: true, - successCount: 0, - }); - expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(0); + expect(result.body).toEqual({ success: true, successCount: 0 }); + expect(savedObjectsClient.bulkCreate).not.toHaveBeenCalled(); // no objects were created }); it('defaults migrationVersion to empty object', async () => { - savedObjectsClient.bulkCreate.mockResolvedValueOnce({ - saved_objects: [ - { - type: 'index-pattern', - id: 'my-pattern', - attributes: { - title: 'my-pattern-*', - }, - references: [], - }, - ], - }); + savedObjectsClient.bulkCreate.mockResolvedValueOnce({ saved_objects: [mockIndexPattern] }); const result = await supertest(httpSetup.server.listener) - .post('/internal/saved_objects/_import') + .post(URL) .set('content-Type', 'multipart/form-data; boundary=EXAMPLE') .send( [ @@ -119,39 +116,24 @@ describe('POST /internal/saved_objects/_import', () => { expect(result.body).toEqual({ success: true, successCount: 1, + successResults: [{ type: 'index-pattern', id: 'my-pattern' }], }); - expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(1); - const firstBulkCreateCallArray = savedObjectsClient.bulkCreate.mock.calls[0][0]; - expect(firstBulkCreateCallArray).toHaveLength(1); - expect(firstBulkCreateCallArray[0].migrationVersion).toEqual({}); + expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(1); // successResults objects were created because no resolvable errors are present + expect(savedObjectsClient.bulkCreate).toHaveBeenCalledWith( + [expect.objectContaining({ migrationVersion: {} })], + expect.anything() // options + ); }); it('imports an index pattern and dashboard, ignoring empty lines in the file', async () => { // NOTE: changes to this scenario should be reflected in the docs savedObjectsClient.bulkCreate.mockResolvedValueOnce({ - saved_objects: [ - { - type: 'index-pattern', - id: 'my-pattern', - attributes: { - title: 'my-pattern-*', - }, - references: [], - }, - { - type: 'dashboard', - id: 'my-dashboard', - attributes: { - title: 'Look at my dashboard', - }, - references: [], - }, - ], + saved_objects: [mockIndexPattern, mockDashboard], }); const result = await supertest(httpSetup.server.listener) - .post('/internal/saved_objects/_import') + .post(URL) .set('content-Type', 'multipart/form-data; boundary=EXAMPLE') .send( [ @@ -172,37 +154,25 @@ describe('POST /internal/saved_objects/_import', () => { expect(result.body).toEqual({ success: true, successCount: 2, + successResults: [ + { type: mockIndexPattern.type, id: mockIndexPattern.id }, + { type: mockDashboard.type, id: mockDashboard.id }, + ], }); + expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(1); // successResults objects were created because no resolvable errors are present }); it('imports an index pattern and dashboard but has a conflict on the index pattern', async () => { // NOTE: changes to this scenario should be reflected in the docs - savedObjectsClient.bulkCreate.mockResolvedValueOnce({ - saved_objects: [ - { - type: 'index-pattern', - id: 'my-pattern', - attributes: {}, - references: [], - error: { - statusCode: 409, - message: 'Saved object [index-pattern/my-pattern] conflict', - }, - }, - { - type: 'dashboard', - id: 'my-dashboard', - attributes: { - title: 'Look at my dashboard', - }, - references: [], - }, - ], + const error = SavedObjectsErrorHelpers.createConflictError('index-pattern', 'my-pattern').output + .payload; + savedObjectsClient.checkConflicts.mockResolvedValue({ + errors: [{ type: mockIndexPattern.type, id: mockIndexPattern.id, error }], }); const result = await supertest(httpSetup.server.listener) - .post('/internal/saved_objects/_import') + .post(URL) .set('content-Type', 'multipart/form-data; boundary=EXAMPLE') .send( [ @@ -220,39 +190,32 @@ describe('POST /internal/saved_objects/_import', () => { expect(result.body).toEqual({ success: false, successCount: 1, + successResults: [{ type: mockDashboard.type, id: mockDashboard.id }], errors: [ { - id: 'my-pattern', - type: 'index-pattern', - title: 'my-pattern-*', - error: { - type: 'conflict', - }, + id: mockIndexPattern.id, + type: mockIndexPattern.type, + title: mockIndexPattern.attributes.title, + error: { type: 'conflict' }, }, ], }); + expect(savedObjectsClient.bulkCreate).not.toHaveBeenCalled(); // successResults objects were not created because resolvable errors are present }); it('imports a visualization with missing references', async () => { // NOTE: changes to this scenario should be reflected in the docs + const error = SavedObjectsErrorHelpers.createGenericNotFoundError( + 'index-pattern', + 'my-pattern-*' + ).output.payload; savedObjectsClient.bulkGet.mockResolvedValueOnce({ - saved_objects: [ - { - id: 'my-pattern-*', - type: 'index-pattern', - error: { - statusCode: 404, - message: 'Not found', - }, - references: [], - attributes: {}, - }, - ], + saved_objects: [{ ...mockIndexPattern, error }], }); const result = await supertest(httpSetup.server.listener) - .post('/internal/saved_objects/_import') + .post(URL) .set('content-Type', 'multipart/form-data; boundary=EXAMPLE') .send( [ @@ -260,7 +223,7 @@ describe('POST /internal/saved_objects/_import', () => { 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', 'Content-Type: application/ndjson', '', - '{"type":"visualization","id":"my-vis","attributes":{"title":"my-vis"},"references":[{"name":"ref_0","type":"index-pattern","id":"my-pattern-*"}]}', + '{"type":"visualization","id":"my-vis","attributes":{"title":"my-vis"},"references":[{"name":"ref_0","type":"index-pattern","id":"my-pattern"}]}', '{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"},"references":[{"name":"ref_0","type":"visualization","id":"my-vis"}]}', '--EXAMPLE--', ].join('\r\n') @@ -277,47 +240,73 @@ describe('POST /internal/saved_objects/_import', () => { title: 'my-vis', error: { type: 'missing_references', - references: [ - { - type: 'index-pattern', - id: 'my-pattern-*', - }, - ], - blocking: [ - { - type: 'dashboard', - id: 'my-dashboard', - }, - ], + references: [{ type: 'index-pattern', id: 'my-pattern' }], + blocking: [{ type: 'dashboard', id: 'my-dashboard' }], }, }, ], }); - expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(` -[MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "fields": Array [ - "id", - ], - "id": "my-pattern-*", - "type": "index-pattern", - }, - ], - Object { - "namespace": undefined, - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], -} -`); + expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.bulkGet).toHaveBeenCalledWith( + [{ fields: ['id'], id: 'my-pattern', type: 'index-pattern' }], + expect.anything() // options + ); + expect(savedObjectsClient.bulkCreate).not.toHaveBeenCalled(); // no objects were created + }); + + describe('trueCopy enabled', () => { + it('imports objects, regenerating all IDs/reference IDs present, and resetting all origin IDs', async () => { + mockUuidv4.mockReturnValueOnce('new-id-1').mockReturnValueOnce('new-id-2'); + savedObjectsClient.bulkGet.mockResolvedValueOnce({ saved_objects: [mockIndexPattern] }); + savedObjectsClient.bulkCreate.mockResolvedValueOnce({ + saved_objects: [ + { type: 'visualization', id: 'new-id-1' } as SavedObject, + { type: 'dashboard', id: 'new-id-2' } as SavedObject, + ], + }); + + const result = await supertest(httpSetup.server.listener) + .post(`${URL}?trueCopy=true`) + .set('content-Type', 'multipart/form-data; boundary=EXAMPLE') + .send( + [ + '--EXAMPLE', + 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', + 'Content-Type: application/ndjson', + '', + '{"type":"visualization","id":"my-vis","attributes":{"title":"my-vis"},"references":[{"name":"ref_0","type":"index-pattern","id":"my-pattern"}]}', + '{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"},"references":[{"name":"ref_0","type":"visualization","id":"my-vis"}]}', + '--EXAMPLE--', + ].join('\r\n') + ) + .expect(200); + + expect(result.body).toEqual({ + success: true, + successCount: 2, + successResults: [ + { type: 'visualization', id: 'my-vis', destinationId: 'new-id-1' }, + { type: 'dashboard', id: 'my-dashboard', destinationId: 'new-id-2' }, + ], + }); + expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(1); // successResults objects were created because no resolvable errors are present + expect(savedObjectsClient.bulkCreate).toHaveBeenCalledWith( + [ + expect.objectContaining({ + type: 'visualization', + id: 'new-id-1', + references: [{ name: 'ref_0', type: 'index-pattern', id: 'my-pattern' }], + originId: undefined, + }), + expect.objectContaining({ + type: 'dashboard', + id: 'new-id-2', + references: [{ name: 'ref_0', type: 'visualization', id: 'new-id-1' }], + originId: undefined, + }), + ], + expect.anything() // options + ); + }); }); }); 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 27750ec692e5a..536ea874cfd9f 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 @@ -23,21 +23,39 @@ import { registerResolveImportErrorsRoute } from '../resolve_import_errors'; import { savedObjectsClientMock } from '../../../../../core/server/mocks'; import { setupServer, createExportableType } from '../test_utils'; import { SavedObjectConfig } from '../../saved_objects_config'; +import { SavedObject } from '../../types'; type setupServerReturn = UnwrapPromise>; const allowedTypes = ['index-pattern', 'visualization', 'dashboard']; -const config = { - maxImportPayloadBytes: 10485760, - maxImportExportSize: 10000, -} as SavedObjectConfig; +const config = { maxImportPayloadBytes: 10485760, maxImportExportSize: 10000 } as SavedObjectConfig; +const URL = '/api/saved_objects/_resolve_import_errors'; -describe('POST /api/saved_objects/_resolve_import_errors', () => { +describe(`POST ${URL}`, () => { let server: setupServerReturn['server']; let httpSetup: setupServerReturn['httpSetup']; let handlerContext: setupServerReturn['handlerContext']; let savedObjectsClient: ReturnType; + const mockDashboard = { + type: 'dashboard', + id: 'my-dashboard', + attributes: { title: 'Look at my dashboard' }, + references: [], + }; + const mockVisualization = { + type: 'visualization', + id: 'my-vis', + attributes: { title: 'Look at my visualization' }, + references: [{ name: 'ref_0', type: 'index-pattern', id: 'existing' }], + }; + const mockIndexPattern = { + type: 'index-pattern', + id: 'existing', + attributes: {}, + references: [], + }; + beforeEach(async () => { ({ server, httpSetup, handlerContext } = await setupServer()); handlerContext.savedObjects.typeRegistry.getImportableAndExportableTypes.mockReturnValue( @@ -45,6 +63,7 @@ describe('POST /api/saved_objects/_resolve_import_errors', () => { ); savedObjectsClient = handlerContext.savedObjects.client; + savedObjectsClient.checkConflicts.mockResolvedValue({ errors: [] }); const router = httpSetup.createRouter('/api/saved_objects/'); registerResolveImportErrorsRoute(router, config); @@ -58,7 +77,7 @@ describe('POST /api/saved_objects/_resolve_import_errors', () => { it('formats successful response', async () => { const result = await supertest(httpSetup.server.listener) - .post('/api/saved_objects/_resolve_import_errors') + .post(URL) .set('content-Type', 'multipart/form-data; boundary=BOUNDARY') .send( [ @@ -77,25 +96,14 @@ describe('POST /api/saved_objects/_resolve_import_errors', () => { .expect(200); expect(result.body).toEqual({ success: true, successCount: 0 }); - expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(0); + expect(savedObjectsClient.bulkCreate).not.toHaveBeenCalled(); // no objects were created }); it('defaults migrationVersion to empty object', async () => { - savedObjectsClient.bulkCreate.mockResolvedValueOnce({ - saved_objects: [ - { - type: 'dashboard', - id: 'my-dashboard', - attributes: { - title: 'Look at my dashboard', - }, - references: [], - }, - ], - }); + savedObjectsClient.bulkCreate.mockResolvedValueOnce({ saved_objects: [mockDashboard] }); const result = await supertest(httpSetup.server.listener) - .post('/api/saved_objects/_resolve_import_errors') + .post(URL) .set('content-Type', 'multipart/form-data; boundary=EXAMPLE') .send( [ @@ -113,30 +121,21 @@ describe('POST /api/saved_objects/_resolve_import_errors', () => { ) .expect(200); - expect(result.body).toEqual({ success: true, successCount: 1 }); - expect(savedObjectsClient.bulkCreate.mock.calls).toHaveLength(1); - const firstBulkCreateCallArray = savedObjectsClient.bulkCreate.mock.calls[0][0]; - expect(firstBulkCreateCallArray).toHaveLength(1); - expect(firstBulkCreateCallArray[0].migrationVersion).toEqual({}); + const { type, id } = mockDashboard; + expect(result.body).toEqual({ success: true, successCount: 1, successResults: [{ type, id }] }); + expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(1); // successResults objects were created because no resolvable errors are present + expect(savedObjectsClient.bulkCreate).toHaveBeenCalledWith( + [expect.objectContaining({ migrationVersion: {} })], + expect.anything() // options + ); }); it('retries importing a dashboard', async () => { // NOTE: changes to this scenario should be reflected in the docs - savedObjectsClient.bulkCreate.mockResolvedValueOnce({ - saved_objects: [ - { - type: 'dashboard', - id: 'my-dashboard', - attributes: { - title: 'Look at my dashboard', - }, - references: [], - }, - ], - }); + savedObjectsClient.bulkCreate.mockResolvedValueOnce({ saved_objects: [mockDashboard] }); const result = await supertest(httpSetup.server.listener) - .post('/api/saved_objects/_resolve_import_errors') + .post(URL) .set('content-Type', 'multipart/form-data; boundary=EXAMPLE') .send( [ @@ -154,53 +153,21 @@ describe('POST /api/saved_objects/_resolve_import_errors', () => { ) .expect(200); - expect(result.body).toEqual({ success: true, successCount: 1 }); - expect(savedObjectsClient.bulkCreate).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "attributes": Object { - "title": "Look at my dashboard", - }, - "id": "my-dashboard", - "migrationVersion": Object {}, - "type": "dashboard", - }, - ], - Object { - "namespace": undefined, - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], - } - `); + const { type, id, attributes } = mockDashboard; + expect(result.body).toEqual({ success: true, successCount: 1, successResults: [{ type, id }] }); + expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(1); // successResults objects were created because no resolvable errors are present + expect(savedObjectsClient.bulkCreate).toHaveBeenCalledWith( + [{ type, id, attributes, migrationVersion: {} }], + expect.objectContaining({ overwrite: undefined }) + ); }); it('resolves conflicts for dashboard', async () => { // NOTE: changes to this scenario should be reflected in the docs - savedObjectsClient.bulkCreate.mockResolvedValueOnce({ - saved_objects: [ - { - type: 'dashboard', - id: 'my-dashboard', - attributes: { - title: 'Look at my dashboard', - }, - references: [], - }, - ], - }); + savedObjectsClient.bulkCreate.mockResolvedValueOnce({ saved_objects: [mockDashboard] }); const result = await supertest(httpSetup.server.listener) - .post('/api/saved_objects/_resolve_import_errors') + .post(URL) .set('content-Type', 'multipart/form-data; boundary=EXAMPLE') .send( [ @@ -219,70 +186,26 @@ describe('POST /api/saved_objects/_resolve_import_errors', () => { ) .expect(200); - expect(result.body).toEqual({ success: true, successCount: 1 }); - expect(savedObjectsClient.bulkCreate).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "attributes": Object { - "title": "Look at my dashboard", - }, - "id": "my-dashboard", - "migrationVersion": Object {}, - "type": "dashboard", - }, - ], - Object { - "namespace": undefined, - "overwrite": true, - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], - } - `); + const { type, id, attributes } = mockDashboard; + expect(result.body).toEqual({ + success: true, + successCount: 1, + successResults: [{ type, id }], + }); + expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(1); // successResults objects were created because no resolvable errors are present + expect(savedObjectsClient.bulkCreate).toHaveBeenCalledWith( + [{ type, id, attributes, migrationVersion: {} }], + expect.objectContaining({ overwrite: true }) + ); }); it('resolves conflicts by replacing the visualization references', async () => { // NOTE: changes to this scenario should be reflected in the docs - savedObjectsClient.bulkCreate.mockResolvedValueOnce({ - saved_objects: [ - { - type: 'visualization', - id: 'my-vis', - attributes: { - title: 'Look at my visualization', - }, - references: [ - { - name: 'ref_0', - type: 'index-pattern', - id: 'existing', - }, - ], - }, - ], - }); - savedObjectsClient.bulkGet.mockResolvedValueOnce({ - saved_objects: [ - { - id: 'existing', - type: 'index-pattern', - attributes: {}, - references: [], - }, - ], - }); + savedObjectsClient.bulkCreate.mockResolvedValueOnce({ saved_objects: [mockVisualization] }); + savedObjectsClient.bulkGet.mockResolvedValueOnce({ saved_objects: [mockIndexPattern] }); const result = await supertest(httpSetup.server.listener) - .post('/api/saved_objects/_resolve_import_errors') + .post(URL) .set('content-Type', 'multipart/form-data; boundary=EXAMPLE') .send( [ @@ -300,66 +223,80 @@ describe('POST /api/saved_objects/_resolve_import_errors', () => { ) .expect(200); - expect(result.body).toEqual({ success: true, successCount: 1 }); - expect(savedObjectsClient.bulkCreate).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "attributes": Object { - "title": "Look at my visualization", - }, - "id": "my-vis", - "migrationVersion": Object {}, - "references": Array [ - Object { - "id": "existing", - "name": "ref_0", - "type": "index-pattern", - }, - ], - "type": "visualization", - }, - ], - Object { - "namespace": undefined, - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, + const { type, id, attributes, references } = mockVisualization; + expect(result.body).toEqual({ + success: true, + successCount: 1, + successResults: [{ type: 'visualization', id: 'my-vis' }], + }); + expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(1); // successResults objects were created because no resolvable errors are present + expect(savedObjectsClient.bulkCreate).toHaveBeenCalledWith( + [{ type, id, attributes, references, migrationVersion: {} }], + expect.objectContaining({ overwrite: undefined }) + ); + expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.bulkGet).toHaveBeenCalledWith( + [{ fields: ['id'], id: 'existing', type: 'index-pattern' }], + expect.anything() // options + ); + }); + + describe('trueCopy enabled', () => { + it('imports objects, regenerating all IDs/reference IDs present, and resetting all origin IDs', async () => { + savedObjectsClient.bulkGet.mockResolvedValueOnce({ saved_objects: [mockIndexPattern] }); + savedObjectsClient.bulkCreate.mockResolvedValueOnce({ + saved_objects: [ + { type: 'visualization', id: 'new-id-1' } as SavedObject, + { type: 'dashboard', id: 'new-id-2' } as SavedObject, ], - } - `); - expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "fields": Array [ - "id", - ], - "id": "existing", - "type": "index-pattern", - }, - ], - Object { - "namespace": undefined, - }, - ], + }); + + const result = await supertest(httpSetup.server.listener) + .post(`${URL}?trueCopy=true`) + .set('content-Type', 'multipart/form-data; boundary=EXAMPLE') + .send( + [ + '--EXAMPLE', + 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', + 'Content-Type: application/ndjson', + '', + '{"type":"visualization","id":"my-vis","attributes":{"title":"my-vis"},"references":[{"name":"ref_0","type":"index-pattern","id":"my-pattern"}]}', + '{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"},"references":[{"name":"ref_0","type":"visualization","id":"my-vis"}]}', + '--EXAMPLE', + 'Content-Disposition: form-data; name="retries"', + '', + '[{"type":"visualization","id":"my-vis","destinationId":"new-id-1","replaceReferences":[{"type":"index-pattern","from":"my-pattern","to":"existing"}]},{"type":"dashboard","id":"my-dashboard","destinationId":"new-id-2"}]', + '--EXAMPLE--', + ].join('\r\n') + ) + .expect(200); + + expect(result.body).toEqual({ + success: true, + successCount: 2, + successResults: [ + { type: 'visualization', id: 'my-vis', destinationId: 'new-id-1' }, + { type: 'dashboard', id: 'my-dashboard', destinationId: 'new-id-2' }, ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, + }); + expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(1); // successResults objects were created because no resolvable errors are present + expect(savedObjectsClient.bulkCreate).toHaveBeenCalledWith( + [ + expect.objectContaining({ + type: 'visualization', + id: 'new-id-1', + references: [{ name: 'ref_0', type: 'index-pattern', id: 'existing' }], + originId: undefined, + }), + expect.objectContaining({ + type: 'dashboard', + id: 'new-id-2', + references: [{ name: 'ref_0', type: 'visualization', id: 'new-id-1' }], + originId: undefined, + }), ], - } - `); + expect.anything() // options + ); + }); }); }); diff --git a/src/core/server/saved_objects/routes/resolve_import_errors.ts b/src/core/server/saved_objects/routes/resolve_import_errors.ts index 3458e601e0fe6..7cdfb3e571e19 100644 --- a/src/core/server/saved_objects/routes/resolve_import_errors.ts +++ b/src/core/server/saved_objects/routes/resolve_import_errors.ts @@ -45,6 +45,9 @@ export const registerResolveImportErrorsRoute = (router: IRouter, config: SavedO }, }, validate: { + query: schema.object({ + trueCopy: schema.boolean({ defaultValue: false }), + }), body: schema.object({ file: schema.stream(), retries: schema.arrayOf( @@ -52,6 +55,7 @@ export const registerResolveImportErrorsRoute = (router: IRouter, config: SavedO type: schema.string(), id: schema.string(), overwrite: schema.boolean({ defaultValue: false }), + destinationId: schema.maybe(schema.string()), replaceReferences: schema.arrayOf( schema.object({ type: schema.string(), @@ -60,6 +64,7 @@ export const registerResolveImportErrorsRoute = (router: IRouter, config: SavedO }), { defaultValue: [] } ), + trueCopy: schema.maybe(schema.boolean()), }) ), }), @@ -72,16 +77,13 @@ export const registerResolveImportErrorsRoute = (router: IRouter, config: SavedO return res.badRequest({ body: `Invalid file extension ${fileExtension}` }); } - const supportedTypes = context.core.savedObjects.typeRegistry - .getImportableAndExportableTypes() - .map((type) => type.name); - const result = await resolveSavedObjectsImportErrors({ - supportedTypes, + typeRegistry: context.core.savedObjects.typeRegistry, savedObjectsClient: context.core.savedObjects.client, readStream: createSavedObjectsStreamFromNdJson(file), retries: req.body.retries, objectLimit: maxImportExportSize, + trueCopy: req.query.trueCopy, }); return res.ok({ body: result }); diff --git a/src/core/server/saved_objects/serialization/serializer.test.ts b/src/core/server/saved_objects/serialization/serializer.test.ts index 1a7dfdd2d130e..e5f0e8abd3b71 100644 --- a/src/core/server/saved_objects/serialization/serializer.test.ts +++ b/src/core/server/saved_objects/serialization/serializer.test.ts @@ -214,6 +214,28 @@ describe('#rawToSavedObject', () => { expect(actual).not.toHaveProperty('updated_at'); }); + test('if specified it copies the _source.originId property to originId', () => { + const originId = 'baz'; + const actual = singleNamespaceSerializer.rawToSavedObject({ + _id: 'foo:bar', + _source: { + type: 'foo', + originId, + }, + }); + expect(actual).toHaveProperty('originId', originId); + }); + + test(`if _source.originId is unspecified it doesn't set originId`, () => { + const actual = singleNamespaceSerializer.rawToSavedObject({ + _id: 'foo:bar', + _source: { + type: 'foo', + }, + }); + expect(actual).not.toHaveProperty('originId'); + }); + test('it does not pass unknown properties through', () => { const actual = singleNamespaceSerializer.rawToSavedObject({ _id: 'universe', @@ -280,6 +302,7 @@ describe('#rawToSavedObject', () => { namespace: 'foo-namespace', updated_at: String(new Date()), references: [], + originId: 'baz', }, }; @@ -458,6 +481,26 @@ describe('#savedObjectToRaw', () => { expect(actual._source).not.toHaveProperty('updated_at'); }); + test('if specified it copies the originId property to _source.originId', () => { + const originId = 'baz'; + const actual = singleNamespaceSerializer.savedObjectToRaw({ + type: '', + attributes: {}, + originId, + } as any); + + expect(actual._source).toHaveProperty('originId', originId); + }); + + test(`if unspecified it doesn't add originId property to _source`, () => { + const actual = singleNamespaceSerializer.savedObjectToRaw({ + type: '', + attributes: {}, + } as any); + + expect(actual._source).not.toHaveProperty('originId'); + }); + test('it copies the migrationVersion property to _source.migrationVersion', () => { const actual = singleNamespaceSerializer.savedObjectToRaw({ type: '', diff --git a/src/core/server/saved_objects/serialization/serializer.ts b/src/core/server/saved_objects/serialization/serializer.ts index 3b19d494d8ecf..11bfba6e3bfb4 100644 --- a/src/core/server/saved_objects/serialization/serializer.ts +++ b/src/core/server/saved_objects/serialization/serializer.ts @@ -64,7 +64,7 @@ export class SavedObjectsSerializer { */ public rawToSavedObject(doc: SavedObjectsRawDoc): SavedObjectSanitizedDoc { const { _id, _source, _seq_no, _primary_term } = doc; - const { type, namespace, namespaces } = _source; + const { type, namespace, namespaces, originId } = _source; const version = _seq_no != null || _primary_term != null @@ -76,6 +76,7 @@ export class SavedObjectsSerializer { id: this.trimIdPrefix(namespace, type, _id), ...(namespace && this.registry.isSingleNamespace(type) && { namespace }), ...(namespaces && this.registry.isMultiNamespace(type) && { namespaces }), + ...(originId && { originId }), attributes: _source[type], references: _source.references || [], ...(_source.migrationVersion && { migrationVersion: _source.migrationVersion }), @@ -95,6 +96,7 @@ export class SavedObjectsSerializer { type, namespace, namespaces, + originId, attributes, migrationVersion, updated_at, @@ -107,6 +109,7 @@ export class SavedObjectsSerializer { references, ...(namespace && this.registry.isSingleNamespace(type) && { namespace }), ...(namespaces && this.registry.isMultiNamespace(type) && { namespaces }), + ...(originId && { originId }), ...(migrationVersion && { migrationVersion }), ...(updated_at && { updated_at }), }; diff --git a/src/core/server/saved_objects/serialization/types.ts b/src/core/server/saved_objects/serialization/types.ts index acd2c7b5284aa..8b3eebceb2c5a 100644 --- a/src/core/server/saved_objects/serialization/types.ts +++ b/src/core/server/saved_objects/serialization/types.ts @@ -40,6 +40,7 @@ export interface SavedObjectsRawDocSource { migrationVersion?: SavedObjectsMigrationVersion; updated_at?: string; references?: SavedObjectReference[]; + originId?: string; [typeMapping: string]: any; } @@ -56,6 +57,7 @@ interface SavedObjectDoc { migrationVersion?: SavedObjectsMigrationVersion; version?: string; updated_at?: string; + originId?: string; } interface Referencable { diff --git a/src/core/server/saved_objects/service/lib/included_fields.test.ts b/src/core/server/saved_objects/service/lib/included_fields.test.ts index ced99361f1ea0..356ffff398343 100644 --- a/src/core/server/saved_objects/service/lib/included_fields.test.ts +++ b/src/core/server/saved_objects/service/lib/included_fields.test.ts @@ -19,6 +19,8 @@ import { includedFields } from './included_fields'; +const BASE_FIELD_COUNT = 9; + describe('includedFields', () => { it('returns undefined if fields are not provided', () => { expect(includedFields()).toBe(undefined); @@ -26,7 +28,7 @@ describe('includedFields', () => { it('accepts type string', () => { const fields = includedFields('config', 'foo'); - expect(fields).toHaveLength(8); + expect(fields).toHaveLength(BASE_FIELD_COUNT); expect(fields).toContain('type'); }); @@ -42,6 +44,7 @@ Array [ "references", "migrationVersion", "updated_at", + "originId", "foo", ] `); @@ -49,14 +52,14 @@ Array [ it('accepts field as string', () => { const fields = includedFields('config', 'foo'); - expect(fields).toHaveLength(8); + expect(fields).toHaveLength(BASE_FIELD_COUNT); expect(fields).toContain('config.foo'); }); it('accepts fields as an array', () => { const fields = includedFields('config', ['foo', 'bar']); - expect(fields).toHaveLength(10); + expect(fields).toHaveLength(BASE_FIELD_COUNT + 2); expect(fields).toContain('config.foo'); expect(fields).toContain('config.bar'); }); @@ -75,6 +78,7 @@ Array [ "references", "migrationVersion", "updated_at", + "originId", "foo", "bar", ] @@ -83,37 +87,43 @@ Array [ it('includes namespace', () => { const fields = includedFields('config', 'foo'); - expect(fields).toHaveLength(8); + expect(fields).toHaveLength(BASE_FIELD_COUNT); expect(fields).toContain('namespace'); }); it('includes namespaces', () => { const fields = includedFields('config', 'foo'); - expect(fields).toHaveLength(8); + expect(fields).toHaveLength(BASE_FIELD_COUNT); expect(fields).toContain('namespaces'); }); it('includes references', () => { const fields = includedFields('config', 'foo'); - expect(fields).toHaveLength(8); + expect(fields).toHaveLength(BASE_FIELD_COUNT); expect(fields).toContain('references'); }); it('includes migrationVersion', () => { const fields = includedFields('config', 'foo'); - expect(fields).toHaveLength(8); + expect(fields).toHaveLength(BASE_FIELD_COUNT); expect(fields).toContain('migrationVersion'); }); it('includes updated_at', () => { const fields = includedFields('config', 'foo'); - expect(fields).toHaveLength(8); + expect(fields).toHaveLength(BASE_FIELD_COUNT); expect(fields).toContain('updated_at'); }); + it('includes originId', () => { + const fields = includedFields('config', 'foo'); + expect(fields).toHaveLength(BASE_FIELD_COUNT); + expect(fields).toContain('originId'); + }); + it('uses wildcard when type is not provided', () => { const fields = includedFields(undefined, 'foo'); - expect(fields).toHaveLength(8); + expect(fields).toHaveLength(BASE_FIELD_COUNT); expect(fields).toContain('*.foo'); }); @@ -121,7 +131,7 @@ Array [ it('includes legacy field path', () => { const fields = includedFields('config', ['foo', 'bar']); - expect(fields).toHaveLength(10); + expect(fields).toHaveLength(BASE_FIELD_COUNT + 2); expect(fields).toContain('foo'); expect(fields).toContain('bar'); }); diff --git a/src/core/server/saved_objects/service/lib/included_fields.ts b/src/core/server/saved_objects/service/lib/included_fields.ts index 33bca49e3fc58..63d8f184ed2f2 100644 --- a/src/core/server/saved_objects/service/lib/included_fields.ts +++ b/src/core/server/saved_objects/service/lib/included_fields.ts @@ -42,5 +42,6 @@ export function includedFields(type: string | string[] = '*', fields?: string[] .concat('references') .concat('migrationVersion') .concat('updated_at') + .concat('originId') .concat(fields); // v5 compatibility } diff --git a/src/core/server/saved_objects/service/lib/repository.mock.ts b/src/core/server/saved_objects/service/lib/repository.mock.ts index afef378b7307b..c5fd260b78a9f 100644 --- a/src/core/server/saved_objects/service/lib/repository.mock.ts +++ b/src/core/server/saved_objects/service/lib/repository.mock.ts @@ -20,6 +20,7 @@ import { ISavedObjectsRepository } from './repository'; const create = (): jest.Mocked => ({ + checkConflicts: jest.fn(), create: jest.fn(), bulkCreate: jest.fn(), bulkUpdate: jest.fn(), diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index ea749235cbb41..06f955733e4f0 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -153,7 +153,7 @@ describe('SavedObjectsRepository', () => { validateDoc: jest.fn(), }); - const getMockGetResponse = ({ type, id, references, namespace }) => ({ + const getMockGetResponse = ({ type, id, references, namespace, originId }) => ({ // NOTE: Elasticsearch returns more fields (_index, _type) but the SavedObjectsRepository method ignores these found: true, _id: `${registry.isSingleNamespace(type) && namespace ? `${namespace}:` : ''}${type}:${id}`, @@ -161,6 +161,7 @@ describe('SavedObjectsRepository', () => { _source: { ...(registry.isSingleNamespace(type) && { namespace }), ...(registry.isMultiNamespace(type) && { namespaces: [namespace ?? 'default'] }), + ...(originId && { originId }), type, [type]: { title: 'Testing' }, references, @@ -201,13 +202,17 @@ describe('SavedObjectsRepository', () => { }); const expectSuccess = ({ type, id }) => expect.toBeDocumentWithoutError(type, id); const expectError = ({ type, id }) => ({ type, id, error: expect.any(Object) }); - const expectErrorResult = ({ type, id }, error) => ({ type, id, error }); - const expectErrorNotFound = (obj) => - expectErrorResult(obj, createGenericNotFoundError(obj.type, obj.id)); - const expectErrorConflict = (obj) => - expectErrorResult(obj, createConflictError(obj.type, obj.id)); - const expectErrorInvalidType = (obj) => - expectErrorResult(obj, createUnsupportedTypeError(obj.type, obj.id)); + const expectErrorResult = ({ type, id }, error, overrides = {}) => ({ + type, + id, + error: { ...error, ...overrides }, + }); + const expectErrorNotFound = (obj, overrides) => + expectErrorResult(obj, createGenericNotFoundError(obj.type, obj.id), overrides); + const expectErrorConflict = (obj, overrides) => + expectErrorResult(obj, createConflictError(obj.type, obj.id), overrides); + const expectErrorInvalidType = (obj, overrides) => + expectErrorResult(obj, createUnsupportedTypeError(obj.type, obj.id), overrides); const expectMigrationArgs = (args, contains = true, n = 1) => { const obj = contains ? expect.objectContaining(args) : expect.not.objectContaining(args); @@ -423,6 +428,7 @@ describe('SavedObjectsRepository', () => { id: '6.0.0-alpha1', attributes: { title: 'Test One' }, references: [{ name: 'ref_0', type: 'test', id: '1' }], + originId: 'some-origin-id', // only one of the object args has an originId, this is intentional to test both a positive and negative case }; const obj2 = { type: 'index-pattern', @@ -434,13 +440,14 @@ describe('SavedObjectsRepository', () => { const getMockBulkCreateResponse = (objects, namespace) => { return { - items: objects.map(({ type, id, attributes, references, migrationVersion }) => ({ + items: objects.map(({ type, id, originId, attributes, references, migrationVersion }) => ({ create: { _id: `${namespace ? `${namespace}:` : ''}${type}:${id}`, _source: { [type]: attributes, type, namespace, + ...(originId && { originId }), references, ...mockTimestampFields, migrationVersion: migrationVersion || { [type]: '1.1.1' }, @@ -452,9 +459,9 @@ describe('SavedObjectsRepository', () => { }; const bulkCreateSuccess = async (objects, options) => { - const multiNamespaceObjects = - options?.overwrite && - objects.filter(({ type, id }) => registry.isMultiNamespace(type) && id); + const multiNamespaceObjects = objects.filter( + ({ type, id }) => registry.isMultiNamespace(type) && id + ); if (multiNamespaceObjects?.length) { const response = getMockMgetResponse(multiNamespaceObjects, options?.namespace); callAdminCluster.mockResolvedValueOnce(response); // this._callCluster('mget', ...) @@ -469,14 +476,15 @@ describe('SavedObjectsRepository', () => { // bulk create calls have two objects for each source -- the action, and the source const expectClusterCallArgsAction = ( objects, - { method, _index = expect.any(String), getId = () => expect.any(String) } + { method, _index = expect.any(String), getId = () => expect.any(String) }, + n ) => { const body = []; for (const { type, id } of objects) { body.push({ [method]: { _index, _id: getId(type, id) } }); body.push(expect.any(Object)); } - expectClusterCallArgs({ body }); + expectClusterCallArgs({ body }, n); }; const expectObjArgs = ({ type, attributes, references }, overrides) => [ @@ -503,9 +511,9 @@ describe('SavedObjectsRepository', () => { expectClusterCalls('bulk'); }); - it(`should use the ES mget action before bulk action for any types that are multi-namespace, when overwrite=true`, async () => { + it(`should use the ES mget action before bulk action for any types that are multi-namespace, when id is defined`, async () => { const objects = [obj1, { ...obj2, type: MULTI_NAMESPACE_TYPE }]; - await bulkCreateSuccess(objects, { overwrite: true }); + await bulkCreateSuccess(objects); expectClusterCalls('mget', 'bulk'); const docs = [expect.objectContaining({ _id: `${MULTI_NAMESPACE_TYPE}:${obj2.id}` })]; expectClusterCallArgs({ body: { docs } }, 1); @@ -554,7 +562,7 @@ describe('SavedObjectsRepository', () => { await bulkCreateSuccess(objects, { namespace }); const expected = expect.not.objectContaining({ namespace: expect.anything() }); const body = [expect.any(Object), expected, expect.any(Object), expected]; - expectClusterCallArgs({ body }); + expectClusterCallArgs({ body }, 2); }); it(`adds namespaces to request body for any types that are multi-namespace`, async () => { @@ -624,7 +632,7 @@ describe('SavedObjectsRepository', () => { { ...obj2, type: MULTI_NAMESPACE_TYPE }, ]; await bulkCreateSuccess(objects, { namespace }); - expectClusterCallArgsAction(objects, { method: 'create', getId }); + expectClusterCallArgsAction(objects, { method: 'create', getId }, 2); }); }); @@ -692,8 +700,9 @@ describe('SavedObjectsRepository', () => { expectClusterCallArgs({ body: body1 }, 1); const body2 = [...expectObjArgs(obj1), ...expectObjArgs(obj2)]; expectClusterCallArgs({ body: body2 }, 2); + const expectedError = expectErrorConflict(obj, { metadata: { isNotOverwritable: true } }); expect(result).toEqual({ - saved_objects: [expectSuccess(obj1), expectErrorConflict(obj), expectSuccess(obj2)], + saved_objects: [expectSuccess(obj1), expectedError, expectSuccess(obj2)], }); }); @@ -851,6 +860,7 @@ describe('SavedObjectsRepository', () => { id: '1', }, ], + originId: 'some-origin-id', // only one of the results has an originId, this is intentional to test both a positive and negative case }; const obj2 = { type: 'index-pattern', @@ -986,6 +996,7 @@ describe('SavedObjectsRepository', () => { type, id, ...(doc._source.namespaces && { namespaces: doc._source.namespaces }), + ...(doc._source.originId && { originId: doc._source.originId }), ...(doc._source.updated_at && { updated_at: doc._source.updated_at }), version: encodeHitVersion(doc), attributes: doc._source[type], @@ -1052,27 +1063,35 @@ describe('SavedObjectsRepository', () => { attributes: { title: 'Test Two' }, }; const references = [{ name: 'ref_0', type: 'test', id: '1' }]; + const originId = 'some-origin-id'; const namespace = 'foo-namespace'; - const getMockBulkUpdateResponse = (objects, options) => ({ + const getMockBulkUpdateResponse = (objects, options, includeOriginId) => ({ items: objects.map(({ type, id }) => ({ update: { _id: `${ registry.isSingleNamespace(type) && options?.namespace ? `${options?.namespace}:` : '' }${type}:${id}`, ...mockVersionProps, + get: { + _source: { + // "includeOriginId" is not an option for the operation; however, if the existing saved object contains an originId attribute, the + // operation will return it in the result. This flag is just used for test purposes to modify the mock cluster call response. + ...(includeOriginId && { originId }), + }, + }, result: 'updated', }, })), }); - const bulkUpdateSuccess = async (objects, options) => { + const bulkUpdateSuccess = async (objects, options, includeOriginId) => { const multiNamespaceObjects = objects.filter(({ type }) => registry.isMultiNamespace(type)); if (multiNamespaceObjects?.length) { const response = getMockMgetResponse(multiNamespaceObjects, options?.namespace); callAdminCluster.mockResolvedValueOnce(response); // this._callCluster('mget', ...) } - const response = getMockBulkUpdateResponse(objects, options?.namespace); + const response = getMockBulkUpdateResponse(objects, options?.namespace, includeOriginId); callAdminCluster.mockResolvedValue(response); // this._writeToCluster('bulk', ...) const result = await savedObjectsRepository.bulkUpdate(objects, options); expect(callAdminCluster).toHaveBeenCalledTimes(multiNamespaceObjects?.length ? 2 : 1); @@ -1350,9 +1369,10 @@ describe('SavedObjectsRepository', () => { }); describe('returns', () => { - const expectSuccessResult = ({ type, id, attributes, references }) => ({ + const expectSuccessResult = ({ type, id, attributes, references, originId }) => ({ type, id, + originId, attributes, references, version: mockVersion, @@ -1399,6 +1419,138 @@ describe('SavedObjectsRepository', () => { ], }); }); + + it(`includes originId property if present in cluster call response`, async () => { + const obj = { type: MULTI_NAMESPACE_TYPE, id: 'three' }; + const result = await bulkUpdateSuccess([obj1, obj], {}, true); + expect(result).toEqual({ + saved_objects: [ + expect.objectContaining({ originId }), + expect.objectContaining({ originId }), + ], + }); + }); + }); + }); + + describe('#checkConflicts', () => { + const obj1 = { type: 'dashboard', id: 'one' }; + const obj2 = { type: 'dashboard', id: 'two' }; + const obj3 = { type: MULTI_NAMESPACE_TYPE, id: 'three' }; + const obj4 = { type: MULTI_NAMESPACE_TYPE, id: 'four' }; + const obj5 = { type: MULTI_NAMESPACE_TYPE, id: 'five' }; + const obj6 = { type: NAMESPACE_AGNOSTIC_TYPE, id: 'six' }; + const obj7 = { type: NAMESPACE_AGNOSTIC_TYPE, id: 'seven' }; + const namespace = 'foo-namespace'; + + const checkConflicts = async (objects, options) => + savedObjectsRepository.checkConflicts( + objects.map(({ type, id }) => ({ type, id })), // checkConflicts only uses type and id + options + ); + const checkConflictsSuccess = async (objects, options) => { + const response = getMockMgetResponse(objects, options?.namespace); + callAdminCluster.mockResolvedValue(response); // this._callCluster('mget', ...) + const result = await checkConflicts(objects, options); + expect(callAdminCluster).toHaveBeenCalledTimes(1); + return result; + }; + + const _expectClusterCallArgs = ( + objects, + { _index = expect.any(String), getId = () => expect.any(String) } + ) => { + expectClusterCallArgs({ + body: { + docs: objects.map(({ type, id }) => + expect.objectContaining({ + _index, + _id: getId(type, id), + }) + ), + }, + }); + }; + + describe('cluster calls', () => { + it(`doesn't make a cluster call if the objects array is empty`, async () => { + await checkConflicts([]); + expect(callAdminCluster).not.toHaveBeenCalled(); + }); + + it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { + const getId = (type, id) => `${namespace}:${type}:${id}`; + await checkConflictsSuccess([obj1, obj2], { namespace }); + _expectClusterCallArgs([obj1, obj2], { getId }); + }); + + it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => { + const getId = (type, id) => `${type}:${id}`; + await checkConflictsSuccess([obj1, obj2]); + _expectClusterCallArgs([obj1, obj2], { getId }); + }); + + it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => { + const getId = (type, id) => `${type}:${id}`; + // obj3 is multi-namespace, and obj6 is namespace-agnostic + await checkConflictsSuccess([obj3, obj6], { namespace }); + _expectClusterCallArgs([obj3, obj6], { getId }); + }); + }); + + describe('migration', () => { + it(`waits until migrations are complete before proceeding`, async () => { + let callAdminClusterCount = 0; + migrator.runMigrations = jest.fn(async () => + // runMigrations should resolve before callAdminCluster is initiated + expect(callAdminCluster).toHaveBeenCalledTimes(callAdminClusterCount++) + ); + await expect(checkConflictsSuccess([obj1, obj2])).resolves.toBeDefined(); + expect(migrator.runMigrations).toHaveReturnedTimes(1); + }); + }); + + describe('returns', () => { + it(`expected results`, async () => { + const unknownTypeObj = { type: 'unknownType', id: 'three' }; + const hiddenTypeObj = { type: HIDDEN_TYPE, id: 'three' }; + const objects = [unknownTypeObj, hiddenTypeObj, obj1, obj2, obj3, obj4, obj5, obj6, obj7]; + const response = { + status: 200, + docs: [ + getMockGetResponse(obj1), + { found: false }, + getMockGetResponse(obj3), + getMockGetResponse({ ...obj4, namespace: 'bar-namespace' }), + { found: false }, + getMockGetResponse(obj6), + { found: false }, + ], + }; + callAdminCluster.mockResolvedValueOnce(response); // this._callCluster('mget', ...) + + const result = await checkConflicts(objects); + expectClusterCalls('mget'); + expect(result).toEqual({ + errors: [ + { ...unknownTypeObj, error: createUnsupportedTypeError(unknownTypeObj.type) }, + { ...hiddenTypeObj, error: createUnsupportedTypeError(hiddenTypeObj.type) }, + { ...obj1, error: createConflictError(obj1.type, obj1.id) }, + // obj2 was not found so it does not result in a conflict error + { ...obj3, error: createConflictError(obj3.type, obj3.id) }, + { + ...obj4, + error: { + ...createConflictError(obj4.type, obj4.id), + metadata: { isNotOverwritable: true }, + }, + }, + // obj5 was not found so it does not result in a conflict error + { ...obj6, error: createConflictError(obj6.type, obj6.id) }, + // obj7 was not found so it does not result in a conflict error + ], + }); + }); }); }); @@ -1414,6 +1566,7 @@ describe('SavedObjectsRepository', () => { const attributes = { title: 'Logstash' }; const id = 'logstash-*'; const namespace = 'foo-namespace'; + const originId = 'some-origin-id'; const references = [ { name: 'ref_0', @@ -1490,6 +1643,20 @@ describe('SavedObjectsRepository', () => { await test(null); }); + it(`defaults to no originId`, async () => { + await createSuccess(type, attributes, { id }); + expectClusterCallArgs({ + body: expect.not.objectContaining({ originId: expect.anything() }), + }); + }); + + it(`accepts custom originId`, async () => { + await createSuccess(type, attributes, { id, originId }); + expectClusterCallArgs({ + body: expect.objectContaining({ originId }), + }); + }); + it(`defaults to a refresh setting of wait_for`, async () => { await createSuccess(type, attributes); expectClusterCallArgs({ refresh: 'wait_for' }); @@ -1643,10 +1810,16 @@ describe('SavedObjectsRepository', () => { describe('returns', () => { it(`formats the ES response`, async () => { - const result = await createSuccess(type, attributes, { id, namespace, references }); + const result = await createSuccess(type, attributes, { + id, + namespace, + references, + originId, + }); expect(result).toEqual({ type, id, + originId, ...mockTimestampFields, version: mockVersion, attributes, @@ -1927,6 +2100,7 @@ describe('SavedObjectsRepository', () => { ...mockVersionProps, _source: { namespace, + originId: 'some-origin-id', // only one of the results has an originId, this is intentional to test both a positive and negative case type: 'index-pattern', ...mockTimestampFields, 'index-pattern': { @@ -2033,6 +2207,7 @@ describe('SavedObjectsRepository', () => { 'references', 'migrationVersion', 'updated_at', + 'originId', 'title', ], }); @@ -2129,6 +2304,7 @@ describe('SavedObjectsRepository', () => { expect(response.saved_objects[i]).toEqual({ id: doc._id.replace(/(index-pattern|config|globalType)\:/, ''), type: doc._source.type, + originId: doc._source.originId, ...mockTimestampFields, version: mockVersion, score: doc._score, @@ -2152,6 +2328,7 @@ describe('SavedObjectsRepository', () => { expect(response.saved_objects[i]).toEqual({ id: doc._id.replace(/(foo-namespace\:)?(index-pattern|config|globalType)\:/, ''), type: doc._source.type, + originId: doc._source.originId, ...mockTimestampFields, version: mockVersion, score: doc._score, @@ -2280,9 +2457,17 @@ describe('SavedObjectsRepository', () => { const type = 'index-pattern'; const id = 'logstash-*'; const namespace = 'foo-namespace'; + const originId = 'some-origin-id'; - const getSuccess = async (type, id, options) => { - const response = getMockGetResponse({ type, id, namespace: options?.namespace }); + const getSuccess = async (type, id, options, includeOriginId) => { + const response = getMockGetResponse({ + type, + id, + namespace: options?.namespace, + // "includeOriginId" is not an option for the operation; however, if the existing saved object contains an originId attribute, the + // operation will return it in the result. This flag is just used for test purposes to modify the mock cluster call response. + ...(includeOriginId && { originId }), + }); callAdminCluster.mockResolvedValue(response); const result = await savedObjectsRepository.get(type, id, options); expect(callAdminCluster).toHaveBeenCalledTimes(1); @@ -2390,6 +2575,11 @@ describe('SavedObjectsRepository', () => { namespaces: expect.anything(), }); }); + + it(`includes originId property if present in cluster call response`, async () => { + const result = await getSuccess(type, id, {}, true); + expect(result).toMatchObject({ originId }); + }); }); }); @@ -2398,6 +2588,7 @@ describe('SavedObjectsRepository', () => { const id = 'one'; const field = 'buildNum'; const namespace = 'foo-namespace'; + const originId = 'some-origin-id'; const incrementCounterSuccess = async (type, id, field, options) => { const isMultiNamespace = registry.isMultiNamespace(type); @@ -2569,6 +2760,7 @@ describe('SavedObjectsRepository', () => { buildNum: 8468, defaultIndex: 'logstash-*', }, + originId, }, }, })); @@ -2591,6 +2783,7 @@ describe('SavedObjectsRepository', () => { buildNum: 8468, defaultIndex: 'logstash-*', }, + originId, }); }); }); @@ -2898,8 +3091,9 @@ describe('SavedObjectsRepository', () => { id: '1', }, ]; + const originId = 'some-origin-id'; - const updateSuccess = async (type, id, attributes, options) => { + const updateSuccess = async (type, id, attributes, options, includeOriginId) => { if (registry.isMultiNamespace(type)) { const mockGetResponse = getMockGetResponse({ type, id, namespace: options?.namespace }); callAdminCluster.mockResolvedValueOnce(mockGetResponse); // this._callCluster('get', ...) @@ -2908,10 +3102,17 @@ describe('SavedObjectsRepository', () => { _id: `${type}:${id}`, ...mockVersionProps, result: 'updated', - ...(registry.isMultiNamespace(type) && { - // don't need the rest of the source for test purposes, just the namespaces attribute - get: { _source: { namespaces: [options?.namespace ?? 'default'] } }, - }), + get: { + _source: { + // don't need the rest of the source for test purposes, just the namespaces attribute + ...(registry.isMultiNamespace(type) && { + namespaces: [options?.namespace ?? 'default'], + }), + // "includeOriginId" is not an option for the operation; however, if the existing saved object contains an originId attribute, the + // operation will return it in the result. This flag is just used for test purposes to modify the mock cluster call response. + ...(includeOriginId && { originId }), + }, + }, }); // this._writeToCluster('update', ...) const result = await savedObjectsRepository.update(type, id, attributes, options); expect(callAdminCluster).toHaveBeenCalledTimes(registry.isMultiNamespace(type) ? 2 : 1); @@ -3009,19 +3210,14 @@ describe('SavedObjectsRepository', () => { expectClusterCallArgs({ id: expect.stringMatching(`${MULTI_NAMESPACE_TYPE}:${id}`) }, 2); }); - it(`includes _sourceIncludes when type is multi-namespace`, async () => { - await updateSuccess(MULTI_NAMESPACE_TYPE, id, attributes); - expectClusterCallArgs({ _sourceIncludes: ['namespaces'] }, 2); + it(`uses default _sourceIncludes when type is not multi-namespace`, async () => { + await updateSuccess(type, id, attributes); + expectClusterCallArgs({ _sourceIncludes: ['originId'] }); }); - it(`doesn't include _sourceIncludes when type is not multi-namespace`, async () => { - await updateSuccess(type, id, attributes); - expect(callAdminCluster).toHaveBeenLastCalledWith( - expect.any(String), - expect.not.objectContaining({ - _sourceIncludes: expect.anything(), - }) - ); + it(`adds to _sourceIncludes when type is multi-namespace`, async () => { + await updateSuccess(MULTI_NAMESPACE_TYPE, id, attributes); + expectClusterCallArgs({ _sourceIncludes: ['originId', 'namespaces'] }, 2); }); }); @@ -3109,6 +3305,11 @@ describe('SavedObjectsRepository', () => { namespaces: expect.anything(), }); }); + + it(`includes originId property if present in cluster call response`, async () => { + const result = await updateSuccess(type, id, attributes, {}, true); + expect(result).toMatchObject({ originId }); + }); }); }); }); diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index f24195c0f295e..d25b62b99925f 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -39,6 +39,8 @@ import { SavedObjectsBulkGetObject, SavedObjectsBulkResponse, SavedObjectsBulkUpdateResponse, + SavedObjectsCheckConflictsObject, + SavedObjectsCheckConflictsResponse, SavedObjectsCreateOptions, SavedObjectsFindResponse, SavedObjectsFindResult, @@ -220,6 +222,7 @@ export class SavedObjectsRepository { overwrite = false, references = [], refresh = DEFAULT_REFRESH_SETTING, + originId, } = options; if (!this._allowedTypes.includes(type)) { @@ -246,6 +249,7 @@ export class SavedObjectsRepository { type, ...(savedObjectNamespace && { namespace: savedObjectNamespace }), ...(savedObjectNamespaces && { namespaces: savedObjectNamespaces }), + originId, attributes, migrationVersion, updated_at: time, @@ -298,8 +302,7 @@ export class SavedObjectsRepository { } const method = object.id && overwrite ? 'index' : 'create'; - const requiresNamespacesCheck = - method === 'index' && this._registry.isMultiNamespace(object.type); + const requiresNamespacesCheck = object.id && this._registry.isMultiNamespace(object.type); if (object.id == null) object.id = uuid.v1(); @@ -351,7 +354,10 @@ export class SavedObjectsRepository { error: { id, type, - error: SavedObjectsErrorHelpers.createConflictError(type, id).output.payload, + error: { + ...SavedObjectsErrorHelpers.createConflictError(type, id).output.payload, + metadata: { isNotOverwritable: true }, + }, }, }; } @@ -377,6 +383,7 @@ export class SavedObjectsRepository { ...(savedObjectNamespaces && { namespaces: savedObjectNamespaces }), updated_at: time, references: object.references || [], + originId: object.originId, }) as SavedObjectSanitizedDoc ), }; @@ -431,6 +438,83 @@ export class SavedObjectsRepository { }; } + /** + * Check what conflicts will result when creating a given array of saved objects. This includes "unresolvable conflicts", which are + * multi-namespace objects that exist in a different namespace; such conflicts cannot be resolved/overwritten. + */ + async checkConflicts( + objects: SavedObjectsCheckConflictsObject[] = [], + options: SavedObjectsBaseOptions = {} + ): Promise { + if (objects.length === 0) { + return { errors: [] }; + } + + const { namespace } = options; + + let bulkGetRequestIndexCounter = 0; + const expectedBulkGetResults: Either[] = objects.map((object) => { + const { type, id } = object; + + if (!this._allowedTypes.includes(type)) { + return { + tag: 'Left' as 'Left', + error: { + id, + type, + error: SavedObjectsErrorHelpers.createUnsupportedTypeError(type).output.payload, + }, + }; + } + + return { + tag: 'Right' as 'Right', + value: { + type, + id, + esRequestIndex: bulkGetRequestIndexCounter++, + }, + }; + }); + + const bulkGetDocs = expectedBulkGetResults.filter(isRight).map(({ value: { type, id } }) => ({ + _id: this._serializer.generateRawId(namespace, type, id), + _index: this.getIndexForType(type), + _source: ['type', 'namespaces'], + })); + const bulkGetResponse = bulkGetDocs.length + ? await this._callCluster('mget', { + body: { docs: bulkGetDocs }, + ignore: [404], + }) + : undefined; + + const errors: SavedObjectsCheckConflictsResponse['errors'] = []; + expectedBulkGetResults.forEach((expectedResult) => { + if (isLeft(expectedResult)) { + errors.push(expectedResult.error as any); + return; + } + + const { type, id, esRequestIndex } = expectedResult.value; + const doc = bulkGetResponse.docs[esRequestIndex]; + if (doc.found) { + errors.push({ + id, + type, + error: { + ...SavedObjectsErrorHelpers.createConflictError(type, id).output.payload, + ...(!this.rawDocExistsInNamespace(doc, namespace) && { + metadata: { isNotOverwritable: true }, + }), + }, + }); + } + }); + + return { errors }; + } + /** * Deletes an object * @@ -584,6 +668,7 @@ export class SavedObjectsRepository { search, defaultSearchOperator = 'OR', searchFields, + rawSearchFields, hasReference, page = 1, perPage = 20, @@ -648,6 +733,7 @@ export class SavedObjectsRepository { search, defaultSearchOperator, searchFields, + rawSearchFields, type: allowedTypes, sortField, sortOrder, @@ -767,12 +853,14 @@ export class SavedObjectsRepository { } as any) as SavedObject; } - const time = doc._source.updated_at; + const { namespaces, originId, updated_at: updatedAt } = doc._source; + return { id, type, - ...(doc._source.namespaces && { namespaces: doc._source.namespaces }), - ...(time && { updated_at: time }), + ...(namespaces && { namespaces }), + ...(originId && { originId }), + ...(updatedAt && { updated_at: updatedAt }), version: encodeHitVersion(doc), attributes: doc._source[type], references: doc._source.references || [], @@ -815,12 +903,13 @@ export class SavedObjectsRepository { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } - const { updated_at: updatedAt } = response._source; + const { namespaces, originId, updated_at: updatedAt } = response._source; return { id, type, - ...(response._source.namespaces && { namespaces: response._source.namespaces }), + ...(namespaces && { namespaces }), + ...(originId && { originId }), ...(updatedAt && { updated_at: updatedAt }), version: encodeHitVersion(response), attributes: response._source[type], @@ -865,6 +954,10 @@ export class SavedObjectsRepository { ...(Array.isArray(references) && { references }), }; + const _sourceIncludes = ['originId']; + if (this._registry.isMultiNamespace(type)) { + _sourceIncludes.push('namespaces'); + } const updateResponse = await this._writeToCluster('update', { id: this._serializer.generateRawId(namespace, type, id), index: this.getIndexForType(type), @@ -874,7 +967,7 @@ export class SavedObjectsRepository { body: { doc, }, - ...(this._registry.isMultiNamespace(type) && { _sourceIncludes: ['namespaces'] }), + _sourceIncludes, }); if (updateResponse.status === 404) { @@ -882,14 +975,14 @@ export class SavedObjectsRepository { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } + const { namespaces, originId } = updateResponse.get._source; return { id, type, + ...(namespaces && { namespaces }), + ...(originId && { originId }), updated_at: time, version: encodeHitVersion(updateResponse), - ...(this._registry.isMultiNamespace(type) && { - namespaces: updateResponse.get._source.namespaces, - }), references, attributes, }; @@ -1176,6 +1269,7 @@ export class SavedObjectsRepository { ? await this._writeToCluster('bulk', { refresh, body: bulkUpdateParams, + _sourceIncludes: ['originId'], }) : {}; @@ -1187,7 +1281,9 @@ export class SavedObjectsRepository { const { type, id, namespaces, documentToSave, esRequestIndex } = expectedResult.value; const response = bulkUpdateResponse.items[esRequestIndex]; - const { error, _seq_no: seqNo, _primary_term: primaryTerm } = Object.values( + // When a bulk update operation is completed, any fields specified in `_sourceIncludes` will be found in the "get" value of the + // returned object. We need to retrieve the `originId` if it exists so we can return it to the consumer. + const { error, _seq_no: seqNo, _primary_term: primaryTerm, get } = Object.values( response )[0] as any; @@ -1199,10 +1295,13 @@ export class SavedObjectsRepository { error: getBulkOperationError(error, type, id), }; } + + const { originId } = get._source; return { id, type, ...(namespaces && { namespaces }), + ...(originId && { originId }), updated_at, version: encodeVersion(seqNo, primaryTerm), attributes, @@ -1227,7 +1326,7 @@ export class SavedObjectsRepository { id: string, counterFieldName: string, options: SavedObjectsIncrementCounterOptions = {} - ) { + ): Promise { if (typeof type !== 'string') { throw new Error('"type" argument must be a string'); } @@ -1290,9 +1389,12 @@ export class SavedObjectsRepository { }, }); + const { originId } = response.get._source; return { id, type, + ...(savedObjectNamespaces && { namespaces: savedObjectNamespaces }), + ...(originId && { originId }), updated_at: time, references: response.get._source.references, version: encodeHitVersion(response), diff --git a/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts b/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts index a0ffa91f53671..7b66f650c11c6 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts @@ -277,13 +277,19 @@ describe('#getQueryParams', () => { }); }); - describe('`searchFields` parameter', () => { + describe('`searchFields` and `rawSearchFields` parameters', () => { const getExpectedFields = (searchFields: string[], typeOrTypes: string | string[]) => { const types = Array.isArray(typeOrTypes) ? typeOrTypes : [typeOrTypes]; return searchFields.map((x) => types.map((y) => `${y}.${x}`)).flat(); }; - const test = (searchFields: string[]) => { + const test = ({ + searchFields, + rawSearchFields, + }: { + searchFields?: string[]; + rawSearchFields?: string[]; + }) => { for (const typeOrTypes of ALL_TYPE_SUBSETS) { const result = getQueryParams({ mappings, @@ -291,8 +297,12 @@ describe('#getQueryParams', () => { type: typeOrTypes, search, searchFields, + rawSearchFields, }); - const fields = getExpectedFields(searchFields, typeOrTypes); + let fields = rawSearchFields || []; + if (searchFields) { + fields = fields.concat(getExpectedFields(searchFields, typeOrTypes)); + } expectResult(result, expect.objectContaining({ fields })); } // also test with no specified type/s @@ -302,31 +312,63 @@ describe('#getQueryParams', () => { type: undefined, search, searchFields, + rawSearchFields, }); - const fields = getExpectedFields(searchFields, ALL_TYPES); + let fields = rawSearchFields || []; + if (searchFields) { + fields = fields.concat(getExpectedFields(searchFields, ALL_TYPES)); + } expectResult(result, expect.objectContaining({ fields })); }; - it('includes lenient flag and all fields when `searchFields` is not specified', () => { + it('throws an error if a raw search field contains a "." character', () => { + expect(() => + getQueryParams({ + mappings, + registry, + type: undefined, + search, + searchFields: undefined, + rawSearchFields: ['foo', 'bar.baz'], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"rawSearchFields entry \\"bar.baz\\" is invalid: cannot contain \\".\\" character"` + ); + }); + + it('includes lenient flag and all fields when `searchFields` and `rawSearchFields` are not specified', () => { const result = getQueryParams({ mappings, registry, search, searchFields: undefined, + rawSearchFields: undefined, }); expectResult(result, expect.objectContaining({ lenient: true, fields: ['*'] })); }); it('includes specified search fields for appropriate type/s', () => { - test(['title']); + test({ searchFields: ['title'] }); }); it('supports boosting', () => { - test(['title^3']); + test({ searchFields: ['title^3'] }); + }); + + it('supports multiple search fields', () => { + test({ searchFields: ['title, title.raw'] }); + }); + + it('includes specified raw search fields', () => { + test({ rawSearchFields: ['_id'] }); + }); + + it('supports multiple raw search fields', () => { + test({ rawSearchFields: ['_id', 'originId'] }); }); - it('supports multiple fields', () => { - test(['title, title.raw']); + it('supports search fields and raw search fields', () => { + test({ searchFields: ['title'], rawSearchFields: ['_id'] }); }); }); diff --git a/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts b/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts index 40485564176a6..663b1f056b353 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts @@ -39,17 +39,27 @@ function getTypes(mappings: IndexMapping, type?: string | string[]) { } /** - * Get the field params based on the types and searchFields + * Get the field params based on the types, searchFields, and rawSearchFields */ -function getFieldsForTypes(types: string[], searchFields?: string[]) { - if (!searchFields || !searchFields.length) { +function getFieldsForTypes( + types: string[], + searchFields: string[] = [], + rawSearchFields: string[] = [] +) { + if (!searchFields.length && !rawSearchFields.length) { return { lenient: true, fields: ['*'], }; } - let fields: string[] = []; + let fields = [...rawSearchFields]; + fields.forEach((field) => { + if (field.indexOf('.') !== -1) { + throw new Error(`rawSearchFields entry "${field}" is invalid: cannot contain "." character`); + } + }); + for (const field of searchFields) { fields = fields.concat(types.map((prefix) => `${prefix}.${field}`)); } @@ -102,6 +112,7 @@ interface QueryParams { type?: string | string[]; search?: string; searchFields?: string[]; + rawSearchFields?: string[]; defaultSearchOperator?: string; hasReference?: HasReferenceQueryParams; kueryNode?: KueryNode; @@ -117,6 +128,7 @@ export function getQueryParams({ type, search, searchFields, + rawSearchFields, defaultSearchOperator, hasReference, kueryNode, @@ -164,7 +176,7 @@ export function getQueryParams({ { simple_query_string: { query: search, - ...getFieldsForTypes(types, searchFields), + ...getFieldsForTypes(types, searchFields, rawSearchFields), ...(defaultSearchOperator ? { default_operator: defaultSearchOperator } : {}), }, }, diff --git a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts index 95b7ffd117ee9..226cf8e187f22 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts @@ -57,12 +57,13 @@ describe('getSearchDsl', () => { }); describe('passes control', () => { - it('passes (mappings, schema, namespace, type, search, searchFields, hasReference) to getQueryParams', () => { + it('passes (mappings, schema, namespace, type, search, searchFields, rawSearchFields, hasReference) to getQueryParams', () => { const opts = { namespace: 'foo-namespace', type: 'foo', search: 'bar', searchFields: ['baz'], + rawSearchFields: ['qux'], defaultSearchOperator: 'AND', hasReference: { type: 'bar', @@ -79,6 +80,7 @@ describe('getSearchDsl', () => { type: opts.type, search: opts.search, searchFields: opts.searchFields, + rawSearchFields: opts.rawSearchFields, defaultSearchOperator: opts.defaultSearchOperator, hasReference: opts.hasReference, }); diff --git a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts index 74c25491aff8b..d612e1006d2f3 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts @@ -31,6 +31,7 @@ interface GetSearchDslOptions { search?: string; defaultSearchOperator?: string; searchFields?: string[]; + rawSearchFields?: string[]; sortField?: string; sortOrder?: string; namespace?: string; @@ -51,6 +52,7 @@ export function getSearchDsl( search, defaultSearchOperator, searchFields, + rawSearchFields, sortField, sortOrder, namespace, @@ -74,6 +76,7 @@ export function getSearchDsl( type, search, searchFields, + rawSearchFields, defaultSearchOperator, hasReference, kueryNode, diff --git a/src/core/server/saved_objects/service/saved_objects_client.mock.ts b/src/core/server/saved_objects/service/saved_objects_client.mock.ts index b209c9ca54f63..3b0789970cc6b 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.mock.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.mock.ts @@ -25,6 +25,7 @@ const create = () => errors: SavedObjectsErrorHelpers, create: jest.fn(), bulkCreate: jest.fn(), + checkConflicts: jest.fn(), bulkUpdate: jest.fn(), delete: jest.fn(), bulkGet: jest.fn(), diff --git a/src/core/server/saved_objects/service/saved_objects_client.test.js b/src/core/server/saved_objects/service/saved_objects_client.test.js index 53bb31369adbf..47011414cbc7f 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.test.js +++ b/src/core/server/saved_objects/service/saved_objects_client.test.js @@ -35,6 +35,21 @@ test(`#create`, async () => { expect(result).toBe(returnValue); }); +test(`#checkConflicts`, async () => { + const returnValue = Symbol(); + const mockRepository = { + checkConflicts: jest.fn().mockResolvedValue(returnValue), + }; + const client = new SavedObjectsClient(mockRepository); + + const objects = Symbol(); + const options = Symbol(); + const result = await client.checkConflicts(objects, options); + + expect(mockRepository.checkConflicts).toHaveBeenCalledWith(objects, options); + expect(result).toBe(returnValue); +}); + test(`#bulkCreate`, async () => { const returnValue = Symbol(); const mockRepository = { diff --git a/src/core/server/saved_objects/service/saved_objects_client.ts b/src/core/server/saved_objects/service/saved_objects_client.ts index e15a92c92772f..eb02ef0dff5bf 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -20,6 +20,7 @@ import { ISavedObjectsRepository } from './lib'; import { SavedObject, + SavedObjectError, SavedObjectReference, SavedObjectsMigrationVersion, SavedObjectsBaseOptions, @@ -42,6 +43,8 @@ export interface SavedObjectsCreateOptions extends SavedObjectsBaseOptions { references?: SavedObjectReference[]; /** The Elasticsearch Refresh setting for this operation */ refresh?: MutatingOperationRefreshSetting; + /** Optional ID of the original saved object, if this object's `id` was regenerated */ + originId?: string; } /** @@ -55,6 +58,8 @@ export interface SavedObjectsBulkCreateObject { references?: SavedObjectReference[]; /** {@inheritDoc SavedObjectsMigrationVersion} */ migrationVersion?: SavedObjectsMigrationVersion; + /** Optional ID of the original saved object, if this object's `id` was regenerated */ + originId?: string; } /** @@ -105,6 +110,27 @@ export interface SavedObjectsFindResponse { page: number; } +/** + * + * @public + */ +export interface SavedObjectsCheckConflictsObject { + id: string; + type: string; +} + +/** + * + * @public + */ +export interface SavedObjectsCheckConflictsResponse { + errors: Array<{ + id: string; + type: string; + error: SavedObjectError; + }>; +} + /** * * @public @@ -232,6 +258,20 @@ export class SavedObjectsClient { return await this._repository.bulkCreate(objects, options); } + /** + * Check what conflicts will result when creating a given array of saved objects. This includes "unresolvable conflicts", which are + * multi-namespace objects that exist in a different namespace; such conflicts cannot be resolved/overwritten. + * + * @param objects + * @param options + */ + async checkConflicts( + objects: SavedObjectsCheckConflictsObject[] = [], + options: SavedObjectsBaseOptions = {} + ): Promise { + return await this._repository.checkConflicts(objects, options); + } + /** * Deletes a SavedObject * diff --git a/src/core/server/saved_objects/types.ts b/src/core/server/saved_objects/types.ts index 2183b47b732f9..92e33bd5bac51 100644 --- a/src/core/server/saved_objects/types.ts +++ b/src/core/server/saved_objects/types.ts @@ -24,7 +24,9 @@ import { PropertyValidators } from './validation'; export { SavedObjectsImportResponse, + SavedObjectsImportSuccess, SavedObjectsImportConflictError, + SavedObjectsImportAmbiguousConflictError, SavedObjectsImportUnsupportedTypeError, SavedObjectsImportMissingReferencesError, SavedObjectsImportUnknownError, @@ -42,6 +44,7 @@ export { SavedObjectAttribute, SavedObjectAttributeSingle, SavedObject, + SavedObjectError, SavedObjectReference, SavedObjectsMigrationVersion, } from '../../types'; @@ -79,6 +82,9 @@ export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions { search?: string; /** The fields to perform the parsed query against. See Elasticsearch Simple Query String `fields` argument for more information */ searchFields?: string[]; + /** The fields to perform the parsed query against. Unlike the `searchFields` argument, these are expected to be raw and will not be + * modified. If used in conjunction with `searchFields`, both are concatenated together. */ + rawSearchFields?: string[]; hasReference?: { type: string; id: string }; defaultSearchOperator?: 'AND' | 'OR'; filter?: string; diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 5973e300e098e..3b078e5c2d252 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -835,7 +835,7 @@ export interface ImageValidation { } // @public -export function importSavedObjectsFromStream({ readStream, objectLimit, overwrite, savedObjectsClient, supportedTypes, namespace, }: SavedObjectsImportOptions): Promise; +export function importSavedObjectsFromStream({ readStream, objectLimit, overwrite, trueCopy, savedObjectsClient, typeRegistry, namespace, }: SavedObjectsImportOptions): Promise; // @public (undocumented) export interface IndexSettingsDeprecationInfo { @@ -1709,7 +1709,7 @@ export type RequestHandlerContextProvider(handler: RequestHandler) => RequestHandler; // @public -export function resolveSavedObjectsImportErrors({ readStream, objectLimit, retries, savedObjectsClient, supportedTypes, namespace, }: SavedObjectsResolveImportErrorsOptions): Promise; +export function resolveSavedObjectsImportErrors({ readStream, objectLimit, retries, savedObjectsClient, typeRegistry, namespace, trueCopy, }: SavedObjectsResolveImportErrorsOptions): Promise; // @public export type ResponseError = string | Error | { @@ -1811,14 +1811,14 @@ export type SafeRouteMethod = 'get' | 'options'; // @public (undocumented) export interface SavedObject { attributes: T; + // Warning: (ae-forgotten-export) The symbol "SavedObjectError" needs to be exported by the entry point index.d.ts + // // (undocumented) - error?: { - message: string; - statusCode: number; - }; + error?: SavedObjectError; id: string; migrationVersion?: SavedObjectsMigrationVersion; namespaces?: string[]; + originId?: string; references: SavedObjectReference[]; type: string; updated_at?: string; @@ -1885,6 +1885,7 @@ export interface SavedObjectsBulkCreateObject { // (undocumented) id?: string; migrationVersion?: SavedObjectsMigrationVersion; + originId?: string; // (undocumented) references?: SavedObjectReference[]; // (undocumented) @@ -1930,6 +1931,24 @@ export interface SavedObjectsBulkUpdateResponse { saved_objects: Array>; } +// @public (undocumented) +export interface SavedObjectsCheckConflictsObject { + // (undocumented) + id: string; + // (undocumented) + type: string; +} + +// @public (undocumented) +export interface SavedObjectsCheckConflictsResponse { + // (undocumented) + errors: Array<{ + id: string; + type: string; + error: SavedObjectError; + }>; +} + // @public (undocumented) export class SavedObjectsClient { // @internal @@ -1938,6 +1957,7 @@ export class SavedObjectsClient { bulkCreate(objects: Array>, options?: SavedObjectsCreateOptions): Promise>; bulkGet(objects?: SavedObjectsBulkGetObject[], options?: SavedObjectsBaseOptions): Promise>; bulkUpdate(objects: Array>, options?: SavedObjectsBulkUpdateOptions): Promise>; + checkConflicts(objects?: SavedObjectsCheckConflictsObject[], options?: SavedObjectsBaseOptions): Promise; create(type: string, attributes: T, options?: SavedObjectsCreateOptions): Promise>; delete(type: string, id: string, options?: SavedObjectsDeleteOptions): Promise<{}>; deleteFromNamespaces(type: string, id: string, namespaces: string[], options?: SavedObjectsDeleteFromNamespacesOptions): Promise<{}>; @@ -2014,6 +2034,7 @@ export interface SavedObjectsCoreFieldMapping { export interface SavedObjectsCreateOptions extends SavedObjectsBaseOptions { id?: string; migrationVersion?: SavedObjectsMigrationVersion; + originId?: string; overwrite?: boolean; // (undocumented) references?: SavedObjectReference[]; @@ -2132,6 +2153,7 @@ export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions { // (undocumented) perPage?: number; preference?: string; + rawSearchFields?: string[]; search?: string; searchFields?: string[]; // (undocumented) @@ -2159,8 +2181,22 @@ export interface SavedObjectsFindResult extends SavedObject { score: number; } +// @public +export interface SavedObjectsImportAmbiguousConflictError { + // (undocumented) + destinations: Array<{ + id: string; + title?: string; + updatedAt?: string; + }>; + // (undocumented) + type: 'ambiguous_conflict'; +} + // @public export interface SavedObjectsImportConflictError { + // (undocumented) + destinationId?: string; // (undocumented) type: 'conflict'; } @@ -2168,7 +2204,7 @@ export interface SavedObjectsImportConflictError { // @public export interface SavedObjectsImportError { // (undocumented) - error: SavedObjectsImportConflictError | SavedObjectsImportUnsupportedTypeError | SavedObjectsImportMissingReferencesError | SavedObjectsImportUnknownError; + error: SavedObjectsImportConflictError | SavedObjectsImportAmbiguousConflictError | SavedObjectsImportUnsupportedTypeError | SavedObjectsImportMissingReferencesError | SavedObjectsImportUnknownError; // (undocumented) id: string; // (undocumented) @@ -2197,10 +2233,13 @@ export interface SavedObjectsImportMissingReferencesError { export interface SavedObjectsImportOptions { namespace?: string; objectLimit: number; + // @deprecated (undocumented) overwrite: boolean; readStream: Readable; savedObjectsClient: SavedObjectsClientContract; - supportedTypes: string[]; + // @deprecated (undocumented) + trueCopy: boolean; + typeRegistry: ISavedObjectTypeRegistry; } // @public @@ -2211,10 +2250,13 @@ export interface SavedObjectsImportResponse { success: boolean; // (undocumented) successCount: number; + // (undocumented) + successResults?: SavedObjectsImportSuccess[]; } // @public export interface SavedObjectsImportRetry { + destinationId?: string; // (undocumented) id: string; // (undocumented) @@ -2225,6 +2267,19 @@ export interface SavedObjectsImportRetry { from: string; to: string; }>; + // @deprecated (undocumented) + trueCopy?: boolean; + // (undocumented) + type: string; +} + +// @public +export interface SavedObjectsImportSuccess { + destinationId?: string; + // (undocumented) + id: string; + // @deprecated (undocumented) + trueCopy?: boolean; // (undocumented) type: string; } @@ -2330,6 +2385,7 @@ export class SavedObjectsRepository { bulkCreate(objects: Array>, options?: SavedObjectsCreateOptions): Promise>; bulkGet(objects?: SavedObjectsBulkGetObject[], options?: SavedObjectsBaseOptions): Promise>; bulkUpdate(objects: Array>, options?: SavedObjectsBulkUpdateOptions): Promise>; + checkConflicts(objects?: SavedObjectsCheckConflictsObject[], options?: SavedObjectsBaseOptions): Promise; create(type: string, attributes: T, options?: SavedObjectsCreateOptions): Promise>; // Warning: (ae-forgotten-export) The symbol "KibanaMigrator" needs to be exported by the entry point index.d.ts // @@ -2339,16 +2395,9 @@ export class SavedObjectsRepository { deleteByNamespace(namespace: string, options?: SavedObjectsDeleteByNamespaceOptions): Promise; deleteFromNamespaces(type: string, id: string, namespaces: string[], options?: SavedObjectsDeleteFromNamespacesOptions): Promise<{}>; // (undocumented) - find({ search, defaultSearchOperator, searchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespace, type, filter, preference, }: SavedObjectsFindOptions): Promise>; + find({ search, defaultSearchOperator, searchFields, rawSearchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespace, type, filter, preference, }: SavedObjectsFindOptions): Promise>; get(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>; - incrementCounter(type: string, id: string, counterFieldName: string, options?: SavedObjectsIncrementCounterOptions): Promise<{ - id: string; - type: string; - updated_at: string; - references: any; - version: string; - attributes: any; - }>; + incrementCounter(type: string, id: string, counterFieldName: string, options?: SavedObjectsIncrementCounterOptions): Promise; update(type: string, id: string, attributes: Partial, options?: SavedObjectsUpdateOptions): Promise>; } @@ -2365,7 +2414,9 @@ export interface SavedObjectsResolveImportErrorsOptions { readStream: Readable; retries: SavedObjectsImportRetry[]; savedObjectsClient: SavedObjectsClientContract; - supportedTypes: string[]; + // @deprecated (undocumented) + trueCopy: boolean; + typeRegistry: ISavedObjectTypeRegistry; } // @internal @deprecated (undocumented) diff --git a/src/core/types/saved_objects.ts b/src/core/types/saved_objects.ts index 04aaacc3cf31a..9abc093c74fb3 100644 --- a/src/core/types/saved_objects.ts +++ b/src/core/types/saved_objects.ts @@ -86,10 +86,7 @@ export interface SavedObject { version?: string; /** Timestamp of the last time this document had been updated. */ updated_at?: string; - error?: { - message: string; - statusCode: number; - }; + error?: SavedObjectError; /** {@inheritdoc SavedObjectAttributes} */ attributes: T; /** {@inheritdoc SavedObjectReference} */ @@ -98,4 +95,18 @@ export interface SavedObject { migrationVersion?: SavedObjectsMigrationVersion; /** Namespace(s) that this saved object exists in. This attribute is only used for multi-namespace saved object types. */ namespaces?: string[]; + /** + * The ID of the saved object this originated from. This is set if this object's `id` was regenerated; that can happen during migration + * from a legacy single-namespace type, or during import. It is only set during migration or create operations. This is used during import + * to ensure that ID regeneration is deterministic, so saved objects will be overwritten if they are imported multiple times into a given + * space. + */ + originId?: string; +} + +export interface SavedObjectError { + error: string; + message: string; + statusCode: number; + metadata?: Record; } diff --git a/src/plugins/saved_objects_management/public/lib/process_import_response.test.ts b/src/plugins/saved_objects_management/public/lib/process_import_response.test.ts index c1a153b800550..cd35d4d726400 100644 --- a/src/plugins/saved_objects_management/public/lib/process_import_response.test.ts +++ b/src/plugins/saved_objects_management/public/lib/process_import_response.test.ts @@ -19,6 +19,7 @@ import { SavedObjectsImportConflictError, + SavedObjectsImportAmbiguousConflictError, SavedObjectsImportUnknownError, SavedObjectsImportMissingReferencesError, } from 'src/core/public'; @@ -35,7 +36,7 @@ describe('processImportResponse()', () => { expect(result.importCount).toBe(0); }); - test('conflict errors get added to failedImports', () => { + test('conflict errors get added to failedImports and result in idle status', () => { const response = { success: false, successCount: 0, @@ -63,9 +64,41 @@ describe('processImportResponse()', () => { }, ] `); + expect(result.status).toBe('idle'); }); - test('unknown errors get added to failedImports', () => { + test('ambiguous conflict errors get added to failedImports and result in idle status', () => { + const response = { + success: false, + successCount: 0, + errors: [ + { + type: 'a', + id: '1', + error: { + type: 'ambiguous_conflict', + } as SavedObjectsImportAmbiguousConflictError, + }, + ], + }; + const result = processImportResponse(response); + expect(result.failedImports).toMatchInlineSnapshot(` + Array [ + Object { + "error": Object { + "type": "ambiguous_conflict", + }, + "obj": Object { + "id": "1", + "type": "a", + }, + }, + ] + `); + expect(result.status).toBe('idle'); + }); + + test('unknown errors get added to failedImports and result in success status', () => { const response = { success: false, successCount: 0, @@ -93,9 +126,10 @@ describe('processImportResponse()', () => { }, ] `); + expect(result.status).toBe('success'); }); - test('missing references get added to failedImports', () => { + test('missing references get added to failedImports and result in idle status', () => { const response = { success: false, successCount: 0, @@ -135,5 +169,6 @@ describe('processImportResponse()', () => { }, ] `); + expect(result.status).toBe('idle'); }); }); diff --git a/src/plugins/saved_objects_management/public/lib/process_import_response.ts b/src/plugins/saved_objects_management/public/lib/process_import_response.ts index 4725000aa9d55..ece8c924f8885 100644 --- a/src/plugins/saved_objects_management/public/lib/process_import_response.ts +++ b/src/plugins/saved_objects_management/public/lib/process_import_response.ts @@ -20,6 +20,7 @@ import { SavedObjectsImportResponse, SavedObjectsImportConflictError, + SavedObjectsImportAmbiguousConflictError, SavedObjectsImportUnsupportedTypeError, SavedObjectsImportMissingReferencesError, SavedObjectsImportUnknownError, @@ -30,6 +31,7 @@ export interface FailedImport { obj: Pick; error: | SavedObjectsImportConflictError + | SavedObjectsImportAmbiguousConflictError | SavedObjectsImportUnsupportedTypeError | SavedObjectsImportMissingReferencesError | SavedObjectsImportUnknownError; @@ -48,6 +50,9 @@ export interface ProcessedImportResponse { conflictedSearchDocs: undefined; } +const isConflict = ({ type }: FailedImport['error']) => + type === 'conflict' || type === 'ambiguous_conflict'; + export function processImportResponse( response: SavedObjectsImportResponse ): ProcessedImportResponse { @@ -80,8 +85,7 @@ export function processImportResponse( // Import won't be successful in the scenario unmatched references exist, import API returned errors of type unknown or import API // returned errors of type missing_references. status: - unmatchedReferences.size === 0 && - !failedImports.some((issue) => issue.error.type === 'conflict') + unmatchedReferences.size === 0 && !failedImports.some((issue) => isConflict(issue.error)) ? 'success' : 'idle', importCount: response.successCount, diff --git a/test/api_integration/apis/saved_objects/import.js b/test/api_integration/apis/saved_objects/import.js index fbacfe458d976..fe0849eb7bcab 100644 --- a/test/api_integration/apis/saved_objects/import.js +++ b/test/api_integration/apis/saved_objects/import.js @@ -40,6 +40,11 @@ export default function ({ getService }) { expect(resp.body).to.eql({ success: true, successCount: 3, + successResults: [ + { type: 'index-pattern', id: '91200a00-9efd-11e7-acb3-3dab96693fab' }, + { type: 'visualization', id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab' }, + { type: 'dashboard', id: 'be3733a0-9efe-11e7-acb3-3dab96693fab' }, + ], }); }); }); @@ -108,6 +113,11 @@ export default function ({ getService }) { expect(resp.body).to.eql({ success: true, successCount: 3, + successResults: [ + { type: 'index-pattern', id: '91200a00-9efd-11e7-acb3-3dab96693fab' }, + { type: 'visualization', id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab' }, + { type: 'dashboard', id: 'be3733a0-9efe-11e7-acb3-3dab96693fab' }, + ], }); }); }); diff --git a/test/api_integration/apis/saved_objects/migrations.js b/test/api_integration/apis/saved_objects/migrations.js index d0ff4cc06c57e..4a18617cc2143 100644 --- a/test/api_integration/apis/saved_objects/migrations.js +++ b/test/api_integration/apis/saved_objects/migrations.js @@ -292,13 +292,10 @@ export default ({ getService }) => { ); // It only created the original and the dest - assert.deepEqual( - _.pluck( - await callCluster('cat.indices', { index: '.migration-c*', format: 'json' }), - 'index' - ).sort(), - ['.migration-c_1', '.migration-c_2'] - ); + const indices = (await callCluster('cat.indices', { index: '.migration-c*', format: 'json' })) + .map(({ index }) => index) + .sort(); + assert.deepEqual(indices, ['.migration-c_1', '.migration-c_2']); // The docs in the original index are unchanged assert.deepEqual(await fetchDocs({ callCluster, index: `${index}_1` }), [ diff --git a/test/api_integration/apis/saved_objects/resolve_import_errors.js b/test/api_integration/apis/saved_objects/resolve_import_errors.js index aacfcd4382fac..093bfc74c60d4 100644 --- a/test/api_integration/apis/saved_objects/resolve_import_errors.js +++ b/test/api_integration/apis/saved_objects/resolve_import_errors.js @@ -72,6 +72,11 @@ export default function ({ getService }) { expect(resp.body).to.eql({ success: true, successCount: 3, + successResults: [ + { type: 'index-pattern', id: '91200a00-9efd-11e7-acb3-3dab96693fab' }, + { type: 'visualization', id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab' }, + { type: 'dashboard', id: 'be3733a0-9efe-11e7-acb3-3dab96693fab' }, + ], }); }); }); @@ -234,7 +239,15 @@ export default function ({ getService }) { .attach('file', join(__dirname, '../../fixtures/import.ndjson')) .expect(200) .then((resp) => { - expect(resp.body).to.eql({ success: true, successCount: 3 }); + expect(resp.body).to.eql({ + success: true, + successCount: 3, + successResults: [ + { type: 'index-pattern', id: '91200a00-9efd-11e7-acb3-3dab96693fab' }, + { type: 'visualization', id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab' }, + { type: 'dashboard', id: 'be3733a0-9efe-11e7-acb3-3dab96693fab' }, + ], + }); }); }); @@ -254,7 +267,13 @@ export default function ({ getService }) { .attach('file', join(__dirname, '../../fixtures/import.ndjson')) .expect(200) .then((resp) => { - expect(resp.body).to.eql({ success: true, successCount: 1 }); + expect(resp.body).to.eql({ + success: true, + successCount: 1, + successResults: [ + { type: 'visualization', id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab' }, + ], + }); }); }); @@ -298,6 +317,7 @@ export default function ({ getService }) { expect(resp.body).to.eql({ success: true, successCount: 1, + successResults: [{ type: 'visualization', id: '1' }], }); }); await supertest diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts index eea19bb1aa7dd..d08c715f7dbfa 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts @@ -42,6 +42,19 @@ beforeEach(() => { afterEach(() => jest.clearAllMocks()); +describe('#checkConflicts', () => { + it('redirects request to underlying base client', async () => { + const objects = [{ type: 'foo', id: 'bar' }]; + const options = { namespace: 'some-namespace' }; + const mockedResponse = { errors: [] }; + mockBaseClient.checkConflicts.mockResolvedValue(mockedResponse); + + await expect(wrapper.checkConflicts(objects, options)).resolves.toEqual(mockedResponse); + expect(mockBaseClient.checkConflicts).toHaveBeenCalledTimes(1); + expect(mockBaseClient.checkConflicts).toHaveBeenCalledWith(objects, options); + }); +}); + describe('#create', () => { it('redirects request to underlying base client if type is not registered', async () => { const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }; diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts index bdc2b6cb2e667..e1819367b4c58 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts @@ -13,6 +13,7 @@ import { SavedObjectsBulkUpdateObject, SavedObjectsBulkResponse, SavedObjectsBulkUpdateResponse, + SavedObjectsCheckConflictsObject, SavedObjectsClientContract, SavedObjectsCreateOptions, SavedObjectsFindOptions, @@ -51,6 +52,13 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon private getDescriptorNamespace = (type: string, namespace?: string) => this.options.baseTypeRegistry.isSingleNamespace(type) ? namespace : undefined; + public async checkConflicts( + objects: SavedObjectsCheckConflictsObject[] = [], + options?: SavedObjectsBaseOptions + ) { + return await this.options.baseClient.checkConflicts(objects, options); + } + public async create( type: string, attributes: T = {} as T, diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/get_objects.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/get_objects.ts index b623295c5e060..f1c22b7b38194 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/get_objects.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/get_objects.ts @@ -9,7 +9,11 @@ import { AssetType } from '../../../types'; import * as Registry from '../registry'; type ArchiveAsset = Pick; -type SavedObjectToBe = Required & { type: AssetType }; +type SavedObjectToBe = Required< + Pick +> & { + type: AssetType; +}; export async function getObject(key: string) { const buffer = Registry.getAsset(key); diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts index c646cd95228f0..01a9504c62fed 100644 --- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts +++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts @@ -61,7 +61,7 @@ const expectGeneralError = async (fn: Function, args: Record) => { * Requires that function args are passed in as key/value pairs * The argument properties must be in the correct order to be spread properly */ -const expectForbiddenError = async (fn: Function, args: Record) => { +const expectForbiddenError = async (fn: Function, args: Record, action?: string) => { clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( getMockCheckPrivilegesFailure ); @@ -84,7 +84,7 @@ const expectForbiddenError = async (fn: Function, args: Record) => expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledTimes(1); expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( USERNAME, - ACTION, + action ?? ACTION, types, spaceIds, missing, @@ -93,7 +93,7 @@ const expectForbiddenError = async (fn: Function, args: Record) => expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); }; -const expectSuccess = async (fn: Function, args: Record) => { +const expectSuccess = async (fn: Function, args: Record, action?: string) => { const result = await fn.bind(client)(...Object.values(args)); const getCalls = (clientOpts.actions.savedObject.get as jest.MockedFunction< SavedObjectActions['get'] @@ -106,7 +106,7 @@ const expectSuccess = async (fn: Function, args: Record) => { expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledTimes(1); expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith( USERNAME, - ACTION, + action ?? ACTION, types, spaceIds, args @@ -474,6 +474,40 @@ describe('#bulkUpdate', () => { }); }); +describe('#checkConflicts', () => { + const obj1 = Object.freeze({ type: 'foo', id: 'foo-id' }); + const obj2 = Object.freeze({ type: 'bar', id: 'bar-id' }); + const options = Object.freeze({ namespace: 'some-ns' }); + + test(`throws decorated GeneralError when checkPrivileges.globally rejects promise`, async () => { + const objects = [obj1, obj2]; + await expectGeneralError(client.checkConflicts, { objects }); + }); + + test(`throws decorated ForbiddenError when unauthorized`, async () => { + const objects = [obj1, obj2]; + await expectForbiddenError(client.checkConflicts, { objects, options }, 'checkConflicts'); + }); + + test(`returns result of baseClient.create when authorized`, async () => { + const apiCallReturnValue = Symbol(); + clientOpts.baseClient.checkConflicts.mockResolvedValue(apiCallReturnValue as any); + + const objects = [obj1, obj2]; + const result = await expectSuccess( + client.checkConflicts, + { objects, options }, + 'checkConflicts' + ); + expect(result).toBe(apiCallReturnValue); + }); + + test(`checks privileges for user, actions, and namespace`, async () => { + const objects = [obj1, obj2]; + await expectPrivilegeCheck(client.checkConflicts, { objects, options }); + }); +}); + describe('#create', () => { const type = 'foo'; const attributes = { some_attr: 's' }; diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts index 969344afae5e3..8cebcf6140ca7 100644 --- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts +++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts @@ -9,6 +9,7 @@ import { SavedObjectsBulkCreateObject, SavedObjectsBulkGetObject, SavedObjectsBulkUpdateObject, + SavedObjectsCheckConflictsObject, SavedObjectsClientContract, SavedObjectsCreateOptions, SavedObjectsFindOptions, @@ -77,6 +78,18 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra return await this.redactSavedObjectNamespaces(savedObject); } + public async checkConflicts( + objects: SavedObjectsCheckConflictsObject[] = [], + options: SavedObjectsBaseOptions = {} + ) { + const types = this.getUniqueObjectTypes(objects); + const args = { objects, options }; + await this.ensureAuthorized(types, 'bulk_create', options.namespace, args, 'checkConflicts'); + + const response = await this.baseClient.checkConflicts(objects, options); + return response; + } + public async bulkCreate( objects: Array>, options: SavedObjectsBaseOptions = {} diff --git a/x-pack/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.test.ts b/x-pack/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.test.ts index 9679dd8c52523..d02e21f9c222d 100644 --- a/x-pack/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.test.ts +++ b/x-pack/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.test.ts @@ -7,10 +7,11 @@ import { SavedObjectsImportResponse, SavedObjectsImportOptions, SavedObjectsExportOptions, + SavedObjectsImportSuccess, } from 'src/core/server'; import { copySavedObjectsToSpacesFactory } from './copy_to_spaces'; import { Readable } from 'stream'; -import { coreMock, savedObjectsTypeRegistryMock, httpServerMock } from 'src/core/server/mocks'; +import { coreMock, httpServerMock } from 'src/core/server/mocks'; jest.mock('../../../../../../src/core/server', () => { return { @@ -53,34 +54,6 @@ describe('copySavedObjectsToSpaces', () => { const setup = (setupOpts: SetupOpts) => { const coreStart = coreMock.createStart(); - const typeRegistry = savedObjectsTypeRegistryMock.create(); - typeRegistry.getAllTypes.mockReturnValue([ - { - name: 'dashboard', - namespaceType: 'single', - hidden: false, - mappings: { properties: {} }, - }, - { - name: 'visualization', - namespaceType: 'single', - hidden: false, - mappings: { properties: {} }, - }, - { - name: 'globaltype', - namespaceType: 'agnostic', - hidden: false, - mappings: { properties: {} }, - }, - ]); - - typeRegistry.isNamespaceAgnostic.mockImplementation((type: string) => - typeRegistry.getAllTypes().some((t) => t.name === type && t.namespaceType === 'agnostic') - ); - - coreStart.savedObjects.getTypeRegistry.mockReturnValue(typeRegistry); - (exportSavedObjectsToStream as jest.Mock).mockImplementation( async (opts: SavedObjectsExportOptions) => { return ( @@ -104,6 +77,9 @@ describe('copySavedObjectsToSpaces', () => { const response: SavedObjectsImportResponse = { success: true, successCount: setupOpts.objects.length, + successResults: [ + ('Some success(es) occurred!' as unknown) as SavedObjectsImportSuccess, + ], }; return Promise.resolve(response); @@ -156,6 +132,7 @@ describe('copySavedObjectsToSpaces', () => { id: 'my-dashboard', }, ], + trueCopy: false, }); expect(result).toMatchInlineSnapshot(` @@ -164,11 +141,17 @@ describe('copySavedObjectsToSpaces', () => { "errors": undefined, "success": true, "successCount": 3, + "successResults": Array [ + "Some success(es) occurred!", + ], }, "destination2": Object { "errors": undefined, "success": true, "successCount": 3, + "successResults": Array [ + "Some success(es) occurred!", + ], }, } `); @@ -192,6 +175,7 @@ describe('copySavedObjectsToSpaces', () => { "bulkCreate": [MockFunction], "bulkGet": [MockFunction], "bulkUpdate": [MockFunction], + "checkConflicts": [MockFunction], "create": [MockFunction], "delete": [MockFunction], "deleteFromNamespaces": [MockFunction], @@ -258,6 +242,7 @@ describe('copySavedObjectsToSpaces', () => { "bulkCreate": [MockFunction], "bulkGet": [MockFunction], "bulkUpdate": [MockFunction], + "checkConflicts": [MockFunction], "create": [MockFunction], "delete": [MockFunction], "deleteFromNamespaces": [MockFunction], @@ -266,10 +251,19 @@ describe('copySavedObjectsToSpaces', () => { "get": [MockFunction], "update": [MockFunction], }, - "supportedTypes": Array [ - "dashboard", - "visualization", - ], + "trueCopy": false, + "typeRegistry": Object { + "getAllTypes": [MockFunction], + "getImportableAndExportableTypes": [MockFunction], + "getIndex": [MockFunction], + "getType": [MockFunction], + "isHidden": [MockFunction], + "isImportableAndExportable": [MockFunction], + "isMultiNamespace": [MockFunction], + "isNamespaceAgnostic": [MockFunction], + "isSingleNamespace": [MockFunction], + "registerType": [MockFunction], + }, }, ], Array [ @@ -323,6 +317,7 @@ describe('copySavedObjectsToSpaces', () => { "bulkCreate": [MockFunction], "bulkGet": [MockFunction], "bulkUpdate": [MockFunction], + "checkConflicts": [MockFunction], "create": [MockFunction], "delete": [MockFunction], "deleteFromNamespaces": [MockFunction], @@ -331,10 +326,19 @@ describe('copySavedObjectsToSpaces', () => { "get": [MockFunction], "update": [MockFunction], }, - "supportedTypes": Array [ - "dashboard", - "visualization", - ], + "trueCopy": false, + "typeRegistry": Object { + "getAllTypes": [MockFunction], + "getImportableAndExportableTypes": [MockFunction], + "getIndex": [MockFunction], + "getType": [MockFunction], + "isHidden": [MockFunction], + "isImportableAndExportable": [MockFunction], + "isMultiNamespace": [MockFunction], + "isNamespaceAgnostic": [MockFunction], + "isSingleNamespace": [MockFunction], + "registerType": [MockFunction], + }, }, ], ] @@ -370,6 +374,7 @@ describe('copySavedObjectsToSpaces', () => { return Promise.resolve({ success: true, successCount: 3, + successResults: [('Some success(es) occurred!' as unknown) as SavedObjectsImportSuccess], }); }, }); @@ -394,6 +399,7 @@ describe('copySavedObjectsToSpaces', () => { id: 'my-dashboard', }, ], + trueCopy: false, } ); @@ -410,11 +416,17 @@ describe('copySavedObjectsToSpaces', () => { "errors": undefined, "success": true, "successCount": 3, + "successResults": Array [ + "Some success(es) occurred!", + ], }, "non-existent-space": Object { "errors": undefined, "success": true, "successCount": 3, + "successResults": Array [ + "Some success(es) occurred!", + ], }, } `); @@ -472,6 +484,7 @@ describe('copySavedObjectsToSpaces', () => { id: 'my-dashboard', }, ], + trueCopy: false, } ) ).rejects.toThrowErrorMatchingInlineSnapshot( diff --git a/x-pack/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.ts b/x-pack/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.ts index dca6f2a6206ab..4f2c83ba1d548 100644 --- a/x-pack/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.ts +++ b/x-pack/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.ts @@ -12,7 +12,6 @@ import { } from '../../../../../../src/core/server'; import { spaceIdToNamespace } from '../utils/namespace'; import { CopyOptions, CopyResponse } from './types'; -import { getEligibleTypes } from './lib/get_eligible_types'; import { createReadableStreamFromArray } from './lib/readable_stream_from_array'; import { createEmptyFailureResponse } from './lib/create_empty_failure_response'; import { readStreamToCompletion } from './lib/read_stream_to_completion'; @@ -27,8 +26,6 @@ export function copySavedObjectsToSpacesFactory( const savedObjectsClient = getScopedClient(request, COPY_TO_SPACES_SAVED_OBJECTS_CLIENT_OPTS); - const eligibleTypes = getEligibleTypes(getTypeRegistry()); - const exportRequestedObjects = async ( sourceSpaceId: string, options: Pick @@ -56,13 +53,15 @@ export function copySavedObjectsToSpacesFactory( objectLimit: getImportExportObjectLimit(), overwrite: options.overwrite, savedObjectsClient, - supportedTypes: eligibleTypes, + typeRegistry: getTypeRegistry(), readStream: objectsStream, + trueCopy: options.trueCopy, }); return { success: importResponse.success, successCount: importResponse.successCount, + successResults: importResponse.successResults, errors: importResponse.errors, }; } catch (error) { diff --git a/x-pack/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.test.ts b/x-pack/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.test.ts index 7bb4c61ed51a0..0778c379c53b9 100644 --- a/x-pack/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.test.ts +++ b/x-pack/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.test.ts @@ -7,8 +7,9 @@ import { SavedObjectsImportResponse, SavedObjectsResolveImportErrorsOptions, SavedObjectsExportOptions, + SavedObjectsImportSuccess, } from 'src/core/server'; -import { coreMock, savedObjectsTypeRegistryMock, httpServerMock } from 'src/core/server/mocks'; +import { coreMock, httpServerMock } from 'src/core/server/mocks'; import { Readable } from 'stream'; import { resolveCopySavedObjectsToSpacesConflictsFactory } from './resolve_copy_conflicts'; @@ -53,34 +54,6 @@ describe('resolveCopySavedObjectsToSpacesConflicts', () => { const setup = (setupOpts: SetupOpts) => { const coreStart = coreMock.createStart(); - const typeRegistry = savedObjectsTypeRegistryMock.create(); - typeRegistry.getAllTypes.mockReturnValue([ - { - name: 'dashboard', - namespaceType: 'single', - hidden: false, - mappings: { properties: {} }, - }, - { - name: 'visualization', - namespaceType: 'single', - hidden: false, - mappings: { properties: {} }, - }, - { - name: 'globaltype', - namespaceType: 'agnostic', - hidden: false, - mappings: { properties: {} }, - }, - ]); - - typeRegistry.isNamespaceAgnostic.mockImplementation((type: string) => - typeRegistry.getAllTypes().some((t) => t.name === type && t.namespaceType === 'agnostic') - ); - - coreStart.savedObjects.getTypeRegistry.mockReturnValue(typeRegistry); - (exportSavedObjectsToStream as jest.Mock).mockImplementation( async (opts: SavedObjectsExportOptions) => { return ( @@ -105,6 +78,9 @@ describe('resolveCopySavedObjectsToSpacesConflicts', () => { const response: SavedObjectsImportResponse = { success: true, successCount: setupOpts.objects.length, + successResults: [ + ('Some success(es) occurred!' as unknown) as SavedObjectsImportSuccess, + ], }; return response; @@ -172,6 +148,7 @@ describe('resolveCopySavedObjectsToSpacesConflicts', () => { }, ], }, + trueCopy: false, }); expect(result).toMatchInlineSnapshot(` @@ -180,11 +157,17 @@ describe('resolveCopySavedObjectsToSpacesConflicts', () => { "errors": undefined, "success": true, "successCount": 3, + "successResults": Array [ + "Some success(es) occurred!", + ], }, "destination2": Object { "errors": undefined, "success": true, "successCount": 3, + "successResults": Array [ + "Some success(es) occurred!", + ], }, } `); @@ -208,6 +191,7 @@ describe('resolveCopySavedObjectsToSpacesConflicts', () => { "bulkCreate": [MockFunction], "bulkGet": [MockFunction], "bulkUpdate": [MockFunction], + "checkConflicts": [MockFunction], "create": [MockFunction], "delete": [MockFunction], "deleteFromNamespaces": [MockFunction], @@ -281,6 +265,7 @@ describe('resolveCopySavedObjectsToSpacesConflicts', () => { "bulkCreate": [MockFunction], "bulkGet": [MockFunction], "bulkUpdate": [MockFunction], + "checkConflicts": [MockFunction], "create": [MockFunction], "delete": [MockFunction], "deleteFromNamespaces": [MockFunction], @@ -289,10 +274,19 @@ describe('resolveCopySavedObjectsToSpacesConflicts', () => { "get": [MockFunction], "update": [MockFunction], }, - "supportedTypes": Array [ - "dashboard", - "visualization", - ], + "trueCopy": false, + "typeRegistry": Object { + "getAllTypes": [MockFunction], + "getImportableAndExportableTypes": [MockFunction], + "getIndex": [MockFunction], + "getType": [MockFunction], + "isHidden": [MockFunction], + "isImportableAndExportable": [MockFunction], + "isMultiNamespace": [MockFunction], + "isNamespaceAgnostic": [MockFunction], + "isSingleNamespace": [MockFunction], + "registerType": [MockFunction], + }, }, ], Array [ @@ -353,6 +347,7 @@ describe('resolveCopySavedObjectsToSpacesConflicts', () => { "bulkCreate": [MockFunction], "bulkGet": [MockFunction], "bulkUpdate": [MockFunction], + "checkConflicts": [MockFunction], "create": [MockFunction], "delete": [MockFunction], "deleteFromNamespaces": [MockFunction], @@ -361,10 +356,19 @@ describe('resolveCopySavedObjectsToSpacesConflicts', () => { "get": [MockFunction], "update": [MockFunction], }, - "supportedTypes": Array [ - "dashboard", - "visualization", - ], + "trueCopy": false, + "typeRegistry": Object { + "getAllTypes": [MockFunction], + "getImportableAndExportableTypes": [MockFunction], + "getIndex": [MockFunction], + "getType": [MockFunction], + "isHidden": [MockFunction], + "isImportableAndExportable": [MockFunction], + "isMultiNamespace": [MockFunction], + "isNamespaceAgnostic": [MockFunction], + "isSingleNamespace": [MockFunction], + "registerType": [MockFunction], + }, }, ], ] @@ -400,6 +404,7 @@ describe('resolveCopySavedObjectsToSpacesConflicts', () => { return Promise.resolve({ success: true, successCount: 3, + successResults: [('Some success(es) occurred!' as unknown) as SavedObjectsImportSuccess], }); }, }); @@ -443,6 +448,7 @@ describe('resolveCopySavedObjectsToSpacesConflicts', () => { }, ], }, + trueCopy: false, }); expect(result).toMatchInlineSnapshot(` @@ -458,11 +464,17 @@ describe('resolveCopySavedObjectsToSpacesConflicts', () => { "errors": undefined, "success": true, "successCount": 3, + "successResults": Array [ + "Some success(es) occurred!", + ], }, "non-existent-space": Object { "errors": undefined, "success": true, "successCount": 3, + "successResults": Array [ + "Some success(es) occurred!", + ], }, } `); @@ -496,6 +508,7 @@ describe('resolveCopySavedObjectsToSpacesConflicts', () => { includeReferences: true, objects: [], retries: {}, + trueCopy: false, }) ).rejects.toThrowErrorMatchingInlineSnapshot( `"Something went wrong while reading this stream"` diff --git a/x-pack/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.ts b/x-pack/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.ts index a355d19b305a3..d8bbb3e2c2644 100644 --- a/x-pack/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.ts +++ b/x-pack/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.ts @@ -5,14 +5,13 @@ */ import { Readable } from 'stream'; -import { SavedObject, CoreStart, KibanaRequest } from 'src/core/server'; +import { SavedObject, CoreStart, KibanaRequest, SavedObjectsImportRetry } from 'src/core/server'; import { exportSavedObjectsToStream, resolveSavedObjectsImportErrors, } from '../../../../../../src/core/server'; import { spaceIdToNamespace } from '../utils/namespace'; import { CopyOptions, ResolveConflictsOptions, CopyResponse } from './types'; -import { getEligibleTypes } from './lib/get_eligible_types'; import { createEmptyFailureResponse } from './lib/create_empty_failure_response'; import { readStreamToCompletion } from './lib/read_stream_to_completion'; import { createReadableStreamFromArray } from './lib/readable_stream_from_array'; @@ -27,8 +26,6 @@ export function resolveCopySavedObjectsToSpacesConflictsFactory( const savedObjectsClient = getScopedClient(request, COPY_TO_SPACES_SAVED_OBJECTS_CLIENT_OPTS); - const eligibleTypes = getEligibleTypes(getTypeRegistry()); - const exportRequestedObjects = async ( sourceSpaceId: string, options: Pick @@ -47,26 +44,24 @@ export function resolveCopySavedObjectsToSpacesConflictsFactory( const resolveConflictsForSpace = async ( spaceId: string, objectsStream: Readable, - retries: Array<{ - type: string; - id: string; - overwrite: boolean; - replaceReferences: Array<{ type: string; from: string; to: string }>; - }> + retries: SavedObjectsImportRetry[], + trueCopy: boolean ) => { try { const importResponse = await resolveSavedObjectsImportErrors({ namespace: spaceIdToNamespace(spaceId), objectLimit: getImportExportObjectLimit(), savedObjectsClient, - supportedTypes: eligibleTypes, + typeRegistry: getTypeRegistry(), readStream: objectsStream, retries, + trueCopy, }); return { success: importResponse.success, successCount: importResponse.successCount, + successResults: importResponse.successResults, errors: importResponse.errors, }; } catch (error) { @@ -93,7 +88,8 @@ export function resolveCopySavedObjectsToSpacesConflictsFactory( response[spaceId] = await resolveConflictsForSpace( spaceId, createReadableStreamFromArray(exportedSavedObjects), - retries + retries, + options.trueCopy ); } diff --git a/x-pack/plugins/spaces/server/lib/copy_to_spaces/types.ts b/x-pack/plugins/spaces/server/lib/copy_to_spaces/types.ts index 1bbe5aa6625b0..4301d3790ce60 100644 --- a/x-pack/plugins/spaces/server/lib/copy_to_spaces/types.ts +++ b/x-pack/plugins/spaces/server/lib/copy_to_spaces/types.ts @@ -5,26 +5,33 @@ */ import { Payload } from 'boom'; -import { SavedObjectsImportError } from 'src/core/server'; +import { + SavedObjectsImportSuccess, + SavedObjectsImportError, + SavedObjectsImportRetry, +} from 'src/core/server'; export interface CopyOptions { objects: Array<{ type: string; id: string }>; overwrite: boolean; includeReferences: boolean; + trueCopy: boolean; } export interface ResolveConflictsOptions { objects: Array<{ type: string; id: string }>; includeReferences: boolean; retries: { - [spaceId: string]: Array<{ type: string; id: string; overwrite: boolean }>; + [spaceId: string]: Array>; }; + trueCopy: boolean; } export interface CopyResponse { [spaceId: string]: { success: boolean; successCount: number; + successResults?: SavedObjectsImportSuccess[]; errors?: Array; }; } diff --git a/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_mock_so_service.ts b/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_mock_so_service.ts index 034d212a33035..ce93591f492f1 100644 --- a/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_mock_so_service.ts +++ b/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_mock_so_service.ts @@ -43,41 +43,6 @@ export const createMockSavedObjectsService = (spaces: any[] = []) => { const { savedObjects } = coreMock.createStart(); const typeRegistry = savedObjectsTypeRegistryMock.create(); - typeRegistry.getAllTypes.mockReturnValue([ - { - name: 'visualization', - namespaceType: 'single', - hidden: false, - mappings: { properties: {} }, - }, - { - name: 'dashboard', - namespaceType: 'single', - hidden: false, - mappings: { properties: {} }, - }, - { - name: 'index-pattern', - namespaceType: 'single', - hidden: false, - mappings: { properties: {} }, - }, - { - name: 'globalType', - namespaceType: 'agnostic', - hidden: false, - mappings: { properties: {} }, - }, - { - name: 'space', - namespaceType: 'agnostic', - hidden: true, - mappings: { properties: {} }, - }, - ]); - typeRegistry.isNamespaceAgnostic.mockImplementation((type: string) => - typeRegistry.getAllTypes().some((t) => t.name === type && t.namespaceType === 'agnostic') - ); savedObjects.getTypeRegistry.mockReturnValue(typeRegistry); savedObjects.getScopedClient.mockReturnValue(mockSavedObjectsClientContract); diff --git a/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts b/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts index b604554cbc59a..77f1751f8a104 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts @@ -191,54 +191,35 @@ describe('copy to space', () => { ); }); - it(`requires objects to be unique`, async () => { + it(`does not allow "overwrite" to be used with "trueCopy"`, async () => { const payload = { spaces: ['a-space'], - objects: [ - { type: 'foo', id: 'bar' }, - { type: 'foo', id: 'bar' }, - ], + objects: [{ type: 'foo', id: 'bar' }], + overwrite: true, + trueCopy: true, }; const { copyToSpace } = await setup(); expect(() => (copyToSpace.routeValidation.body as ObjectType).validate(payload) - ).toThrowErrorMatchingInlineSnapshot(`"[objects]: duplicate objects are not allowed"`); + ).toThrowErrorMatchingInlineSnapshot(`"cannot use [overwrite] with [trueCopy]"`); }); - it('does not allow namespace agnostic types to be copied (via "supportedTypes" property)', async () => { + it(`requires objects to be unique`, async () => { const payload = { spaces: ['a-space'], objects: [ - { type: 'globalType', id: 'bar' }, - { type: 'visualization', id: 'bar' }, + { type: 'foo', id: 'bar' }, + { type: 'foo', id: 'bar' }, ], }; const { copyToSpace } = await setup(); - const request = httpServerMock.createKibanaRequest({ - body: payload, - method: 'post', - }); - - const response = await copyToSpace.routeHandler( - mockRouteContext, - request, - kibanaResponseFactory - ); - - const { status } = response; - - expect(status).toEqual(200); - expect(importSavedObjectsFromStream).toHaveBeenCalledTimes(1); - const [importCallOptions] = (importSavedObjectsFromStream as jest.Mock).mock.calls[0]; - - expect(importCallOptions).toMatchObject({ - namespace: 'a-space', - supportedTypes: ['visualization', 'dashboard', 'index-pattern'], - }); + expect(() => + (copyToSpace.routeValidation.body as ObjectType).validate(payload) + ).toThrowErrorMatchingInlineSnapshot(`"[objects]: duplicate objects are not allowed"`); }); it('copies to multiple spaces', async () => { @@ -365,58 +346,6 @@ describe('copy to space', () => { ); }); - it('does not allow namespace agnostic types to be copied (via "supportedTypes" property)', async () => { - const payload = { - retries: { - ['a-space']: [ - { - type: 'visualization', - id: 'bar', - overwrite: true, - }, - { - type: 'globalType', - id: 'bar', - overwrite: true, - }, - ], - }, - objects: [ - { - type: 'globalType', - id: 'bar', - }, - { type: 'visualization', id: 'bar' }, - ], - }; - - const { resolveConflicts } = await setup(); - - const request = httpServerMock.createKibanaRequest({ - body: payload, - method: 'post', - }); - - const response = await resolveConflicts.routeHandler( - mockRouteContext, - request, - kibanaResponseFactory - ); - - const { status } = response; - - expect(status).toEqual(200); - expect(resolveSavedObjectsImportErrors).toHaveBeenCalledTimes(1); - const [ - resolveImportErrorsCallOptions, - ] = (resolveSavedObjectsImportErrors as jest.Mock).mock.calls[0]; - - expect(resolveImportErrorsCallOptions).toMatchObject({ - namespace: 'a-space', - supportedTypes: ['visualization', 'dashboard', 'index-pattern'], - }); - }); - it('resolves conflicts for multiple spaces', async () => { const payload = { objects: [{ type: 'visualization', id: 'bar' }], @@ -459,19 +388,13 @@ describe('copy to space', () => { resolveImportErrorsFirstCallOptions, ] = (resolveSavedObjectsImportErrors as jest.Mock).mock.calls[0]; - expect(resolveImportErrorsFirstCallOptions).toMatchObject({ - namespace: 'a-space', - supportedTypes: ['visualization', 'dashboard', 'index-pattern'], - }); + expect(resolveImportErrorsFirstCallOptions).toMatchObject({ namespace: 'a-space' }); const [ resolveImportErrorsSecondCallOptions, ] = (resolveSavedObjectsImportErrors as jest.Mock).mock.calls[1]; - expect(resolveImportErrorsSecondCallOptions).toMatchObject({ - namespace: 'b-space', - supportedTypes: ['visualization', 'dashboard', 'index-pattern'], - }); + expect(resolveImportErrorsSecondCallOptions).toMatchObject({ namespace: 'b-space' }); }); }); }); diff --git a/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.ts b/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.ts index 4c9f62503a21b..9161d574cddee 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.ts @@ -30,39 +30,49 @@ export function initCopyToSpacesApi(deps: ExternalRouteDeps) { tags: ['access:copySavedObjectsToSpaces'], }, validate: { - body: schema.object({ - spaces: schema.arrayOf( - schema.string({ - validate: (value) => { - if (!SPACE_ID_REGEX.test(value)) { - return `lower case, a-z, 0-9, "_", and "-" are allowed`; - } - }, - }), - { - validate: (spaceIds) => { - if (_.uniq(spaceIds).length !== spaceIds.length) { - return 'duplicate space ids are not allowed'; - } - }, - } - ), - objects: schema.arrayOf( - schema.object({ - type: schema.string(), - id: schema.string(), - }), - { - validate: (objects) => { - if (!areObjectsUnique(objects)) { - return 'duplicate objects are not allowed'; - } - }, - } - ), - includeReferences: schema.boolean({ defaultValue: false }), - overwrite: schema.boolean({ defaultValue: false }), - }), + body: schema.object( + { + spaces: schema.arrayOf( + schema.string({ + validate: (value) => { + if (!SPACE_ID_REGEX.test(value)) { + return `lower case, a-z, 0-9, "_", and "-" are allowed`; + } + }, + }), + { + validate: (spaceIds) => { + if (_.uniq(spaceIds).length !== spaceIds.length) { + return 'duplicate space ids are not allowed'; + } + }, + } + ), + objects: schema.arrayOf( + schema.object({ + type: schema.string(), + id: schema.string(), + }), + { + validate: (objects) => { + if (!areObjectsUnique(objects)) { + return 'duplicate objects are not allowed'; + } + }, + } + ), + includeReferences: schema.boolean({ defaultValue: false }), + overwrite: schema.boolean({ defaultValue: false }), + trueCopy: schema.boolean({ defaultValue: false }), + }, + { + validate: (object) => { + if (object.overwrite && object.trueCopy) { + return 'cannot use [overwrite] with [trueCopy]'; + } + }, + } + ), }, }, createLicensedRouteHandler(async (context, request, response) => { @@ -73,12 +83,19 @@ export function initCopyToSpacesApi(deps: ExternalRouteDeps) { getImportExportObjectLimit, request ); - const { spaces: destinationSpaceIds, objects, includeReferences, overwrite } = request.body; + const { + spaces: destinationSpaceIds, + objects, + includeReferences, + overwrite, + trueCopy, + } = request.body; const sourceSpaceId = spacesService.getSpaceId(request); const copyResponse = await copySavedObjectsToSpaces(sourceSpaceId, destinationSpaceIds, { objects, includeReferences, overwrite, + trueCopy, }); return response.ok({ body: copyResponse }); }) @@ -105,6 +122,8 @@ export function initCopyToSpacesApi(deps: ExternalRouteDeps) { type: schema.string(), id: schema.string(), overwrite: schema.boolean({ defaultValue: false }), + destinationId: schema.maybe(schema.string()), + trueCopy: schema.maybe(schema.boolean()), }) ) ), @@ -122,6 +141,7 @@ export function initCopyToSpacesApi(deps: ExternalRouteDeps) { } ), includeReferences: schema.boolean({ defaultValue: false }), + trueCopy: schema.boolean({ defaultValue: false }), }), }, }, @@ -133,7 +153,7 @@ export function initCopyToSpacesApi(deps: ExternalRouteDeps) { getImportExportObjectLimit, request ); - const { objects, includeReferences, retries } = request.body; + const { objects, includeReferences, retries, trueCopy } = request.body; const sourceSpaceId = spacesService.getSpaceId(request); const resolveConflictsResponse = await resolveCopySavedObjectsToSpacesConflicts( sourceSpaceId, @@ -141,6 +161,7 @@ export function initCopyToSpacesApi(deps: ExternalRouteDeps) { objects, includeReferences, retries, + trueCopy, } ); return response.ok({ body: resolveConflictsResponse }); diff --git a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts index 190429d2dacd4..25d562aa69d77 100644 --- a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts +++ b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts @@ -176,6 +176,34 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; }); }); + describe('#checkConflicts', () => { + test(`throws error if options.namespace is specified`, async () => { + const { client } = await createSpacesSavedObjectsClient(); + + await expect( + // @ts-ignore + client.checkConflicts(null, { namespace: 'bar' }) + ).rejects.toThrow(ERROR_NAMESPACE_SPECIFIED); + }); + + test(`supplements options with the current namespace`, async () => { + const { client, baseClient } = await createSpacesSavedObjectsClient(); + const expectedReturnValue = { errors: [] }; + baseClient.checkConflicts.mockReturnValue(Promise.resolve(expectedReturnValue)); + + const objects = Symbol(); + const options = Object.freeze({ foo: 'bar' }); + // @ts-ignore + const actualReturnValue = await client.checkConflicts(objects, options); + + expect(actualReturnValue).toBe(expectedReturnValue); + expect(baseClient.checkConflicts).toHaveBeenCalledWith(objects, { + foo: 'bar', + namespace: currentSpace.expectedNamespace, + }); + }); + }); + describe('#create', () => { test(`throws error if options.namespace is specified`, async () => { const { client } = await createSpacesSavedObjectsClient(); diff --git a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts index 6611725be8b67..acb3c8dbd9551 100644 --- a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts +++ b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts @@ -9,6 +9,7 @@ import { SavedObjectsBulkCreateObject, SavedObjectsBulkGetObject, SavedObjectsBulkUpdateObject, + SavedObjectsCheckConflictsObject, SavedObjectsClientContract, SavedObjectsCreateOptions, SavedObjectsFindOptions, @@ -56,6 +57,25 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract { this.errors = baseClient.errors; } + /** + * Check what conflicts will result when creating a given array of saved objects. This includes "unresolvable conflicts", which are + * multi-namespace objects that exist in a different namespace; such conflicts cannot be resolved/overwritten. + * + * @param objects + * @param options + */ + public async checkConflicts( + objects: SavedObjectsCheckConflictsObject[] = [], + options: SavedObjectsBaseOptions = {} + ) { + throwErrorIfNamespaceSpecified(options); + + return await this.client.checkConflicts(objects, { + ...options, + namespace: spaceIdToNamespace(this.spaceId), + }); + } + /** * Persists an object * 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 d2c14189e2529..4c0447c29c8f9 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 @@ -397,3 +397,91 @@ "type": "doc" } } + +{ + "type": "doc", + "value": { + "id": "sharedtype:conflict_1", + "index": ".kibana", + "source": { + "sharedtype": { + "title": "A shared saved-object in all spaces" + }, + "type": "sharedtype", + "namespaces": ["default", "space_1", "space_2"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "sharedtype:conflict_2a", + "index": ".kibana", + "source": { + "originId": "conflict_2", + "sharedtype": { + "title": "A shared saved-object in all spaces" + }, + "type": "sharedtype", + "namespaces": ["default", "space_1", "space_2"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "sharedtype:conflict_2b", + "index": ".kibana", + "source": { + "originId": "conflict_2", + "sharedtype": { + "title": "A shared saved-object in all spaces" + }, + "type": "sharedtype", + "namespaces": ["default", "space_1", "space_2"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "sharedtype:conflict_3", + "index": ".kibana", + "source": { + "sharedtype": { + "title": "A shared saved-object in all spaces" + }, + "type": "sharedtype", + "namespaces": ["default", "space_1", "space_2"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "sharedtype:conflict_4a", + "index": ".kibana", + "source": { + "originId": "conflict_4", + "sharedtype": { + "title": "A shared saved-object in all spaces" + }, + "type": "sharedtype", + "namespaces": ["default", "space_1", "space_2"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json index 7b5b1d86f6bcc..73f0e536b9295 100644 --- a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json +++ b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json @@ -182,6 +182,9 @@ "namespaces": { "type": "keyword" }, + "originId": { + "type": "keyword" + }, "search": { "properties": { "columns": { diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/saved_object_test_plugin/server/plugin.ts b/x-pack/test/saved_object_api_integration/common/fixtures/saved_object_test_plugin/server/plugin.ts index 0c15ab4bd2f80..45880635586a7 100644 --- a/x-pack/test/saved_object_api_integration/common/fixtures/saved_object_test_plugin/server/plugin.ts +++ b/x-pack/test/saved_object_api_integration/common/fixtures/saved_object_test_plugin/server/plugin.ts @@ -48,6 +48,7 @@ export class Plugin { name: 'sharedtype', hidden: false, namespaceType: 'multiple', + management, mappings, }); core.savedObjects.registerType({ diff --git a/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_utils.ts b/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_utils.ts index de036494caa83..115f4dca8b4bc 100644 --- a/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_utils.ts +++ b/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_utils.ts @@ -161,7 +161,9 @@ export const expectResponses = { expect(actualNamespace).to.eql(spaceId); } if (isMultiNamespace(type)) { - if (id === CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1.id) { + if (['conflict_1', 'conflict_2a', 'conflict_2b', 'conflict_3', 'conflict_4a'].includes(id)) { + expect(actualNamespaces).to.eql([DEFAULT_SPACE_ID, SPACE_1_ID, SPACE_2_ID]); + } else if (id === CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1.id) { expect(actualNamespaces).to.eql([DEFAULT_SPACE_ID, SPACE_1_ID]); } else if (id === CASES.MULTI_NAMESPACE_ONLY_SPACE_1.id) { expect(actualNamespaces).to.eql([SPACE_1_ID]); diff --git a/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts b/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts index dd32c42597c32..707060cedfe66 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts @@ -6,6 +6,7 @@ import expect from '@kbn/expect'; import { SuperTest } from 'supertest'; +import { SavedObjectsErrorHelpers } from '../../../../../src/core/server'; import { SAVED_OBJECT_TEST_CASES as CASES } from '../lib/saved_object_test_cases'; import { SPACES } from '../lib/spaces'; import { @@ -23,6 +24,7 @@ export interface BulkCreateTestDefinition extends TestDefinition { export type BulkCreateTestSuite = TestSuite; export interface BulkCreateTestCase extends TestCase { failure?: 400 | 409; // only used for permitted response case + fail409Param?: string; } const NEW_ATTRIBUTE_KEY = 'title'; // all type mappings include this attribute, for simplicity's sake @@ -56,6 +58,15 @@ export function bulkCreateTestSuiteFactory(es: any, esArchiver: any, supertest: for (let i = 0; i < savedObjects.length; i++) { const object = savedObjects[i]; const testCase = testCaseArray[i]; + if (testCase.failure === 409 && testCase.fail409Param === 'unresolvableConflict') { + const { type, id } = testCase; + const error = SavedObjectsErrorHelpers.createConflictError(type, id); + const payload = { ...error.output.payload, metadata: { isNotOverwritable: true } }; + expect(object.type).to.eql(type); + expect(object.id).to.eql(id); + expect(object.error).to.eql(payload); + continue; + } await expectResponses.permitted(object, testCase); if (!testCase.failure) { expect(object.attributes[NEW_ATTRIBUTE_KEY]).to.eql(NEW_ATTRIBUTE_VAL); diff --git a/x-pack/test/saved_object_api_integration/common/suites/export.ts b/x-pack/test/saved_object_api_integration/common/suites/export.ts index 394693677699f..ce5a3538d2060 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/export.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/export.ts @@ -8,7 +8,7 @@ import { SuperTest } from 'supertest'; import { SAVED_OBJECT_TEST_CASES as CASES } from '../lib/saved_object_test_cases'; import { SPACES } from '../lib/spaces'; import { expectResponses, getUrlPrefix } from '../lib/saved_object_test_utils'; -import { ExpectResponseBody, TestCase, TestDefinition, TestSuite } from '../lib/types'; +import { ExpectResponseBody, TestDefinition, TestSuite } from '../lib/types'; const { DEFAULT: { spaceId: DEFAULT_SPACE_ID }, @@ -20,15 +20,28 @@ export interface ExportTestDefinition extends TestDefinition { request: ReturnType; } export type ExportTestSuite = TestSuite; +interface SuccessResult { + type: string; + id: string; + originId?: string; +} export interface ExportTestCase { title: string; type: string; id?: string; - successResult?: TestCase | TestCase[]; + successResult?: SuccessResult | SuccessResult[]; failure?: 400 | 403; } -export const getTestCases = (spaceId?: string) => ({ +// additional sharedtype objects that exist but do not have common test cases defined +const CID = 'conflict_'; +const CONFLICT_1_OBJ = Object.freeze({ type: 'sharedtype', id: `${CID}1` }); +const CONFLICT_2A_OBJ = Object.freeze({ type: 'sharedtype', id: `${CID}2a`, originId: `${CID}2` }); +const CONFLICT_2B_OBJ = Object.freeze({ type: 'sharedtype', id: `${CID}2b`, originId: `${CID}2` }); +const CONFLICT_3_OBJ = Object.freeze({ type: 'sharedtype', id: `${CID}3` }); +const CONFLICT_4A_OBJ = Object.freeze({ type: 'sharedtype', id: `${CID}4a`, originId: `${CID}4` }); + +export const getTestCases = (spaceId?: string): { [key: string]: ExportTestCase } => ({ singleNamespaceObject: { title: 'single-namespace object', ...(spaceId === SPACE_1_ID @@ -36,7 +49,7 @@ export const getTestCases = (spaceId?: string) => ({ : spaceId === SPACE_2_ID ? CASES.SINGLE_NAMESPACE_SPACE_2 : CASES.SINGLE_NAMESPACE_DEFAULT_SPACE), - } as ExportTestCase, + }, singleNamespaceType: { // this test explicitly ensures that single-namespace objects from other spaces are not returned title: 'single-namespace type', @@ -47,7 +60,7 @@ export const getTestCases = (spaceId?: string) => ({ : spaceId === SPACE_2_ID ? CASES.SINGLE_NAMESPACE_SPACE_2 : CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, - } as ExportTestCase, + }, multiNamespaceObject: { title: 'multi-namespace object', ...(spaceId === SPACE_1_ID @@ -55,30 +68,30 @@ export const getTestCases = (spaceId?: string) => ({ : spaceId === SPACE_2_ID ? CASES.MULTI_NAMESPACE_ONLY_SPACE_2 : CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1), - failure: 400, // multi-namespace types cannot be exported yet - } as ExportTestCase, + }, multiNamespaceType: { title: 'multi-namespace type', type: 'sharedtype', - // successResult: - // spaceId === SPACE_1_ID - // ? [CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, CASES.MULTI_NAMESPACE_ONLY_SPACE_1] - // : spaceId === SPACE_2_ID - // ? CASES.MULTI_NAMESPACE_ONLY_SPACE_2 - // : CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, - failure: 400, // multi-namespace types cannot be exported yet - } as ExportTestCase, + successResult: (spaceId === SPACE_1_ID + ? [CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, CASES.MULTI_NAMESPACE_ONLY_SPACE_1] + : spaceId === SPACE_2_ID + ? [CASES.MULTI_NAMESPACE_ONLY_SPACE_2] + : [CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1] + ) + .concat([CONFLICT_1_OBJ, CONFLICT_2A_OBJ, CONFLICT_2B_OBJ, CONFLICT_3_OBJ, CONFLICT_4A_OBJ]) + .flat(), + }, namespaceAgnosticObject: { title: 'namespace-agnostic object', ...CASES.NAMESPACE_AGNOSTIC, - } as ExportTestCase, + }, namespaceAgnosticType: { title: 'namespace-agnostic type', type: 'globaltype', successResult: CASES.NAMESPACE_AGNOSTIC, - } as ExportTestCase, - hiddenObject: { title: 'hidden object', ...CASES.HIDDEN, failure: 400 } as ExportTestCase, - hiddenType: { title: 'hidden type', type: 'hiddentype', failure: 400 } as ExportTestCase, + }, + hiddenObject: { title: 'hidden object', ...CASES.HIDDEN, failure: 400 }, + hiddenType: { title: 'hidden type', type: 'hiddentype', failure: 400 }, }); export const createRequest = ({ type, id }: ExportTestCase) => id ? { objects: [{ type, id }] } : { type }; @@ -98,7 +111,7 @@ export function exportTestSuiteFactory(esArchiver: any, supertest: SuperTest async ( response: Record ) => { - const { type, id, successResult = { type, id }, failure } = testCase; + const { type, id, successResult = { type, id } as SuccessResult, failure } = testCase; if (failure === 403) { // In export only, the API uses "bulk_get" or "find" depending on the parameters it receives. // The best that could be done here is to have an if statement to ensure at least one of the @@ -125,11 +138,14 @@ export function exportTestSuiteFactory(esArchiver: any, supertest: SuperTest x.id === object.id)!; + expect(expected).not.to.be(undefined); + expect(object.type).to.eql(expected.type); + if (object.originId) { + expect(object.originId).to.eql(expected.originId); + } expect(object.updated_at).to.match(/^[\d-]{10}T[\d:\.]{12}Z$/); // don't test attributes, version, or references } diff --git a/x-pack/test/saved_object_api_integration/common/suites/find.ts b/x-pack/test/saved_object_api_integration/common/suites/find.ts index 13f411fc14fc8..8647f7d90f3b5 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/find.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/find.ts @@ -34,6 +34,14 @@ export interface FindTestCase { failure?: 400 | 403; } +// additional sharedtype objects that exist but do not have common test cases defined +const CID = 'conflict_'; +const CONFLICT_1_OBJ = Object.freeze({ type: 'sharedtype', id: `${CID}1` }); +const CONFLICT_2A_OBJ = Object.freeze({ type: 'sharedtype', id: `${CID}2a`, originId: `${CID}2` }); +const CONFLICT_2B_OBJ = Object.freeze({ type: 'sharedtype', id: `${CID}2b`, originId: `${CID}2` }); +const CONFLICT_3_OBJ = Object.freeze({ type: 'sharedtype', id: `${CID}3` }); +const CONFLICT_4A_OBJ = Object.freeze({ type: 'sharedtype', id: `${CID}4a`, originId: `${CID}4` }); + export const getTestCases = (spaceId?: string) => ({ singleNamespaceType: { title: 'find single-namespace type', @@ -51,12 +59,14 @@ export const getTestCases = (spaceId?: string) => ({ title: 'find multi-namespace type', query: 'type=sharedtype&fields=title', successResult: { - savedObjects: - spaceId === SPACE_1_ID - ? [CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, CASES.MULTI_NAMESPACE_ONLY_SPACE_1] - : spaceId === SPACE_2_ID - ? CASES.MULTI_NAMESPACE_ONLY_SPACE_2 - : CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + savedObjects: (spaceId === SPACE_1_ID + ? [CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, CASES.MULTI_NAMESPACE_ONLY_SPACE_1] + : spaceId === SPACE_2_ID + ? [CASES.MULTI_NAMESPACE_ONLY_SPACE_2] + : [CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1] + ) + .concat([CONFLICT_1_OBJ, CONFLICT_2A_OBJ, CONFLICT_2B_OBJ, CONFLICT_3_OBJ, CONFLICT_4A_OBJ]) + .flat(), }, } as FindTestCase, namespaceAgnosticType: { 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 a5d2ca238d34e..58941cc16b07a 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,49 +8,85 @@ import expect from '@kbn/expect'; import { SuperTest } from 'supertest'; import { SAVED_OBJECT_TEST_CASES as CASES } from '../lib/saved_object_test_cases'; import { SPACES } from '../lib/spaces'; -import { - createRequest, - expectResponses, - getUrlPrefix, - getTestTitle, -} from '../lib/saved_object_test_utils'; +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 }>; + request: Array<{ type: string; id: string; originId?: string }>; + overwrite: boolean; + trueCopy: boolean; } export type ImportTestSuite = TestSuite; export interface ImportTestCase extends TestCase { + originId?: string; + expectedNewId?: string; + successParam?: string; failure?: 400 | 409; // only used for permitted response case + fail409Param?: string; } const NEW_ATTRIBUTE_KEY = 'title'; // all type mappings include this attribute, for simplicity's sake const NEW_ATTRIBUTE_VAL = `New attribute value ${Date.now()}`; -const NEW_SINGLE_NAMESPACE_OBJ = Object.freeze({ type: 'dashboard', id: 'new-dashboard-id' }); -const NEW_MULTI_NAMESPACE_OBJ = Object.freeze({ type: 'sharedtype', id: 'new-sharedtype-id' }); -const NEW_NAMESPACE_AGNOSTIC_OBJ = Object.freeze({ type: 'globaltype', id: 'new-globaltype-id' }); +// these five saved objects already exist in the sample data: +// * id: conflict_1 +// * id: conflict_2a, originId: conflict_2 +// * id: conflict_2b, originId: conflict_2 +// * 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_'; export const TEST_CASES = Object.freeze({ ...CASES, - NEW_SINGLE_NAMESPACE_OBJ, - NEW_MULTI_NAMESPACE_OBJ, - NEW_NAMESPACE_AGNOSTIC_OBJ, + 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_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({ + type: 'sharedtype', + id: `${CID}3a`, + originId: `${CID}3`, + expectedNewId: `${CID}3`, + }), + CONFLICT_4_OBJ: Object.freeze({ type: 'sharedtype', id: `${CID}4`, expectedNewId: `${CID}4a` }), + NEW_SINGLE_NAMESPACE_OBJ: Object.freeze({ type: 'dashboard', id: 'new-dashboard-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' }), +}); + +/** + * Test cases have additional properties that we don't want to send in HTTP Requests + */ +const createRequest = ({ type, id, originId }: ImportTestCase) => ({ + type, + id, + ...(originId && { originId }), +}); + +const getConflictDest = (id: string) => ({ + id, + title: 'A shared saved-object in all spaces', + updatedAt: '2017-09-21T18:59:16.270Z', }); export function importTestSuiteFactory(es: any, esArchiver: any, supertest: SuperTest) { - const expectForbidden = expectResponses.forbidden('bulk_create'); + const expectForbidden = expectResponses.forbidden; const expectResponseBody = ( testCases: ImportTestCase | ImportTestCase[], statusCode: 200 | 403, + singleRequest: boolean, + overwrite: boolean, + trueCopy: boolean, spaceId = SPACES.DEFAULT.spaceId ): ExpectResponseBody => async (response: Record) => { const testCaseArray = Array.isArray(testCases) ? testCases : [testCases]; if (statusCode === 403) { const types = testCaseArray.map((x) => x.type); - await expectForbidden(types)(response); + await expectResponses.forbidden('bulk_create')(types)(response); } else { // permitted - const { success, successCount, errors } = response.body; + const { success, successCount, successResults, errors } = response.body; const expectedSuccesses = testCaseArray.filter((x) => !x.failure); const expectedFailures = testCaseArray.filter((x) => x.failure); expect(success).to.eql(expectedFailures.length === 0); @@ -61,12 +97,52 @@ export function importTestSuiteFactory(es: any, esArchiver: any, supertest: Supe expect(response.body).not.to.have.property('errors'); } for (let i = 0; i < expectedSuccesses.length; i++) { - const { type, id } = expectedSuccesses[i]; - const { _source } = await expectResponses.successCreated(es, spaceId, type, id); - expect(_source[type][NEW_ATTRIBUTE_KEY]).to.eql(NEW_ATTRIBUTE_VAL); + const { type, id, successParam, expectedNewId } = expectedSuccesses[i]; + // we don't know the order of the returned successResults; search for each one + const object = (successResults as Array>).find( + (x) => x.type === type && x.id === id + ); + expect(object).not.to.be(undefined); + const destinationId = object!.destinationId as string; + if (successParam === 'destinationId') { + // Kibana created the object with a different ID than what was specified in the import + // This can happen due to an unresolvable conflict (so the new ID will be random), or due to an inexact match (so the new ID will + // be equal to the ID or originID of the existing object that it inexactly matched) + if (expectedNewId) { + expect(destinationId).to.be(expectedNewId); + } else { + // the new ID was randomly generated + expect(destinationId).to.match(/^[0-9a-f-]{36}$/); + } + } else if (successParam === 'trueCopy' || successParam === 'newOrigin') { + // the new ID was randomly generated + expect(destinationId).to.match(/^[0-9a-f-]{36}$/); + } else { + expect(destinationId).to.be(undefined); + } + + // This assertion is only needed for the case where True Copy mode is disabled and ambiguous source conflicts are detected. When + // True Copy mode is permanently enabled, this field will be removed, and this assertion will be redundant and can be removed too. + const resultTrueCopy = object!.trueCopy as boolean | undefined; + if (successParam === 'newOrigin') { + expect(resultTrueCopy).to.be(true); + } else { + expect(resultTrueCopy).to.be(undefined); + } + + if (!singleRequest || overwrite || trueCopy) { + // even if the object result was a "success" result, it may not have been created if other resolvable errors were returned + const { _source } = await expectResponses.successCreated( + es, + spaceId, + type, + destinationId ?? id + ); + expect(_source[type][NEW_ATTRIBUTE_KEY]).to.eql(NEW_ATTRIBUTE_VAL); + } } for (let i = 0; i < expectedFailures.length; i++) { - const { type, id, failure } = expectedFailures[i]; + const { type, id, failure, fail409Param, 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 @@ -76,7 +152,30 @@ export function importTestSuiteFactory(es: any, esArchiver: any, supertest: Supe expect(object!.error).to.eql({ type: 'unsupported_type' }); } else { // 409 - expect(object!.error).to.eql({ type: 'conflict' }); + let error: Record = { + type: 'conflict', + ...(expectedNewId && { destinationId: expectedNewId }), + }; + if (fail409Param === 'ambiguous_conflict_1a1b') { + // "ambiguous source" conflict + error = { + type: 'ambiguous_conflict', + destinations: [getConflictDest(`${CID}1`)], + }; + } else if (fail409Param === 'ambiguous_conflict_2c') { + // "ambiguous destination" conflict + error = { + type: 'ambiguous_conflict', + destinations: [getConflictDest(`${CID}2a`), getConflictDest(`${CID}2b`)], + }; + } else if (fail409Param === 'ambiguous_conflict_2c2d') { + // "ambiguous source and destination" conflict + error = { + type: 'ambiguous_conflict', + destinations: [getConflictDest(`${CID}2a`), getConflictDest(`${CID}2b`)], + }; + } + expect(object!.error).to.eql(error); } } } @@ -84,7 +183,9 @@ export function importTestSuiteFactory(es: any, esArchiver: any, supertest: Supe const createTestDefinitions = ( testCases: ImportTestCase | ImportTestCase[], forbidden: boolean, - options?: { + options: { + overwrite?: boolean; + trueCopy?: boolean; spaceId?: string; singleRequest?: boolean; responseBodyOverride?: ExpectResponseBody; @@ -92,7 +193,14 @@ export function importTestSuiteFactory(es: any, esArchiver: any, supertest: Supe ): ImportTestDefinition[] => { const cases = Array.isArray(testCases) ? testCases : [testCases]; const responseStatusCode = forbidden ? 403 : 200; - if (!options?.singleRequest) { + const { + overwrite = false, + trueCopy = false, + spaceId, + singleRequest, + responseBodyOverride, + } = options; + if (!singleRequest) { // if we are testing cases that should result in a forbidden response, we can do each case individually // this ensures that multiple test cases of a single type will each result in a forbidden error return cases.map((x) => ({ @@ -100,8 +208,10 @@ export function importTestSuiteFactory(es: any, esArchiver: any, supertest: Supe request: [createRequest(x)], responseStatusCode, responseBody: - options?.responseBodyOverride || - expectResponseBody(x, responseStatusCode, options?.spaceId), + responseBodyOverride || + expectResponseBody(x, responseStatusCode, false, overwrite, trueCopy, spaceId), + overwrite, + trueCopy, })); } // batch into a single request to save time during test execution @@ -111,8 +221,10 @@ export function importTestSuiteFactory(es: any, esArchiver: any, supertest: Supe request: cases.map((x) => createRequest(x)), responseStatusCode, responseBody: - options?.responseBodyOverride || - expectResponseBody(cases, responseStatusCode, options?.spaceId), + responseBodyOverride || + expectResponseBody(cases, responseStatusCode, true, overwrite, trueCopy, spaceId), + overwrite, + trueCopy, }, ]; }; @@ -134,8 +246,9 @@ export function importTestSuiteFactory(es: any, esArchiver: any, supertest: Supe const requestBody = test.request .map((obj) => JSON.stringify({ ...obj, ...attrs })) .join('\n'); + const query = test.overwrite ? '?overwrite=true' : test.trueCopy ? '?trueCopy=true' : ''; await supertest - .post(`${getUrlPrefix(spaceId)}/api/saved_objects/_import`) + .post(`${getUrlPrefix(spaceId)}/api/saved_objects/_import${query}`) .auth(user?.username, user?.password) .attach('file', Buffer.from(requestBody, 'utf8'), 'export.ndjson') .expect(test.responseStatusCode) 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 cb48f26ed645c..52b5610a195e5 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,34 +8,85 @@ import expect from '@kbn/expect'; import { SuperTest } from 'supertest'; import { SAVED_OBJECT_TEST_CASES as CASES } from '../lib/saved_object_test_cases'; import { SPACES } from '../lib/spaces'; -import { - createRequest, - expectResponses, - getUrlPrefix, - getTestTitle, -} from '../lib/saved_object_test_utils'; +import { expectResponses, getUrlPrefix, getTestTitle } from '../lib/saved_object_test_utils'; import { ExpectResponseBody, TestCase, TestDefinition, TestSuite } from '../lib/types'; export interface ResolveImportErrorsTestDefinition extends TestDefinition { - request: Array<{ type: string; id: string }>; + request: { + objects: Array<{ type: string; id: string; originId?: string }>; + retries: Array<{ type: string; id: string; overwrite: boolean; destinationId?: string }>; + }; overwrite: boolean; + trueCopy: boolean; } export type ResolveImportErrorsTestSuite = TestSuite; export interface ResolveImportErrorsTestCase extends TestCase { + originId?: string; + expectedNewId?: string; + successParam?: string; failure?: 400 | 409; // only used for permitted response case } const NEW_ATTRIBUTE_KEY = 'title'; // all type mappings include this attribute, for simplicity's sake const NEW_ATTRIBUTE_VAL = `New attribute value ${Date.now()}`; -const NEW_SINGLE_NAMESPACE_OBJ = Object.freeze({ type: 'dashboard', id: 'new-dashboard-id' }); -const NEW_MULTI_NAMESPACE_OBJ = Object.freeze({ type: 'sharedtype', id: 'new-sharedtype-id' }); -const NEW_NAMESPACE_AGNOSTIC_OBJ = Object.freeze({ type: 'globaltype', id: 'new-globaltype-id' }); +// these five saved objects already exist in the sample data: +// * id: conflict_1 +// * id: conflict_2a, originId: conflict_2 +// * id: conflict_2b, originId: conflict_2 +// * 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 export const TEST_CASES = Object.freeze({ ...CASES, - NEW_SINGLE_NAMESPACE_OBJ, - NEW_MULTI_NAMESPACE_OBJ, - NEW_NAMESPACE_AGNOSTIC_OBJ, + CONFLICT_1A_OBJ: Object.freeze({ + type: 'sharedtype', + id: `conflict_1a`, + originId: `conflict_1`, + expectedNewId: 'some-random-id', + }), + CONFLICT_1B_OBJ: Object.freeze({ + type: 'sharedtype', + id: `conflict_1b`, + originId: `conflict_1`, + expectedNewId: 'another-random-id', + }), + CONFLICT_2C_OBJ: Object.freeze({ + type: 'sharedtype', + id: `conflict_2c`, + originId: `conflict_2`, + expectedNewId: `conflict_2a`, + }), + CONFLICT_3A_OBJ: Object.freeze({ + type: 'sharedtype', + id: `conflict_3a`, + originId: `conflict_3`, + expectedNewId: `conflict_3`, + }), + CONFLICT_4_OBJ: Object.freeze({ + type: 'sharedtype', + id: `conflict_4`, + expectedNewId: `conflict_4a`, + }), +}); + +/** + * Test cases have additional properties that we don't want to send in HTTP Requests + */ +const createRequest = ( + { type, id, originId, expectedNewId, successParam }: ResolveImportErrorsTestCase, + overwrite: boolean +): ResolveImportErrorsTestDefinition['request'] => ({ + objects: [{ type, id, ...(originId && { originId }) }], + retries: [ + { + type, + id, + overwrite, + ...(expectedNewId && { destinationId: expectedNewId }), + ...(successParam === 'newOrigin' && { trueCopy: true }), + }, + ], }); export function resolveImportErrorsTestSuiteFactory( @@ -55,7 +106,7 @@ export function resolveImportErrorsTestSuiteFactory( await expectForbidden(types)(response); } else { // permitted - const { success, successCount, errors } = response.body; + const { success, successCount, successResults, errors } = response.body; const expectedSuccesses = testCaseArray.filter((x) => !x.failure); const expectedFailures = testCaseArray.filter((x) => x.failure); expect(success).to.eql(expectedFailures.length === 0); @@ -66,12 +117,48 @@ export function resolveImportErrorsTestSuiteFactory( expect(response.body).not.to.have.property('errors'); } for (let i = 0; i < expectedSuccesses.length; i++) { - const { type, id } = expectedSuccesses[i]; - const { _source } = await expectResponses.successCreated(es, spaceId, type, id); + const { type, id, successParam, expectedNewId } = expectedSuccesses[i]; + // we don't know the order of the returned successResults; search for each one + const object = (successResults as Array>).find( + (x) => x.type === type && x.id === id + ); + expect(object).not.to.be(undefined); + const destinationId = object!.destinationId as string; + if (successParam === 'destinationId') { + // Kibana created the object with a different ID than what was specified in the import + // This can happen due to an unresolvable conflict (so the new ID will be random), or due to an inexact match (so the new ID will + // be equal to the ID or originID of the existing object that it inexactly matched) + if (expectedNewId) { + expect(destinationId).to.be(expectedNewId); + } else { + // the new ID was randomly generated + expect(destinationId).to.match(/^[0-9a-f-]{36}$/); + } + } else if (successParam === 'trueCopy' || successParam === 'newOrigin') { + expect(destinationId).to.be(expectedNewId!); + } else { + expect(destinationId).to.be(undefined); + } + + // This assertion is only needed for the case where True Copy mode is disabled and ambiguous source conflicts are detected. When + // True Copy mode is permanently enabled, this field will be removed, and this assertion will be redundant and can be removed too. + const resultTrueCopy = object!.trueCopy as boolean | undefined; + if (successParam === 'newOrigin') { + expect(resultTrueCopy).to.be(true); + } else { + expect(resultTrueCopy).to.be(undefined); + } + + const { _source } = await expectResponses.successCreated( + es, + spaceId, + type, + destinationId ?? id + ); expect(_source[type][NEW_ATTRIBUTE_KEY]).to.eql(NEW_ATTRIBUTE_VAL); } for (let i = 0; i < expectedFailures.length; i++) { - const { type, id, failure } = expectedFailures[i]; + const { type, id, failure, 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 @@ -81,7 +168,10 @@ export function resolveImportErrorsTestSuiteFactory( expect(object!.error).to.eql({ type: 'unsupported_type' }); } else { // 409 - expect(object!.error).to.eql({ type: 'conflict' }); + expect(object!.error).to.eql({ + type: 'conflict', + ...(expectedNewId && { destinationId: expectedNewId }), + }); } } } @@ -89,8 +179,9 @@ export function resolveImportErrorsTestSuiteFactory( const createTestDefinitions = ( testCases: ResolveImportErrorsTestCase | ResolveImportErrorsTestCase[], forbidden: boolean, - overwrite: boolean, - options?: { + options: { + overwrite?: boolean; + trueCopy?: boolean; spaceId?: string; singleRequest?: boolean; responseBodyOverride?: ExpectResponseBody; @@ -98,29 +189,40 @@ export function resolveImportErrorsTestSuiteFactory( ): ResolveImportErrorsTestDefinition[] => { const cases = Array.isArray(testCases) ? testCases : [testCases]; const responseStatusCode = forbidden ? 403 : 200; - if (!options?.singleRequest) { + const { + overwrite = false, + trueCopy = false, + spaceId, + singleRequest, + responseBodyOverride, + } = options; + if (!singleRequest) { // if we are testing cases that should result in a forbidden response, we can do each case individually // this ensures that multiple test cases of a single type will each result in a forbidden error return cases.map((x) => ({ title: getTestTitle(x, responseStatusCode), - request: [createRequest(x)], + request: createRequest(x, overwrite), responseStatusCode, - responseBody: - options?.responseBodyOverride || - expectResponseBody(x, responseStatusCode, options?.spaceId), + responseBody: responseBodyOverride || expectResponseBody(x, responseStatusCode, spaceId), overwrite, + trueCopy, })); } // batch into a single request to save time during test execution return [ { title: getTestTitle(cases, responseStatusCode), - request: cases.map((x) => createRequest(x)), + request: cases + .map((x) => createRequest(x, overwrite)) + .reduce((acc, cur) => ({ + objects: [...acc.objects, ...cur.objects], + retries: [...acc.retries, ...cur.retries], + })), responseStatusCode, responseBody: - options?.responseBodyOverride || - expectResponseBody(cases, responseStatusCode, options?.spaceId), + responseBodyOverride || expectResponseBody(cases, responseStatusCode, spaceId), overwrite, + trueCopy, }, ]; }; @@ -139,17 +241,14 @@ export function resolveImportErrorsTestSuiteFactory( for (const test of tests) { it(`should return ${test.responseStatusCode} ${test.title}`, async () => { - const retryAttrs = test.overwrite ? { overwrite: true } : {}; - const retries = JSON.stringify( - test.request.map(({ type, id }) => ({ type, id, ...retryAttrs })) - ); - const requestBody = test.request + const requestBody = test.request.objects .map((obj) => JSON.stringify({ ...obj, ...attrs })) .join('\n'); + const query = test.trueCopy ? '?trueCopy=true' : ''; await supertest - .post(`${getUrlPrefix(spaceId)}/api/saved_objects/_resolve_import_errors`) + .post(`${getUrlPrefix(spaceId)}/api/saved_objects/_resolve_import_errors${query}`) .auth(user?.username, user?.password) - .field('retries', retries) + .field('retries', JSON.stringify(test.request.retries)) .attach('file', Buffer.from(requestBody, 'utf8'), 'export.ndjson') .expect(test.responseStatusCode) .then(test.responseBody); diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts index d83f3449460ce..0cc5969e2b7ab 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts @@ -20,6 +20,8 @@ const { SPACE_2: { spaceId: SPACE_2_ID }, } = SPACES; const { fail400, fail409 } = testCaseFailures; +const unresolvableConflict = (condition?: boolean) => + condition !== false ? { fail409Param: 'unresolvableConflict' } : {}; const createTestCases = (overwrite: boolean, spaceId: string) => { // for each permitted (non-403) outcome, if failure !== undefined then we expect @@ -34,9 +36,18 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite || (spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID)), + ...unresolvableConflict(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), + }, + { + ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, + ...fail409(!overwrite || spaceId !== SPACE_1_ID), + ...unresolvableConflict(spaceId !== SPACE_1_ID), + }, + { + ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, + ...fail409(!overwrite || spaceId !== SPACE_2_ID), + ...unresolvableConflict(spaceId !== SPACE_2_ID), }, - { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail409(!overwrite || spaceId !== SPACE_1_ID) }, - { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail409(!overwrite || spaceId !== SPACE_2_ID) }, { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, CASES.NEW_SINGLE_NAMESPACE_OBJ, CASES.NEW_MULTI_NAMESPACE_OBJ, diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/export.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/export.ts index f85cd3a36c092..c581a1757565e 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/export.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/export.ts @@ -18,15 +18,12 @@ const createTestCases = (spaceId: string) => { const exportableTypes = [ cases.singleNamespaceObject, cases.singleNamespaceType, - cases.namespaceAgnosticObject, - cases.namespaceAgnosticType, - ]; - const nonExportableTypes = [ cases.multiNamespaceObject, cases.multiNamespaceType, - cases.hiddenObject, - cases.hiddenType, + cases.namespaceAgnosticObject, + cases.namespaceAgnosticType, ]; + const nonExportableTypes = [cases.hiddenObject, cases.hiddenType]; const allTypes = exportableTypes.concat(nonExportableTypes); return { exportableTypes, nonExportableTypes, allTypes }; }; 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 6b4dfe1d05f72..164a01a3c2618 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 @@ -20,27 +20,78 @@ const { SPACE_2: { spaceId: SPACE_2_ID }, } = SPACES; const { fail400, fail409 } = testCaseFailures; +const destinationId = (condition?: boolean) => + condition !== false ? { successParam: 'destinationId' } : {}; +const newOrigin = () => ({ successParam: 'newOrigin' }); +const ambiguousConflict = (suffix: string) => ({ + failure: 409 as 409, + fail409Param: `ambiguous_conflict_${suffix}`, +}); -const createTestCases = (spaceId: string) => { +const createTrueCopyTestCases = () => { + // 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: 'trueCopy' })); + const nonImportable = [{ ...CASES.HIDDEN, ...fail400() }]; + const all = [...importable, ...nonImportable]; + return { importable, nonImportable, all }; +}; + +const createTestCases = (overwrite: boolean, spaceId: string) => { // for each permitted (non-403) outcome, if failure !== undefined then we expect // to receive an error; otherwise, we expect to receive a success result - const importableTypes = [ - { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail409(spaceId === DEFAULT_SPACE_ID) }, - { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail409(spaceId === SPACE_1_ID) }, - { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail409(spaceId === SPACE_2_ID) }, - { ...CASES.NAMESPACE_AGNOSTIC, ...fail409() }, + const group1Importable = [ + // 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), + }, + { ...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.NEW_SINGLE_NAMESPACE_OBJ, CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, ]; - const nonImportableTypes = [ - { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail400() }, - { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail400() }, - { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail400() }, - { ...CASES.HIDDEN, ...fail400() }, - { ...CASES.NEW_MULTI_NAMESPACE_OBJ, ...fail400() }, + const group1NonImportable = [{ ...CASES.HIDDEN, ...fail400() }]; + 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_DEFAULT_AND_SPACE_1, + ...fail409(!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), + ...destinationId(spaceId !== SPACE_1_ID), + }, + { + ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, + ...fail409(!overwrite && spaceId === SPACE_2_ID), + ...destinationId(spaceId !== SPACE_2_ID), + }, + { ...CASES.CONFLICT_1A_OBJ, ...newOrigin() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID + { ...CASES.CONFLICT_1B_OBJ, ...newOrigin() }, // "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 + ]; + 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 + ]; + 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 + CASES.CONFLICT_1B_OBJ, // no conflict because CONFLICT_1_OBJ is an exact match + { ...CASES.CONFLICT_2C_OBJ, ...newOrigin() }, // "ambiguous source and destination" conflict which results in a new destination ID and empty origin ID + { ...CASES.CONFLICT_2D_OBJ, ...newOrigin() }, // "ambiguous source and destination" conflict which results in a new destination ID and empty origin ID ]; - const allTypes = importableTypes.concat(nonImportableTypes); - return { importableTypes, nonImportableTypes, allTypes }; + return { group1Importable, group1NonImportable, group1All, group2, group3, group4 }; }; export default function ({ getService }: FtrProviderContext) { @@ -53,27 +104,77 @@ export default function ({ getService }: FtrProviderContext) { esArchiver, supertest ); - const createTests = (spaceId: string) => { - const { importableTypes, nonImportableTypes, allTypes } = createTestCases(spaceId); - // use singleRequest to reduce execution time and/or test combined cases + const createTests = (overwrite: boolean, trueCopy: boolean, spaceId: string) => { + const singleRequest = true; + + if (trueCopy) { + const { importable, nonImportable, all } = createTrueCopyTestCases(); + return { + unauthorized: [ + createTestDefinitions(importable, true, { trueCopy, spaceId }), + createTestDefinitions(nonImportable, false, { trueCopy, spaceId, singleRequest }), + createTestDefinitions(all, true, { + trueCopy, + spaceId, + singleRequest, + responseBodyOverride: expectForbidden('bulk_create')([ + 'dashboard', + 'globaltype', + 'isolatedtype', + 'sharedtype', + ]), + }), + ].flat(), + authorized: createTestDefinitions(all, false, { trueCopy, spaceId, singleRequest }), + }; + } + + const { + group1Importable, + group1NonImportable, + group1All, + group2, + group3, + group4, + } = createTestCases(overwrite, spaceId); return { unauthorized: [ - createTestDefinitions(importableTypes, true, { spaceId }), - createTestDefinitions(nonImportableTypes, false, { spaceId, singleRequest: true }), - createTestDefinitions(allTypes, true, { + createTestDefinitions(group1Importable, true, { overwrite, spaceId }), + createTestDefinitions(group1NonImportable, false, { overwrite, spaceId, singleRequest }), + createTestDefinitions(group1All, true, { + overwrite, spaceId, - singleRequest: true, - responseBodyOverride: expectForbidden(['dashboard', 'globaltype', 'isolatedtype']), + singleRequest, + responseBodyOverride: expectForbidden('bulk_create')([ + 'dashboard', + 'globaltype', + 'isolatedtype', + ]), }), + createTestDefinitions(group2, true, { overwrite, spaceId, singleRequest }), + createTestDefinitions(group3, true, { overwrite, spaceId, singleRequest }), + createTestDefinitions(group4, 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 }), ].flat(), - authorized: createTestDefinitions(allTypes, false, { spaceId, singleRequest: true }), }; }; describe('_import', () => { - getTestScenarios().securityAndSpaces.forEach(({ spaceId, users }) => { - const suffix = ` within the ${spaceId} space`; - const { unauthorized, authorized } = createTests(spaceId); + getTestScenarios([ + [false, false], + [false, true], + [true, false], + ]).securityAndSpaces.forEach(({ spaceId, users, modifier }) => { + const [overwrite, trueCopy] = modifier!; + const suffix = ` within the ${spaceId} space${ + overwrite ? ' with overwrite enabled' : trueCopy ? ' with trueCopy enabled' : '' + }`; + const { unauthorized, authorized } = createTests(overwrite, trueCopy, spaceId); const _addTests = (user: TestUser, tests: ImportTestDefinition[]) => { addTests(`${user.description}${suffix}`, { user, spaceId, tests }); }; 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 8c16e298c7df9..163fb53253c74 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 @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { v4 as uuidv4 } from 'uuid'; import { SPACES } from '../../common/lib/spaces'; import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { TestUser } from '../../common/lib/types'; @@ -20,30 +21,65 @@ const { SPACE_2: { spaceId: SPACE_2_ID }, } = SPACES; const { fail400, fail409 } = testCaseFailures; +const destinationId = (condition?: boolean) => + condition !== false ? { successParam: 'destinationId' } : {}; +const newOrigin = () => ({ successParam: 'newOrigin' }); + +const createTrueCopyTestCases = () => { + // 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: 'trueCopy', + expectedNewId: uuidv4(), + })); + const nonImportable = [{ ...CASES.HIDDEN, ...fail400() }]; + const all = [...importable, ...nonImportable]; + return { importable, nonImportable, all }; +}; const createTestCases = (overwrite: boolean, spaceId: string) => { // for each permitted (non-403) outcome, if failure !== undefined then we expect // to receive an error; otherwise, we expect to receive a success result - const importableTypes = [ - { - ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, - ...fail409(!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) }, + const singleNamespaceObject = + spaceId === DEFAULT_SPACE_ID + ? CASES.SINGLE_NAMESPACE_DEFAULT_SPACE + : spaceId === SPACE_1_ID + ? CASES.SINGLE_NAMESPACE_SPACE_1 + : CASES.SINGLE_NAMESPACE_SPACE_2; + const group1Importable = [ + { ...singleNamespaceObject, ...fail409(!overwrite) }, { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, - CASES.NEW_SINGLE_NAMESPACE_OBJ, - CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, ]; - const nonImportableTypes = [ - { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail400() }, - { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail400() }, - { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail400() }, - { ...CASES.HIDDEN, ...fail400() }, - { ...CASES.NEW_MULTI_NAMESPACE_OBJ, ...fail400() }, + const group1NonImportable = [{ ...CASES.HIDDEN, ...fail400() }]; + const group1All = [...group1Importable, ...group1NonImportable]; + const group2 = [ + { + ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + ...fail409(!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), + ...destinationId(spaceId !== SPACE_1_ID), + }, + { + ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, + ...fail409(!overwrite && spaceId === SPACE_2_ID), + ...destinationId(spaceId !== SPACE_2_ID), + }, + { ...CASES.CONFLICT_1A_OBJ, ...newOrigin() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID + { ...CASES.CONFLICT_1B_OBJ, ...newOrigin() }, // "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' ]; - const allTypes = importableTypes.concat(nonImportableTypes); - return { importableTypes, nonImportableTypes, allTypes }; + return { group1Importable, group1NonImportable, group1All, group2 }; }; export default function ({ getService }: FtrProviderContext) { @@ -56,47 +92,78 @@ export default function ({ getService }: FtrProviderContext) { esArchiver, supertest ); - const createTests = (overwrite: boolean, spaceId: string) => { - const { importableTypes, nonImportableTypes, allTypes } = createTestCases(overwrite, spaceId); - const singleRequest = true; + const createTests = (overwrite: boolean, trueCopy: boolean, spaceId: string) => { // use singleRequest to reduce execution time and/or test combined cases + const singleRequest = true; + + if (trueCopy) { + const { importable, nonImportable, all } = createTrueCopyTestCases(); + return { + unauthorized: [ + createTestDefinitions(importable, true, { trueCopy, spaceId }), + createTestDefinitions(nonImportable, false, { trueCopy, spaceId, singleRequest }), + createTestDefinitions(all, true, { + trueCopy, + spaceId, + singleRequest, + responseBodyOverride: expectForbidden(['globaltype', 'isolatedtype', 'sharedtype']), + }), + ].flat(), + authorized: createTestDefinitions(all, false, { trueCopy, spaceId, singleRequest }), + }; + } + + const { group1Importable, group1NonImportable, group1All, group2 } = createTestCases( + overwrite, + spaceId + ); return { unauthorized: [ - createTestDefinitions(importableTypes, true, overwrite, { spaceId }), - createTestDefinitions(nonImportableTypes, false, overwrite, { spaceId, singleRequest }), - createTestDefinitions(allTypes, true, overwrite, { + createTestDefinitions(group1Importable, true, { overwrite, spaceId }), + createTestDefinitions(group1NonImportable, false, { overwrite, spaceId, singleRequest }), + createTestDefinitions(group1All, true, { + overwrite, spaceId, singleRequest, - responseBodyOverride: expectForbidden(['dashboard', 'globaltype', 'isolatedtype']), + responseBodyOverride: expectForbidden(['globaltype', 'isolatedtype']), }), + createTestDefinitions(group2, true, { overwrite, spaceId, singleRequest }), + ].flat(), + authorized: [ + createTestDefinitions(group1All, false, { overwrite, spaceId, singleRequest }), + createTestDefinitions(group2, false, { overwrite, spaceId, singleRequest }), ].flat(), - authorized: createTestDefinitions(allTypes, false, overwrite, { spaceId, singleRequest }), }; }; describe('_resolve_import_errors', () => { - getTestScenarios([false, true]).securityAndSpaces.forEach( - ({ spaceId, users, modifier: overwrite }) => { - const suffix = ` within the ${spaceId} space${overwrite ? ' with overwrite enabled' : ''}`; - const { unauthorized, authorized } = createTests(overwrite!, spaceId); - const _addTests = (user: TestUser, tests: ResolveImportErrorsTestDefinition[]) => { - addTests(`${user.description}${suffix}`, { user, spaceId, tests }); - }; + getTestScenarios([ + [false, false], + [false, true], + [true, false], + ]).securityAndSpaces.forEach(({ spaceId, users, modifier }) => { + const [overwrite, trueCopy] = modifier!; + const suffix = ` within the ${spaceId} space${ + overwrite ? ' with overwrite enabled' : trueCopy ? ' with trueCopy enabled' : '' + }`; + const { unauthorized, authorized } = createTests(overwrite, trueCopy, 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.dualAll, users.allGlobally, users.allAtSpace, users.superuser].forEach((user) => { - _addTests(user, authorized); - }); - } - ); + [ + users.noAccess, + users.legacyAll, + users.dualRead, + users.readGlobally, + users.readAtSpace, + users.allAtOtherSpace, + ].forEach((user) => { + _addTests(user, unauthorized); + }); + [users.dualAll, users.allGlobally, users.allAtSpace, users.superuser].forEach((user) => { + _addTests(user, authorized); + }); + }); }); } diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts index 464a5a1e76016..725120687c231 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts @@ -14,6 +14,7 @@ import { } from '../../common/suites/bulk_create'; const { fail400, fail409 } = testCaseFailures; +const unresolvableConflict = () => ({ fail409Param: 'unresolvableConflict' }); const createTestCases = (overwrite: boolean) => { // for each permitted (non-403) outcome, if failure !== undefined then we expect @@ -23,8 +24,8 @@ const createTestCases = (overwrite: boolean) => { CASES.SINGLE_NAMESPACE_SPACE_1, CASES.SINGLE_NAMESPACE_SPACE_2, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite) }, - { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail409() }, - { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail409() }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail409(), ...unresolvableConflict() }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail409(), ...unresolvableConflict() }, { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, CASES.NEW_SINGLE_NAMESPACE_OBJ, CASES.NEW_MULTI_NAMESPACE_OBJ, diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/export.ts b/x-pack/test/saved_object_api_integration/security_only/apis/export.ts index 61ff6eeb4bd80..99babf683ccfa 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/export.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/export.ts @@ -18,15 +18,12 @@ const createTestCases = () => { const exportableTypes = [ cases.singleNamespaceObject, cases.singleNamespaceType, - cases.namespaceAgnosticObject, - cases.namespaceAgnosticType, - ]; - const nonExportableTypes = [ cases.multiNamespaceObject, cases.multiNamespaceType, - cases.hiddenObject, - cases.hiddenType, + cases.namespaceAgnosticObject, + cases.namespaceAgnosticType, ]; + const nonExportableTypes = [cases.hiddenObject, cases.hiddenType]; const allTypes = exportableTypes.concat(nonExportableTypes); return { exportableTypes, nonExportableTypes, allTypes }; }; diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/import.ts b/x-pack/test/saved_object_api_integration/security_only/apis/import.ts index beec276b3bd73..3ff7925628d38 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/import.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/import.ts @@ -14,27 +14,63 @@ import { } from '../../common/suites/import'; const { fail400, fail409 } = testCaseFailures; +const destinationId = (condition?: boolean) => + condition !== false ? { successParam: 'destinationId' } : {}; +const newOrigin = () => ({ successParam: 'newOrigin' }); +const ambiguousConflict = (suffix: string) => ({ + failure: 409 as 409, + fail409Param: `ambiguous_conflict_${suffix}`, +}); -const createTestCases = () => { +const createTrueCopyTestCases = () => { + // 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: 'trueCopy' })); + const nonImportable = [{ ...CASES.HIDDEN, ...fail400() }]; + const all = [...importable, ...nonImportable]; + return { importable, nonImportable, all }; +}; + +const createTestCases = (overwrite: boolean) => { // for each permitted (non-403) outcome, if failure !== undefined then we expect // to receive an error; otherwise, we expect to receive a success result - const importableTypes = [ - { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail409() }, + const group1Importable = [ + // 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) }, CASES.SINGLE_NAMESPACE_SPACE_1, CASES.SINGLE_NAMESPACE_SPACE_2, - { ...CASES.NAMESPACE_AGNOSTIC, ...fail409() }, + { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, CASES.NEW_SINGLE_NAMESPACE_OBJ, CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, ]; - const nonImportableTypes = [ - { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail400() }, - { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail400() }, - { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail400() }, - { ...CASES.HIDDEN, ...fail400() }, - { ...CASES.NEW_MULTI_NAMESPACE_OBJ, ...fail400() }, + const group1NonImportable = [{ ...CASES.HIDDEN, ...fail400() }]; + 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_DEFAULT_AND_SPACE_1, ...fail409(!overwrite) }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...destinationId() }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...destinationId() }, + { ...CASES.CONFLICT_1A_OBJ, ...newOrigin() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID + { ...CASES.CONFLICT_1B_OBJ, ...newOrigin() }, // "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 + ]; + 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 + ]; + 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 + CASES.CONFLICT_1B_OBJ, // no conflict because CONFLICT_1_OBJ is an exact match + { ...CASES.CONFLICT_2C_OBJ, ...newOrigin() }, // "ambiguous source and destination" conflict which results in a new destination ID and empty origin ID + { ...CASES.CONFLICT_2D_OBJ, ...newOrigin() }, // "ambiguous source and destination" conflict which results in a new destination ID and empty origin ID ]; - const allTypes = importableTypes.concat(nonImportableTypes); - return { importableTypes, nonImportableTypes, allTypes }; + return { group1Importable, group1NonImportable, group1All, group2, group3, group4 }; }; export default function ({ getService }: FtrProviderContext) { @@ -47,27 +83,80 @@ export default function ({ getService }: FtrProviderContext) { esArchiver, supertest ); - const createTests = () => { - const { importableTypes, nonImportableTypes, allTypes } = createTestCases(); + const createTests = (overwrite: boolean, trueCopy: boolean) => { // use singleRequest to reduce execution time and/or test combined cases + const singleRequest = true; + + if (trueCopy) { + const { importable, nonImportable, all } = createTrueCopyTestCases(); + return { + unauthorized: [ + createTestDefinitions(importable, true, { trueCopy }), + createTestDefinitions(nonImportable, false, { trueCopy, singleRequest }), + createTestDefinitions(all, true, { + trueCopy, + singleRequest, + responseBodyOverride: expectForbidden('bulk_create')([ + 'dashboard', + 'globaltype', + 'isolatedtype', + 'sharedtype', + ]), + }), + ].flat(), + authorized: createTestDefinitions(all, false, { trueCopy, singleRequest }), + }; + } + + const { + group1Importable, + group1NonImportable, + group1All, + group2, + group3, + group4, + } = createTestCases(overwrite); return { unauthorized: [ - createTestDefinitions(importableTypes, true), - createTestDefinitions(nonImportableTypes, false, { singleRequest: true }), - createTestDefinitions(allTypes, true, { - singleRequest: true, - responseBodyOverride: expectForbidden(['dashboard', 'globaltype', 'isolatedtype']), + createTestDefinitions(group1Importable, true, { overwrite }), + createTestDefinitions(group1NonImportable, false, { overwrite, singleRequest }), + createTestDefinitions(group1All, true, { + overwrite, + singleRequest, + responseBodyOverride: expectForbidden('bulk_create')([ + 'dashboard', + 'globaltype', + 'isolatedtype', + ]), }), + createTestDefinitions(group2, true, { overwrite, singleRequest }), + createTestDefinitions(group3, true, { overwrite, singleRequest }), + createTestDefinitions(group4, true, { overwrite, singleRequest }), + ].flat(), + authorized: [ + createTestDefinitions(group1All, false, { overwrite, singleRequest }), + createTestDefinitions(group2, false, { overwrite, singleRequest }), + createTestDefinitions(group3, false, { overwrite, singleRequest }), + createTestDefinitions(group4, false, { overwrite, singleRequest }), ].flat(), - authorized: createTestDefinitions(allTypes, false, { singleRequest: true }), }; }; describe('_import', () => { - getTestScenarios().security.forEach(({ users }) => { - const { unauthorized, authorized } = createTests(); + getTestScenarios([ + [false, false], + [false, true], + [true, false], + ]).security.forEach(({ users, modifier }) => { + const [overwrite, trueCopy] = modifier!; + const suffix = overwrite + ? ' with overwrite enabled' + : trueCopy + ? ' with trueCopy enabled' + : ''; + const { unauthorized, authorized } = createTests(overwrite, trueCopy); const _addTests = (user: TestUser, tests: ImportTestDefinition[]) => { - addTests(user.description, { user, tests }); + addTests(`${user.description}${suffix}`, { user, tests }); }; [ diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/resolve_import_errors.ts b/x-pack/test/saved_object_api_integration/security_only/apis/resolve_import_errors.ts index a0abe4b0483f8..dacf1d4745274 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/resolve_import_errors.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/resolve_import_errors.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { v4 as uuidv4 } from 'uuid'; import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; @@ -14,27 +15,45 @@ import { } from '../../common/suites/resolve_import_errors'; const { fail400, fail409 } = testCaseFailures; +const destinationId = (condition?: boolean) => + condition !== false ? { successParam: 'destinationId' } : {}; +const newOrigin = () => ({ successParam: 'newOrigin' }); + +const createTrueCopyTestCases = () => { + // 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: 'trueCopy', + expectedNewId: uuidv4(), + })); + const nonImportable = [{ ...CASES.HIDDEN, ...fail400() }]; + const all = [...importable, ...nonImportable]; + return { importable, nonImportable, all }; +}; const createTestCases = (overwrite: boolean) => { // for each permitted (non-403) outcome, if failure !== undefined then we expect // to receive an error; otherwise, we expect to receive a success result - const importableTypes = [ + const group1Importable = [ { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail409(!overwrite) }, - CASES.SINGLE_NAMESPACE_SPACE_1, - CASES.SINGLE_NAMESPACE_SPACE_2, { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, - CASES.NEW_SINGLE_NAMESPACE_OBJ, - CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, ]; - const nonImportableTypes = [ - { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail400() }, - { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail400() }, - { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail400() }, - { ...CASES.HIDDEN, ...fail400() }, - { ...CASES.NEW_MULTI_NAMESPACE_OBJ, ...fail400() }, + const group1NonImportable = [{ ...CASES.HIDDEN, ...fail400() }]; + const group1All = [...group1Importable, ...group1NonImportable]; + const group2 = [ + { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite) }, + { ...CASES.CONFLICT_1A_OBJ, ...newOrigin() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID + { ...CASES.CONFLICT_1B_OBJ, ...newOrigin() }, // "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' ]; - const allTypes = importableTypes.concat(nonImportableTypes); - return { importableTypes, nonImportableTypes, allTypes }; + return { group1Importable, group1NonImportable, group1All, group2 }; }; export default function ({ getService }: FtrProviderContext) { @@ -47,26 +66,58 @@ export default function ({ getService }: FtrProviderContext) { esArchiver, supertest ); - const createTests = (overwrite: boolean) => { - const { importableTypes, nonImportableTypes, allTypes } = createTestCases(overwrite); + const createTests = (overwrite: boolean, trueCopy: boolean) => { // use singleRequest to reduce execution time and/or test combined cases + const singleRequest = true; + + if (trueCopy) { + const { importable, nonImportable, all } = createTrueCopyTestCases(); + return { + unauthorized: [ + createTestDefinitions(importable, true, { trueCopy }), + createTestDefinitions(nonImportable, false, { trueCopy, singleRequest }), + createTestDefinitions(all, true, { + trueCopy, + singleRequest, + responseBodyOverride: expectForbidden(['globaltype', 'isolatedtype', 'sharedtype']), + }), + ].flat(), + authorized: createTestDefinitions(all, false, { trueCopy, singleRequest }), + }; + } + + const { group1Importable, group1NonImportable, group1All, group2 } = createTestCases(overwrite); return { unauthorized: [ - createTestDefinitions(importableTypes, true, overwrite), - createTestDefinitions(nonImportableTypes, false, overwrite, { singleRequest: true }), - createTestDefinitions(allTypes, true, overwrite, { - singleRequest: true, - responseBodyOverride: expectForbidden(['dashboard', 'globaltype', 'isolatedtype']), + createTestDefinitions(group1Importable, true, { overwrite }), + createTestDefinitions(group1NonImportable, false, { overwrite, singleRequest }), + createTestDefinitions(group1All, true, { + overwrite, + singleRequest, + responseBodyOverride: expectForbidden(['globaltype', 'isolatedtype']), }), + createTestDefinitions(group2, true, { overwrite, singleRequest }), + ].flat(), + authorized: [ + createTestDefinitions(group1All, false, { overwrite, singleRequest }), + createTestDefinitions(group2, false, { overwrite, singleRequest }), ].flat(), - authorized: createTestDefinitions(allTypes, false, overwrite, { singleRequest: true }), }; }; describe('_resolve_import_errors', () => { - getTestScenarios([false, true]).security.forEach(({ users, modifier: overwrite }) => { - const suffix = overwrite ? ' with overwrite enabled' : ''; - const { unauthorized, authorized } = createTests(overwrite!); + getTestScenarios([ + [false, false], + [false, true], + [true, false], + ]).security.forEach(({ users, modifier }) => { + const [overwrite, trueCopy] = modifier!; + const suffix = overwrite + ? ' with overwrite enabled' + : trueCopy + ? ' with trueCopy enabled' + : ''; + const { unauthorized, authorized } = createTests(overwrite, trueCopy); const _addTests = (user: TestUser, tests: ResolveImportErrorsTestDefinition[]) => { addTests(`${user.description}${suffix}`, { user, tests }); }; diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts index f9edc56b8ffea..74fade39bf7a5 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts @@ -16,6 +16,8 @@ const { SPACE_2: { spaceId: SPACE_2_ID }, } = SPACES; const { fail400, fail409 } = testCaseFailures; +const unresolvableConflict = (condition?: boolean) => + condition !== false ? { fail409Param: 'unresolvableConflict' } : {}; const createTestCases = (overwrite: boolean, spaceId: string) => [ // for each outcome, if failure !== undefined then we expect to receive @@ -29,9 +31,18 @@ const createTestCases = (overwrite: boolean, spaceId: string) => [ { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite || (spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID)), + ...unresolvableConflict(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), + }, + { + ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, + ...fail409(!overwrite || spaceId !== SPACE_1_ID), + ...unresolvableConflict(spaceId !== SPACE_1_ID), + }, + { + ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, + ...fail409(!overwrite || spaceId !== SPACE_2_ID), + ...unresolvableConflict(spaceId !== SPACE_2_ID), }, - { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail409(!overwrite || spaceId !== SPACE_1_ID) }, - { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail409(!overwrite || spaceId !== SPACE_2_ID) }, { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, { ...CASES.HIDDEN, ...fail400() }, CASES.NEW_SINGLE_NAMESPACE_OBJ, 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 45a76a2f39e37..fcb66da3b0ed1 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 @@ -15,22 +15,75 @@ const { SPACE_2: { spaceId: SPACE_2_ID }, } = SPACES; const { fail400, fail409 } = testCaseFailures; +const destinationId = (condition?: boolean) => + condition !== false ? { successParam: 'destinationId' } : {}; +const newOrigin = () => ({ successParam: 'newOrigin' }); +const ambiguousConflict = (suffix: string) => ({ + failure: 409 as 409, + fail409Param: `ambiguous_conflict_${suffix}`, +}); -const createTestCases = (spaceId: string) => [ +const createTrueCopyTestCases = () => { // for each outcome, if failure !== undefined then we expect to receive // an error; otherwise, we expect to receive a success result - { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail409(spaceId === DEFAULT_SPACE_ID) }, - { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail409(spaceId === SPACE_1_ID) }, - { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail409(spaceId === SPACE_2_ID) }, - { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail400() }, - { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail400() }, - { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail400() }, - { ...CASES.NAMESPACE_AGNOSTIC, ...fail409() }, - { ...CASES.HIDDEN, ...fail400() }, - CASES.NEW_SINGLE_NAMESPACE_OBJ, - { ...CASES.NEW_MULTI_NAMESPACE_OBJ, ...fail400() }, - CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, -]; + const cases = Object.entries(CASES).filter(([key]) => key !== 'HIDDEN'); + return [ + ...cases.map(([, val]) => ({ ...val, successParam: 'trueCopy' })), + { ...CASES.HIDDEN, ...fail400() }, + ]; +}; + +const createTestCases = (overwrite: boolean, spaceId: string) => { + // for each outcome, if failure !== 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), + }, + { ...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_DEFAULT_AND_SPACE_1, + ...fail409(!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), + ...destinationId(spaceId !== SPACE_1_ID), + }, + { + ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, + ...fail409(!overwrite && spaceId === SPACE_2_ID), + ...destinationId(spaceId !== SPACE_2_ID), + }, + { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, + { ...CASES.HIDDEN, ...fail400() }, + { ...CASES.CONFLICT_1A_OBJ, ...newOrigin() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID + { ...CASES.CONFLICT_1B_OBJ, ...newOrigin() }, // "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.NEW_SINGLE_NAMESPACE_OBJ, + CASES.NEW_MULTI_NAMESPACE_OBJ, + CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, + ]; + 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 + ]; + const group3 = [ + // 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 + CASES.CONFLICT_1B_OBJ, // no conflict because CONFLICT_1_OBJ is an exact match + { ...CASES.CONFLICT_2C_OBJ, ...newOrigin() }, // "ambiguous source and destination" conflict which results in a new destination ID and empty origin ID + { ...CASES.CONFLICT_2D_OBJ, ...newOrigin() }, // "ambiguous source and destination" conflict which results in a new destination ID and empty origin ID + ]; + return { group1, group2, group3 }; +}; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -38,15 +91,35 @@ export default function ({ getService }: FtrProviderContext) { const es = getService('legacyEs'); const { addTests, createTestDefinitions } = importTestSuiteFactory(es, esArchiver, supertest); - const createTests = (spaceId: string) => { - const testCases = createTestCases(spaceId); - return createTestDefinitions(testCases, false, { spaceId, singleRequest: true }); + const createTests = (overwrite: boolean, trueCopy: boolean, spaceId: string) => { + const singleRequest = true; + if (trueCopy) { + const cases = createTrueCopyTestCases(); + return createTestDefinitions(cases, false, { trueCopy, spaceId, singleRequest }); + } + + const { group1, group2, group3 } = createTestCases(overwrite, spaceId); + return [ + createTestDefinitions(group1, false, { overwrite, spaceId, singleRequest }), + createTestDefinitions(group2, false, { overwrite, spaceId, singleRequest }), + createTestDefinitions(group3, false, { overwrite, spaceId, singleRequest }), + ].flat(); }; describe('_import', () => { - getTestScenarios().spaces.forEach(({ spaceId }) => { - const tests = createTests(spaceId); - addTests(`within the ${spaceId} space`, { spaceId, tests }); + getTestScenarios([ + [false, false], + [false, true], + [true, false], + ]).spaces.forEach(({ spaceId, modifier }) => { + const [overwrite, trueCopy] = modifier!; + const suffix = overwrite + ? ' with overwrite enabled' + : trueCopy + ? ' with trueCopy enabled' + : ''; + const tests = createTests(overwrite, trueCopy, spaceId); + addTests(`within the ${spaceId} space${suffix}`, { spaceId, tests }); }); }); } 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 a6ef902e2e9eb..f95b3e4b13bec 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 @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { v4 as uuidv4 } from 'uuid'; import { SPACES } from '../../common/lib/spaces'; import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { FtrProviderContext } from '../../common/ftr_provider_context'; @@ -18,25 +19,58 @@ const { SPACE_2: { spaceId: SPACE_2_ID }, } = SPACES; const { fail400, fail409 } = testCaseFailures; +const destinationId = (condition?: boolean) => + condition !== false ? { successParam: 'destinationId' } : {}; +const newOrigin = () => ({ successParam: 'newOrigin' }); -const createTestCases = (overwrite: boolean, spaceId: string) => [ +const createTrueCopyTestCases = () => { // for each outcome, if failure !== undefined then we expect to receive // an error; otherwise, we expect to receive a success result - { - ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, - ...fail409(!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_DEFAULT_AND_SPACE_1, ...fail400() }, - { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail400() }, - { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail400() }, - { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, - { ...CASES.HIDDEN, ...fail400() }, - CASES.NEW_SINGLE_NAMESPACE_OBJ, - { ...CASES.NEW_MULTI_NAMESPACE_OBJ, ...fail400() }, - CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, -]; + const cases = Object.entries(CASES).filter(([key]) => key !== 'HIDDEN'); + return [ + ...cases.map(([, val]) => ({ ...val, successParam: 'trueCopy', expectedNewId: uuidv4() })), + { ...CASES.HIDDEN, ...fail400() }, + ]; +}; + +const createTestCases = (overwrite: boolean, spaceId: string) => { + // for each outcome, if failure !== undefined then we expect to receive + // an error; otherwise, we expect to receive a success result + const singleNamespaceObject = + spaceId === DEFAULT_SPACE_ID + ? CASES.SINGLE_NAMESPACE_DEFAULT_SPACE + : spaceId === SPACE_1_ID + ? CASES.SINGLE_NAMESPACE_SPACE_1 + : CASES.SINGLE_NAMESPACE_SPACE_2; + return [ + { ...singleNamespaceObject, ...fail409(!overwrite) }, + { + ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + ...fail409(!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), + ...destinationId(spaceId !== SPACE_1_ID), + }, + { + ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, + ...fail409(!overwrite && spaceId === SPACE_2_ID), + ...destinationId(spaceId !== SPACE_2_ID), + }, + { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, + { ...CASES.HIDDEN, ...fail400() }, + { ...CASES.CONFLICT_1A_OBJ, ...newOrigin() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID + { ...CASES.CONFLICT_1B_OBJ, ...newOrigin() }, // "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' + ]; +}; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -48,15 +82,32 @@ export default function ({ getService }: FtrProviderContext) { esArchiver, supertest ); - const createTests = (overwrite: boolean, spaceId: string) => { + const createTests = (overwrite: boolean, trueCopy: boolean, spaceId: string) => { + const singleRequest = true; + if (trueCopy) { + const cases = createTrueCopyTestCases(); + // The resolveImportErrors API doesn't actually have a flag for "trueCopy" mode; rather, we create test cases as if we are resolving + // errors from a call to the import API that had trueCopy mode enabled. + return createTestDefinitions(cases, false, { trueCopy, spaceId, singleRequest }); + } + const testCases = createTestCases(overwrite, spaceId); - return createTestDefinitions(testCases, false, overwrite, { spaceId, singleRequest: true }); + return createTestDefinitions(testCases, false, { overwrite, spaceId, singleRequest }); }; describe('_resolve_import_errors', () => { - getTestScenarios([false, true]).spaces.forEach(({ spaceId, modifier: overwrite }) => { - const suffix = overwrite ? ' with overwrite enabled' : ''; - const tests = createTests(overwrite!, spaceId); + getTestScenarios([ + [false, false], + [false, true], + [true, false], + ]).spaces.forEach(({ spaceId, modifier }) => { + const [overwrite, trueCopy] = modifier!; + const suffix = overwrite + ? ' with overwrite enabled' + : trueCopy + ? ' with trueCopy enabled' + : ''; + const tests = createTests(overwrite, trueCopy, spaceId); addTests(`within the ${spaceId} space${suffix}`, { spaceId, tests }); }); }); 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 9a8a0a1fdda14..7e528c23c20a0 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 @@ -380,11 +380,11 @@ { "type": "doc", "value": { - "id": "sharedtype:default_space_only", + "id": "sharedtype:default_only", "index": ".kibana", "source": { "sharedtype": { - "title": "A shared saved-object in the default space" + "title": "A shared saved-object in one space" }, "type": "sharedtype", "namespaces": ["default"], @@ -401,7 +401,7 @@ "index": ".kibana", "source": { "sharedtype": { - "title": "A shared saved-object in the space_1 space" + "title": "A shared saved-object in one space" }, "type": "sharedtype", "namespaces": ["space_1"], @@ -418,7 +418,7 @@ "index": ".kibana", "source": { "sharedtype": { - "title": "A shared saved-object in the space_2 space" + "title": "A shared saved-object in one space" }, "type": "sharedtype", "namespaces": ["space_2"], @@ -496,3 +496,128 @@ } } +{ + "type": "doc", + "value": { + "id": "sharedtype:conflict_1_default", + "index": ".kibana", + "source": { + "originId": "conflict_1", + "sharedtype": { + "title": "A shared saved-object in one space" + }, + "type": "sharedtype", + "namespaces": ["default"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "sharedtype:conflict_1_space_1", + "index": ".kibana", + "source": { + "originId": "conflict_1", + "sharedtype": { + "title": "A shared saved-object in one space" + }, + "type": "sharedtype", + "namespaces": ["space_1"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "sharedtype:conflict_1_space_2", + "index": ".kibana", + "source": { + "originId": "conflict_1", + "sharedtype": { + "title": "A shared saved-object in one space" + }, + "type": "sharedtype", + "namespaces": ["space_2"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "sharedtype:conflict_2_default", + "index": ".kibana", + "source": { + "originId": "conflict_2", + "sharedtype": { + "title": "A shared saved-object in one space" + }, + "type": "sharedtype", + "namespaces": ["default"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "sharedtype:conflict_2_space_1", + "index": ".kibana", + "source": { + "originId": "conflict_2", + "sharedtype": { + "title": "A shared saved-object in one space" + }, + "type": "sharedtype", + "namespaces": ["space_1"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "sharedtype:conflict_2_space_2", + "index": ".kibana", + "source": { + "originId": "conflict_2", + "sharedtype": { + "title": "A shared saved-object in one space" + }, + "type": "sharedtype", + "namespaces": ["space_2"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "sharedtype:conflict_2_all", + "index": ".kibana", + "source": { + "originId": "conflict_2", + "sharedtype": { + "title": "A shared saved-object in all spaces" + }, + "type": "sharedtype", + "namespaces": ["default", "space_1", "space_2"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} diff --git a/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json b/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json index 508de68c32f70..a2f8088ce0436 100644 --- a/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json +++ b/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json @@ -162,6 +162,9 @@ "namespaces": { "type": "keyword" }, + "originId": { + "type": "keyword" + }, "search": { "properties": { "columns": { diff --git a/x-pack/test/spaces_api_integration/common/fixtures/spaces_test_plugin/server/plugin.ts b/x-pack/test/spaces_api_integration/common/fixtures/spaces_test_plugin/server/plugin.ts index ee03fa6b648af..ed45f0870a45c 100644 --- a/x-pack/test/spaces_api_integration/common/fixtures/spaces_test_plugin/server/plugin.ts +++ b/x-pack/test/spaces_api_integration/common/fixtures/spaces_test_plugin/server/plugin.ts @@ -15,6 +15,12 @@ export class Plugin { name: 'sharedtype', hidden: false, namespaceType: 'multiple', + management: { + importableAndExportable: true, + getTitle(obj) { + return obj.attributes.title; + }, + }, mappings: { properties: { title: { type: 'text' }, diff --git a/x-pack/test/spaces_api_integration/common/lib/saved_object_test_cases.ts b/x-pack/test/spaces_api_integration/common/lib/saved_object_test_cases.ts index 67f5d737ba010..3b0f5f8570aa3 100644 --- a/x-pack/test/spaces_api_integration/common/lib/saved_object_test_cases.ts +++ b/x-pack/test/spaces_api_integration/common/lib/saved_object_test_cases.ts @@ -5,8 +5,8 @@ */ export const MULTI_NAMESPACE_SAVED_OBJECT_TEST_CASES = Object.freeze({ - DEFAULT_SPACE_ONLY: Object.freeze({ - id: 'default_space_only', + DEFAULT_ONLY: Object.freeze({ + id: 'default_only', existingNamespaces: ['default'], }), SPACE_1_ONLY: Object.freeze({ 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 ebec70793e8fd..0386a2afccb3d 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 @@ -19,6 +19,11 @@ interface CopyToSpaceTest { response: (resp: TestResponse) => Promise; } +interface CopyToSpaceMultiNamespaceTest extends CopyToSpaceTest { + testTitle: string; + objects: Array>; +} + interface CopyToSpaceTests { noConflictsWithoutReferences: CopyToSpaceTest; noConflictsWithReferences: CopyToSpaceTest; @@ -30,6 +35,7 @@ interface CopyToSpaceTests { withConflictsResponse: (resp: TestResponse) => Promise; noConflictsResponse: (resp: TestResponse) => Promise; }; + multiNamespaceTestCases: (overwrite: boolean) => CopyToSpaceMultiNamespaceTest[]; } interface CopyToSpaceTestDefinition { @@ -53,28 +59,14 @@ interface SpaceBucket { } const INITIAL_COUNTS: Record> = { - [DEFAULT_SPACE_ID]: { - dashboard: 2, - visualization: 3, - 'index-pattern': 1, - }, - space_1: { - dashboard: 2, - visualization: 3, - 'index-pattern': 1, - }, - space_2: { - dashboard: 1, - }, + [DEFAULT_SPACE_ID]: { dashboard: 2, visualization: 3, 'index-pattern': 1 }, + space_1: { dashboard: 2, visualization: 3, 'index-pattern': 1 }, + space_2: { dashboard: 1 }, }; const getDestinationWithoutConflicts = () => 'space_2'; -const getDestinationWithConflicts = (originSpaceId?: string) => { - if (!originSpaceId || originSpaceId === DEFAULT_SPACE_ID) { - return 'space_1'; - } - return DEFAULT_SPACE_ID; -}; +const getDestinationWithConflicts = (originSpaceId?: string) => + !originSpaceId || originSpaceId === DEFAULT_SPACE_ID ? 'space_1' : DEFAULT_SPACE_ID; export function copyToSpaceTestSuiteFactory( es: any, @@ -86,27 +78,11 @@ export function copyToSpaceTestSuiteFactory( index: '.kibana', body: { size: 0, - query: { - terms: { - type: ['visualization', 'dashboard', 'index-pattern'], - }, - }, + query: { terms: { type: ['visualization', 'dashboard', 'index-pattern'] } }, aggs: { count: { - terms: { - field: 'namespace', - missing: DEFAULT_SPACE_ID, - size: 10, - }, - aggs: { - countByType: { - terms: { - field: 'type', - missing: 'UNKNOWN', - size: 10, - }, - }, - }, + terms: { field: 'namespace', missing: DEFAULT_SPACE_ID, size: 10 }, + aggs: { countByType: { terms: { field: 'type', missing: 'UNKNOWN', size: 10 } } }, }, }, }, @@ -135,13 +111,7 @@ export function copyToSpaceTestSuiteFactory( const { countByType } = spaceBucket; const expectedBuckets = Object.entries(expectedCounts).reduce((acc, entry) => { const [type, count] = entry; - return [ - ...acc, - { - key: type, - doc_count: count, - }, - ]; + return [...acc, { key: type, doc_count: count }]; }, [] as CountByTypeBucket[]); expectedBuckets.sort(bucketSorter); @@ -154,14 +124,6 @@ export function copyToSpaceTestSuiteFactory( }); }; - const expectRbacForbiddenResponse = async (resp: TestResponse) => { - expect(resp.body).to.eql({ - statusCode: 403, - error: 'Forbidden', - message: 'Unable to bulk_get dashboard', - }); - }; - const expectNotFoundResponse = async (resp: TestResponse) => { expect(resp.body).to.eql({ statusCode: 404, @@ -179,6 +141,7 @@ export function copyToSpaceTestSuiteFactory( [spaceId]: { success: true, successCount: 1, + successResults: [{ id: 'cts_dashboard', type: 'dashboard' }], }, } as CopyResponse); @@ -198,13 +161,22 @@ export function copyToSpaceTestSuiteFactory( 1 ); - const expectNoConflictsWithReferencesResult = async (resp: TestResponse) => { + const expectNoConflictsWithReferencesResult = (spaceId: string = DEFAULT_SPACE_ID) => async ( + resp: TestResponse + ) => { const destination = getDestinationWithoutConflicts(); const result = resp.body as CopyResponse; expect(result).to.eql({ [destination]: { success: true, successCount: 5, + successResults: [ + { id: 'cts_ip_1', type: 'index-pattern' }, + { id: `cts_vis_1_${spaceId}`, type: 'visualization' }, + { id: `cts_vis_2_${spaceId}`, type: 'visualization' }, + { id: 'cts_vis_3', type: 'visualization' }, + { id: 'cts_dashboard', type: 'dashboard' }, + ], }, } as CopyResponse); @@ -288,6 +260,13 @@ export function copyToSpaceTestSuiteFactory( [destination]: { success: true, successCount: 5, + successResults: [ + { id: 'cts_ip_1', type: 'index-pattern' }, + { id: `cts_vis_1_${spaceId}`, type: 'visualization' }, + { id: `cts_vis_2_${spaceId}`, type: 'visualization' }, + { id: 'cts_vis_3', type: 'visualization' }, + { id: 'cts_dashboard', type: 'dashboard' }, + ], }, } as CopyResponse); @@ -309,27 +288,25 @@ export function copyToSpaceTestSuiteFactory( const result = resp.body as CopyResponse; result[destination].errors!.sort(errorSorter); + const expectedSuccessResults = [ + { id: `cts_vis_1_${spaceId}`, type: 'visualization' }, + { id: `cts_vis_2_${spaceId}`, type: 'visualization' }, + ]; const expectedErrors = [ { - error: { - type: 'conflict', - }, + error: { type: 'conflict' }, id: 'cts_dashboard', title: `This is the ${spaceId} test space CTS dashboard`, type: 'dashboard', }, { - error: { - type: 'conflict', - }, + error: { type: 'conflict' }, id: 'cts_ip_1', title: `Copy to Space index pattern 1 from ${spaceId} space`, type: 'index-pattern', }, { - error: { - type: 'conflict', - }, + error: { type: 'conflict' }, id: 'cts_vis_3', title: `CTS vis 3 from ${spaceId} space`, type: 'visualization', @@ -341,16 +318,169 @@ export function copyToSpaceTestSuiteFactory( [destination]: { success: false, successCount: 2, + successResults: expectedSuccessResults, errors: expectedErrors, }, } as CopyResponse); - // Query ES to ensure that we copied everything we expected - await assertSpaceCounts(destination, { - dashboard: 2, - visualization: 5, - 'index-pattern': 1, - }); + // Query ES to ensure that no objects were created + await assertSpaceCounts(destination, INITIAL_COUNTS[destination]); + }; + + /** + * Creates test cases for multi-namespace saved object types. + * Note: these are written with the assumption that test data will only be reloaded between each group of test cases, *not* before every + * single test case. This saves time during test execution. + */ + const createMultiNamespaceTestCases = ( + spaceId: string, + outcome: 'authorized' | 'unauthorizedRead' | 'unauthorizedWrite' | 'noAccess' = 'authorized' + ) => (overwrite: boolean): CopyToSpaceMultiNamespaceTest[] => { + // the status code of the HTTP response differs depending on the error type + // a 403 error actually comes back as an HTTP 200 response + const statusCode = outcome === 'noAccess' ? 404 : 200; + const type = 'sharedtype'; + const v4 = new RegExp(/^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i); + const noConflictId = `${spaceId}_only`; + const exactMatchId = 'all_spaces'; + const inexactMatchId = `conflict_1_${spaceId}`; + const ambiguousConflictId = `conflict_2_${spaceId}`; + + const getResult = (response: TestResponse) => (response.body as CopyResponse).space_2; + const expectForbiddenResponse = (response: TestResponse) => { + expect(response.body).to.eql({ + space_2: { + success: false, + successCount: 0, + errors: [ + { statusCode: 403, error: 'Forbidden', message: `Unable to bulk_create sharedtype` }, + ], + }, + }); + }; + + return [ + { + testTitle: 'copying with no conflict', + objects: [{ type, id: noConflictId }], + statusCode, + response: async (response: TestResponse) => { + if (outcome === 'authorized') { + const { success, successCount, successResults, errors } = getResult(response); + expect(success).to.eql(true); + expect(successCount).to.eql(1); + const destinationId = successResults![0].destinationId; + expect(destinationId).to.match(v4); + expect(successResults).to.eql([{ type, id: noConflictId, destinationId }]); + expect(errors).to.be(undefined); + } else if (outcome === 'noAccess') { + expectNotFoundResponse(response); + } else { + // unauthorized read/write + expectForbiddenResponse(response); + } + }, + }, + { + testTitle: 'copying with an exact match conflict', + objects: [{ type, id: exactMatchId }], + statusCode, + response: async (response: TestResponse) => { + if (outcome === 'authorized') { + const { success, successCount, successResults, errors } = getResult(response); + if (overwrite) { + expect(success).to.eql(true); + expect(successCount).to.eql(1); + expect(successResults).to.eql([{ type, id: exactMatchId }]); + 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' }, + type, + id: exactMatchId, + title: 'A shared saved-object in the default, space_1, and space_2 spaces', + }, + ]); + } + } else if (outcome === 'noAccess') { + expectNotFoundResponse(response); + } else { + // unauthorized read/write + expectForbiddenResponse(response); + } + }, + }, + { + testTitle: 'copying with an inexact match conflict', + objects: [{ type, id: inexactMatchId }], + statusCode, + response: async (response: TestResponse) => { + if (outcome === 'authorized') { + const { success, successCount, successResults, errors } = getResult(response); + const destinationId = 'conflict_1_space_2'; + if (overwrite) { + expect(success).to.eql(true); + expect(successCount).to.eql(1); + expect(successResults).to.eql([{ type, id: inexactMatchId, 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: inexactMatchId, + title: 'A shared saved-object in one space', + }, + ]); + } + } else if (outcome === 'noAccess') { + expectNotFoundResponse(response); + } else { + // unauthorized read/write + expectForbiddenResponse(response); + } + }, + }, + { + testTitle: 'copying with an ambiguous conflict', + objects: [{ type, id: ambiguousConflictId }], + statusCode, + response: async (response: TestResponse) => { + if (outcome === 'authorized') { + const { success, successCount, successResults, errors } = getResult(response); + const updatedAt = '2017-09-21T18:59:16.270Z'; + const destinations = [ + // response should be sorted by ID in ascending order + { id: 'conflict_2_all', title: 'A shared saved-object in all spaces', updatedAt }, + { id: 'conflict_2_space_2', title: 'A shared saved-object in one space', updatedAt }, + ]; + expect(success).to.eql(false); + expect(successCount).to.eql(0); + expect(successResults).to.be(undefined); + expect(errors).to.eql([ + { + error: { type: 'ambiguous_conflict', destinations }, + type, + id: ambiguousConflictId, + title: 'A shared saved-object in one space', + }, + ]); + } else if (outcome === 'noAccess') { + expectNotFoundResponse(response); + } else { + // unauthorized read/write + expectForbiddenResponse(response); + } + }, + }, + ]; }; const makeCopyToSpaceTest = (describeFn: DescribeFn) => ( @@ -363,162 +493,153 @@ export function copyToSpaceTestSuiteFactory( expect(['default', 'space_1']).to.contain(spaceId); }); - beforeEach(() => esArchiver.load('saved_objects/spaces')); - afterEach(() => esArchiver.unload('saved_objects/spaces')); - - it(`should return ${tests.noConflictsWithoutReferences.statusCode} when copying to space without conflicts or references`, async () => { - const destination = getDestinationWithoutConflicts(); - - await assertSpaceCounts(destination, INITIAL_COUNTS[destination]); - - return supertest - .post(`${getUrlPrefix(spaceId)}/api/spaces/_copy_saved_objects`) - .auth(user.username, user.password) - .send({ - objects: [ - { - type: 'dashboard', - id: 'cts_dashboard', - }, - ], - spaces: [destination], - includeReferences: false, - overwrite: false, - }) - .expect(tests.noConflictsWithoutReferences.statusCode) - .then(tests.noConflictsWithoutReferences.response); - }); - - it(`should return ${tests.noConflictsWithReferences.statusCode} when copying to space without conflicts with references`, async () => { - const destination = getDestinationWithoutConflicts(); - - await assertSpaceCounts(destination, INITIAL_COUNTS[destination]); - - return supertest - .post(`${getUrlPrefix(spaceId)}/api/spaces/_copy_saved_objects`) - .auth(user.username, user.password) - .send({ - objects: [ - { - type: 'dashboard', - id: 'cts_dashboard', - }, - ], - spaces: [destination], - includeReferences: true, - overwrite: false, - }) - .expect(tests.noConflictsWithReferences.statusCode) - .then(tests.noConflictsWithReferences.response); - }); - - it(`should return ${tests.withConflictsOverwriting.statusCode} when copying to space with conflicts when overwriting`, async () => { - const destination = getDestinationWithConflicts(spaceId); - - await assertSpaceCounts(destination, INITIAL_COUNTS[destination]); - - return supertest - .post(`${getUrlPrefix(spaceId)}/api/spaces/_copy_saved_objects`) - .auth(user.username, user.password) - .send({ - objects: [ - { - type: 'dashboard', - id: 'cts_dashboard', - }, - ], - spaces: [destination], - includeReferences: true, - overwrite: true, - }) - .expect(tests.withConflictsOverwriting.statusCode) - .then(tests.withConflictsOverwriting.response); - }); - - it(`should return ${tests.withConflictsWithoutOverwriting.statusCode} when copying to space with conflicts without overwriting`, async () => { - const destination = getDestinationWithConflicts(spaceId); - - await assertSpaceCounts(destination, INITIAL_COUNTS[destination]); - - return supertest - .post(`${getUrlPrefix(spaceId)}/api/spaces/_copy_saved_objects`) - .auth(user.username, user.password) - .send({ - objects: [ - { - type: 'dashboard', - id: 'cts_dashboard', - }, - ], - spaces: [destination], - includeReferences: true, - overwrite: false, - }) - .expect(tests.withConflictsWithoutOverwriting.statusCode) - .then(tests.withConflictsWithoutOverwriting.response); - }); - - it(`should return ${tests.multipleSpaces.statusCode} when copying to multiple spaces`, async () => { - const conflictDestination = getDestinationWithConflicts(spaceId); - const noConflictDestination = getDestinationWithoutConflicts(); - - return supertest - .post(`${getUrlPrefix(spaceId)}/api/spaces/_copy_saved_objects`) - .auth(user.username, user.password) - .send({ - objects: [ - { - type: 'dashboard', - id: 'cts_dashboard', - }, - ], - spaces: [conflictDestination, noConflictDestination], - includeReferences: true, - overwrite: true, - }) - .expect(tests.multipleSpaces.statusCode) - .then((response: TestResponse) => { - if (tests.multipleSpaces.statusCode === 200) { - expect(Object.keys(response.body).length).to.eql(2); + describe('single-namespace types', () => { + beforeEach(() => esArchiver.load('saved_objects/spaces')); + afterEach(() => esArchiver.unload('saved_objects/spaces')); + + const dashboardObject = { type: 'dashboard', id: 'cts_dashboard' }; + + it(`should return ${tests.noConflictsWithoutReferences.statusCode} when copying to space without conflicts or references`, async () => { + const destination = getDestinationWithoutConflicts(); + + await assertSpaceCounts(destination, INITIAL_COUNTS[destination]); + + return supertest + .post(`${getUrlPrefix(spaceId)}/api/spaces/_copy_saved_objects`) + .auth(user.username, user.password) + .send({ + objects: [dashboardObject], + spaces: [destination], + includeReferences: false, + overwrite: false, + }) + .expect(tests.noConflictsWithoutReferences.statusCode) + .then(tests.noConflictsWithoutReferences.response); + }); + + it(`should return ${tests.noConflictsWithReferences.statusCode} when copying to space without conflicts with references`, async () => { + const destination = getDestinationWithoutConflicts(); + + await assertSpaceCounts(destination, INITIAL_COUNTS[destination]); + + return supertest + .post(`${getUrlPrefix(spaceId)}/api/spaces/_copy_saved_objects`) + .auth(user.username, user.password) + .send({ + objects: [dashboardObject], + spaces: [destination], + includeReferences: true, + overwrite: false, + }) + .expect(tests.noConflictsWithReferences.statusCode) + .then(tests.noConflictsWithReferences.response); + }); + + it(`should return ${tests.withConflictsOverwriting.statusCode} when copying to space with conflicts when overwriting`, async () => { + const destination = getDestinationWithConflicts(spaceId); + + await assertSpaceCounts(destination, INITIAL_COUNTS[destination]); + + return supertest + .post(`${getUrlPrefix(spaceId)}/api/spaces/_copy_saved_objects`) + .auth(user.username, user.password) + .send({ + objects: [dashboardObject], + spaces: [destination], + includeReferences: true, + overwrite: true, + }) + .expect(tests.withConflictsOverwriting.statusCode) + .then(tests.withConflictsOverwriting.response); + }); + + it(`should return ${tests.withConflictsWithoutOverwriting.statusCode} when copying to space with conflicts without overwriting`, async () => { + const destination = getDestinationWithConflicts(spaceId); + + await assertSpaceCounts(destination, INITIAL_COUNTS[destination]); + + return supertest + .post(`${getUrlPrefix(spaceId)}/api/spaces/_copy_saved_objects`) + .auth(user.username, user.password) + .send({ + objects: [dashboardObject], + spaces: [destination], + includeReferences: true, + overwrite: false, + }) + .expect(tests.withConflictsWithoutOverwriting.statusCode) + .then(tests.withConflictsWithoutOverwriting.response); + }); + + it(`should return ${tests.multipleSpaces.statusCode} when copying to multiple spaces`, async () => { + const conflictDestination = getDestinationWithConflicts(spaceId); + const noConflictDestination = getDestinationWithoutConflicts(); + + return supertest + .post(`${getUrlPrefix(spaceId)}/api/spaces/_copy_saved_objects`) + .auth(user.username, user.password) + .send({ + objects: [dashboardObject], + spaces: [conflictDestination, noConflictDestination], + includeReferences: true, + overwrite: true, + }) + .expect(tests.multipleSpaces.statusCode) + .then((response: TestResponse) => { + if (tests.multipleSpaces.statusCode === 200) { + expect(Object.keys(response.body).length).to.eql(2); + return Promise.all([ + tests.multipleSpaces.noConflictsResponse({ + body: { [noConflictDestination]: response.body[noConflictDestination] }, + }), + tests.multipleSpaces.withConflictsResponse({ + body: { [conflictDestination]: response.body[conflictDestination] }, + }), + ]); + } + + // non-200 status codes will not have a response body broken out by space id, like above. return Promise.all([ - tests.multipleSpaces.noConflictsResponse({ - body: { - [noConflictDestination]: response.body[noConflictDestination], - }, - }), - tests.multipleSpaces.withConflictsResponse({ - body: { - [conflictDestination]: response.body[conflictDestination], - }, - }), + tests.multipleSpaces.noConflictsResponse(response), + tests.multipleSpaces.withConflictsResponse(response), ]); - } - - // non-200 status codes will not have a response body broken out by space id, like above. - return Promise.all([ - tests.multipleSpaces.noConflictsResponse(response), - tests.multipleSpaces.withConflictsResponse(response), - ]); - }); + }); + }); + + it(`should return ${tests.nonExistentSpace.statusCode} when copying to non-existent space`, async () => { + return supertest + .post(`${getUrlPrefix(spaceId)}/api/spaces/_copy_saved_objects`) + .auth(user.username, user.password) + .send({ + objects: [dashboardObject], + spaces: ['non_existent_space'], + includeReferences: false, + overwrite: true, + }) + .expect(tests.nonExistentSpace.statusCode) + .then(tests.nonExistentSpace.response); + }); }); - it(`should return ${tests.nonExistentSpace.statusCode} when copying to non-existent space`, async () => { - return supertest - .post(`${getUrlPrefix(spaceId)}/api/spaces/_copy_saved_objects`) - .auth(user.username, user.password) - .send({ - objects: [ - { - type: 'dashboard', - id: 'cts_dashboard', - }, - ], - spaces: ['non_existent_space'], - includeReferences: false, - overwrite: true, - }) - .expect(tests.nonExistentSpace.statusCode) - .then(tests.nonExistentSpace.response); + [false, true].forEach((overwrite) => { + const spaces = ['space_2']; + const includeReferences = false; + describe(`multi-namespace types with overwrite=${overwrite}`, () => { + before(() => esArchiver.load('saved_objects/spaces')); + after(() => esArchiver.unload('saved_objects/spaces')); + + const testCases = tests.multiNamespaceTestCases(overwrite); + testCases.forEach(({ testTitle, objects, statusCode, response }) => { + it(`should return ${statusCode} when ${testTitle}`, async () => { + return supertest + .post(`${getUrlPrefix(spaceId)}/api/spaces/_copy_saved_objects`) + .auth(user.username, user.password) + .send({ objects, spaces, includeReferences, overwrite }) + .expect(statusCode) + .then(response); + }); + }); + }); }); }); }; @@ -534,10 +655,10 @@ export function copyToSpaceTestSuiteFactory( expectNoConflictsForNonExistentSpaceResult, createExpectWithConflictsOverwritingResult, createExpectWithConflictsWithoutOverwritingResult, - expectRbacForbiddenResponse, expectNotFoundResponse, createExpectUnauthorizedAtSpaceWithReferencesResult, createExpectUnauthorizedAtSpaceWithoutReferencesResult, + createMultiNamespaceTestCases, originSpaces: ['default', 'space_1'], }; } 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 15a90092f5517..69b5697d8a9a8 100644 --- a/x-pack/test/spaces_api_integration/common/suites/delete.ts +++ b/x-pack/test/spaces_api_integration/common/suites/delete.ts @@ -130,7 +130,7 @@ export function deleteTestSuiteFactory(es: any, esArchiver: any, supertest: Supe expect(buckets).to.eql(expectedBuckets); - // There were seven multi-namespace objects. + // There were eleven 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({ @@ -138,16 +138,13 @@ export function deleteTestSuiteFactory(es: any, esArchiver: any, supertest: Supe body: { query: { terms: { type: ['sharedtype'] } } }, }); const docs: [Record] = multiNamespaceResponse.hits.hits; - expect(docs).length(6); // just six results, since spaces_2_only got deleted - Object.values(CASES).forEach(({ id, existingNamespaces }) => { - const remainingNamespaces = existingNamespaces.filter((x) => x !== 'space_2'); - const doc = docs.find((x) => x._id === `sharedtype:${id}`); - if (remainingNamespaces.length > 0) { - expect(doc?._source?.namespaces).to.eql(remainingNamespaces); - } else { - expect(doc).to.be(undefined); - } + expect(docs).length(10); // just ten results, since spaces_2_only got deleted + docs.forEach((doc) => () => { + const containsSpace2 = doc?._source?.namespaces.includes('space_2'); + expect(containsSpace2).to.eql(false); }); + const space2OnlyObjExists = docs.some((x) => x._id === CASES.SPACE_2_ONLY); + expect(space2OnlyObjExists).to.eql(false); }; const expectNotFound = (resp: { [key: string]: any }) => { 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 3529d8f3ae9c9..3dab8a4e7f244 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 @@ -20,12 +20,19 @@ interface ResolveCopyToSpaceTest { response: (resp: TestResponse) => Promise; } +interface ResolveCopyToSpaceMultiNamespaceTest extends ResolveCopyToSpaceTest { + testTitle: string; + objects: Array>; + retries: Record; +} + interface ResolveCopyToSpaceTests { withReferencesNotOverwriting: ResolveCopyToSpaceTest; withReferencesOverwriting: ResolveCopyToSpaceTest; withoutReferencesOverwriting: ResolveCopyToSpaceTest; withoutReferencesNotOverwriting: ResolveCopyToSpaceTest; nonExistentSpace: ResolveCopyToSpaceTest; + multiNamespaceTestCases: () => ResolveCopyToSpaceMultiNamespaceTest[]; } interface ResolveCopyToSpaceTestDefinition { @@ -76,6 +83,7 @@ export function resolveCopyToSpaceConflictsSuite( [destination]: { success: true, successCount: 1, + successResults: [{ id: 'cts_vis_3', type: 'visualization' }], }, }); const [dashboard, visualization] = await getObjectsAtSpace(destination); @@ -94,6 +102,7 @@ export function resolveCopyToSpaceConflictsSuite( [destinationSpaceId]: { success: true, successCount: 1, + successResults: [{ id: 'cts_dashboard', type: 'dashboard' }], }, }); const [dashboard, visualization] = await getObjectsAtSpace(destinationSpaceId); @@ -119,9 +128,7 @@ export function resolveCopyToSpaceConflictsSuite( successCount: 0, errors: [ { - error: { - type: 'conflict', - }, + error: { type: 'conflict' }, id: 'cts_vis_3', title: `CTS vis 3 from ${sourceSpaceId} space`, type: 'visualization', @@ -149,9 +156,7 @@ export function resolveCopyToSpaceConflictsSuite( successCount: 0, errors: [ { - error: { - type: 'conflict', - }, + error: { type: 'conflict' }, id: 'cts_dashboard', title: `This is the ${sourceSpaceId} test space CTS dashboard`, type: 'dashboard', @@ -264,6 +269,106 @@ export function resolveCopyToSpaceConflictsSuite( } }; + /** + * Creates test cases for multi-namespace saved object types. + * Note: these are written with the assumption that test data will only be reloaded between each group of test cases, *not* before every + * single test case. This saves time during test execution. + */ + const createMultiNamespaceTestCases = ( + spaceId: string, + outcome: 'authorized' | 'unauthorizedRead' | 'unauthorizedWrite' | 'noAccess' = 'authorized' + ) => (): ResolveCopyToSpaceMultiNamespaceTest[] => { + // the status code of the HTTP response differs depending on the error type + // a 403 error actually comes back as an HTTP 200 response + const statusCode = outcome === 'noAccess' ? 404 : 200; + const type = 'sharedtype'; + const exactMatchId = 'all_spaces'; + const inexactMatchId = `conflict_1_${spaceId}`; + const ambiguousConflictId = `conflict_2_${spaceId}`; + + const createRetries = (overwriteRetry: Record) => ({ space_2: [overwriteRetry] }); + const getResult = (response: TestResponse) => (response.body as CopyResponse).space_2; + const expectForbiddenResponse = (response: TestResponse) => { + expect(response.body).to.eql({ + space_2: { + success: false, + successCount: 0, + errors: [ + { statusCode: 403, error: 'Forbidden', message: `Unable to bulk_create sharedtype` }, + ], + }, + }); + }; + const expectSuccessResponse = (response: TestResponse, id: string, destinationId?: string) => { + const { success, successCount, successResults, errors } = getResult(response); + expect(success).to.eql(true); + expect(successCount).to.eql(1); + expect(errors).to.be(undefined); + expect(successResults).to.eql([{ type, id, ...(destinationId && { destinationId }) }]); + }; + + return [ + { + testTitle: 'copying with an exact match conflict', + objects: [{ type, id: exactMatchId }], + retries: createRetries({ type, id: exactMatchId, overwrite: true }), + statusCode, + response: async (response: TestResponse) => { + if (outcome === 'authorized') { + expectSuccessResponse(response, exactMatchId); + } else if (outcome === 'noAccess') { + expectNotFoundResponse(response); + } else { + // unauthorized read/write + expectForbiddenResponse(response); + } + }, + }, + { + testTitle: 'copying with an inexact match conflict', + objects: [{ type, id: inexactMatchId }], + retries: createRetries({ + type, + id: inexactMatchId, + overwrite: true, + destinationId: 'conflict_1_space_2', + }), + statusCode, + response: async (response: TestResponse) => { + if (outcome === 'authorized') { + expectSuccessResponse(response, inexactMatchId, 'conflict_1_space_2'); + } else if (outcome === 'noAccess') { + expectNotFoundResponse(response); + } else { + // unauthorized read/write + expectForbiddenResponse(response); + } + }, + }, + { + testTitle: 'copying with an ambiguous conflict', + objects: [{ type, id: ambiguousConflictId }], + retries: createRetries({ + type, + id: ambiguousConflictId, + overwrite: true, + destinationId: 'conflict_2_space_2', + }), + statusCode, + response: async (response: TestResponse) => { + if (outcome === 'authorized') { + expectSuccessResponse(response, ambiguousConflictId, 'conflict_2_space_2'); + } else if (outcome === 'noAccess') { + expectNotFoundResponse(response); + } else { + // unauthorized read/write + expectForbiddenResponse(response); + } + }, + }, + ]; + }; + const makeResolveCopyToSpaceConflictsTest = (describeFn: DescribeFn) => ( description: string, { user = {}, spaceId = DEFAULT_SPACE_ID, tests }: ResolveCopyToSpaceTestDefinition @@ -274,147 +379,105 @@ export function resolveCopyToSpaceConflictsSuite( expect(['default', 'space_1']).to.contain(spaceId); }); - beforeEach(() => esArchiver.load('saved_objects/spaces')); - afterEach(() => esArchiver.unload('saved_objects/spaces')); - - it(`should return ${tests.withReferencesNotOverwriting.statusCode} when not overwriting, with references`, async () => { - const destination = getDestinationSpace(spaceId); - - return supertestWithoutAuth - .post(`${getUrlPrefix(spaceId)}/api/spaces/_resolve_copy_saved_objects_errors`) - .auth(user.username, user.password) - .send({ - objects: [ - { - type: 'dashboard', - id: 'cts_dashboard', - }, - ], - includeReferences: true, - retries: { - [destination]: [ - { - type: 'visualization', - id: 'cts_vis_3', - overwrite: false, - }, - ], - }, - }) - .expect(tests.withReferencesNotOverwriting.statusCode) - .then(tests.withReferencesNotOverwriting.response); - }); - - it(`should return ${tests.withReferencesOverwriting.statusCode} when overwriting, with references`, async () => { - const destination = getDestinationSpace(spaceId); - - return supertestWithoutAuth - .post(`${getUrlPrefix(spaceId)}/api/spaces/_resolve_copy_saved_objects_errors`) - .auth(user.username, user.password) - .send({ - objects: [ - { - type: 'dashboard', - id: 'cts_dashboard', - }, - ], - includeReferences: true, - retries: { - [destination]: [ - { - type: 'visualization', - id: 'cts_vis_3', - overwrite: true, - }, - ], - }, - }) - .expect(tests.withReferencesOverwriting.statusCode) - .then(tests.withReferencesOverwriting.response); - }); - - it(`should return ${tests.withoutReferencesOverwriting.statusCode} when overwriting, without references`, async () => { - const destination = getDestinationSpace(spaceId); - - return supertestWithoutAuth - .post(`${getUrlPrefix(spaceId)}/api/spaces/_resolve_copy_saved_objects_errors`) - .auth(user.username, user.password) - .send({ - objects: [ - { - type: 'dashboard', - id: 'cts_dashboard', - }, - ], - includeReferences: false, - retries: { - [destination]: [ - { - type: 'dashboard', - id: 'cts_dashboard', - overwrite: true, - }, - ], - }, - }) - .expect(tests.withoutReferencesOverwriting.statusCode) - .then(tests.withoutReferencesOverwriting.response); - }); - - it(`should return ${tests.withoutReferencesNotOverwriting.statusCode} when not overwriting, without references`, async () => { - const destination = getDestinationSpace(spaceId); - - return supertestWithoutAuth - .post(`${getUrlPrefix(spaceId)}/api/spaces/_resolve_copy_saved_objects_errors`) - .auth(user.username, user.password) - .send({ - objects: [ - { - type: 'dashboard', - id: 'cts_dashboard', - }, - ], - includeReferences: false, - retries: { - [destination]: [ - { - type: 'dashboard', - id: 'cts_dashboard', - overwrite: false, - }, - ], - }, - }) - .expect(tests.withoutReferencesNotOverwriting.statusCode) - .then(tests.withoutReferencesNotOverwriting.response); + describe('single-namespace types', () => { + beforeEach(() => esArchiver.load('saved_objects/spaces')); + afterEach(() => esArchiver.unload('saved_objects/spaces')); + + const dashboardObject = { type: 'dashboard', id: 'cts_dashboard' }; + const visualizationObject = { type: 'visualization', id: 'cts_vis_3' }; + + it(`should return ${tests.withReferencesNotOverwriting.statusCode} when not overwriting, with references`, async () => { + const destination = getDestinationSpace(spaceId); + + return supertestWithoutAuth + .post(`${getUrlPrefix(spaceId)}/api/spaces/_resolve_copy_saved_objects_errors`) + .auth(user.username, user.password) + .send({ + objects: [dashboardObject], + includeReferences: true, + retries: { [destination]: [{ ...visualizationObject, overwrite: false }] }, + }) + .expect(tests.withReferencesNotOverwriting.statusCode) + .then(tests.withReferencesNotOverwriting.response); + }); + + it(`should return ${tests.withReferencesOverwriting.statusCode} when overwriting, with references`, async () => { + const destination = getDestinationSpace(spaceId); + + return supertestWithoutAuth + .post(`${getUrlPrefix(spaceId)}/api/spaces/_resolve_copy_saved_objects_errors`) + .auth(user.username, user.password) + .send({ + objects: [dashboardObject], + includeReferences: true, + retries: { [destination]: [{ ...visualizationObject, overwrite: true }] }, + }) + .expect(tests.withReferencesOverwriting.statusCode) + .then(tests.withReferencesOverwriting.response); + }); + + it(`should return ${tests.withoutReferencesOverwriting.statusCode} when overwriting, without references`, async () => { + const destination = getDestinationSpace(spaceId); + + return supertestWithoutAuth + .post(`${getUrlPrefix(spaceId)}/api/spaces/_resolve_copy_saved_objects_errors`) + .auth(user.username, user.password) + .send({ + objects: [dashboardObject], + includeReferences: false, + retries: { [destination]: [{ ...dashboardObject, overwrite: true }] }, + }) + .expect(tests.withoutReferencesOverwriting.statusCode) + .then(tests.withoutReferencesOverwriting.response); + }); + + it(`should return ${tests.withoutReferencesNotOverwriting.statusCode} when not overwriting, without references`, async () => { + const destination = getDestinationSpace(spaceId); + + return supertestWithoutAuth + .post(`${getUrlPrefix(spaceId)}/api/spaces/_resolve_copy_saved_objects_errors`) + .auth(user.username, user.password) + .send({ + objects: [dashboardObject], + includeReferences: false, + retries: { [destination]: [{ ...dashboardObject, overwrite: false }] }, + }) + .expect(tests.withoutReferencesNotOverwriting.statusCode) + .then(tests.withoutReferencesNotOverwriting.response); + }); + + it(`should return ${tests.nonExistentSpace.statusCode} when resolving within a non-existent space`, async () => { + const destination = NON_EXISTENT_SPACE_ID; + + return supertestWithoutAuth + .post(`${getUrlPrefix(spaceId)}/api/spaces/_resolve_copy_saved_objects_errors`) + .auth(user.username, user.password) + .send({ + objects: [dashboardObject], + includeReferences: false, + retries: { [destination]: [{ ...dashboardObject, overwrite: true }] }, + }) + .expect(tests.nonExistentSpace.statusCode) + .then(tests.nonExistentSpace.response); + }); }); - it(`should return ${tests.nonExistentSpace.statusCode} when resolving within a non-existent space`, async () => { - const destination = NON_EXISTENT_SPACE_ID; - - return supertestWithoutAuth - .post(`${getUrlPrefix(spaceId)}/api/spaces/_resolve_copy_saved_objects_errors`) - .auth(user.username, user.password) - .send({ - objects: [ - { - type: 'dashboard', - id: 'cts_dashboard', - }, - ], - includeReferences: false, - retries: { - [destination]: [ - { - type: 'dashboard', - id: 'cts_dashboard', - overwrite: true, - }, - ], - }, - }) - .expect(tests.nonExistentSpace.statusCode) - .then(tests.nonExistentSpace.response); + const includeReferences = false; + describe(`multi-namespace types with "overwrite" retry`, () => { + before(() => esArchiver.load('saved_objects/spaces')); + after(() => esArchiver.unload('saved_objects/spaces')); + + const testCases = tests.multiNamespaceTestCases(); + testCases.forEach(({ testTitle, objects, retries, statusCode, response }) => { + it(`should return ${statusCode} when ${testTitle}`, async () => { + return supertestWithoutAuth + .post(`${getUrlPrefix(spaceId)}/api/spaces/_resolve_copy_saved_objects_errors`) + .auth(user.username, user.password) + .send({ objects, includeReferences, retries }) + .expect(statusCode) + .then(response); + }); + }); }); }); }; @@ -433,6 +496,7 @@ export function resolveCopyToSpaceConflictsSuite( createExpectUnauthorizedAtSpaceWithReferencesResult, createExpectReadonlyAtSpaceWithReferencesResult, createExpectUnauthorizedAtSpaceWithoutReferencesResult, + createMultiNamespaceTestCases, originSpaces: ['default', 'space_1'], NON_EXISTENT_SPACE_ID, }; diff --git a/x-pack/test/spaces_api_integration/security_and_spaces/apis/copy_to_space.ts b/x-pack/test/spaces_api_integration/security_and_spaces/apis/copy_to_space.ts index 08450f48567c8..2c4fc6d38d79d 100644 --- a/x-pack/test/spaces_api_integration/security_and_spaces/apis/copy_to_space.ts +++ b/x-pack/test/spaces_api_integration/security_and_spaces/apis/copy_to_space.ts @@ -25,6 +25,7 @@ export default function copyToSpaceSpacesAndSecuritySuite({ getService }: TestIn createExpectUnauthorizedAtSpaceWithReferencesResult, createExpectUnauthorizedAtSpaceWithoutReferencesResult, expectNotFoundResponse, + createMultiNamespaceTestCases, } = copyToSpaceTestSuiteFactory(es, esArchiver, supertestWithoutAuth); describe('copy to spaces', () => { @@ -55,325 +56,148 @@ export default function copyToSpaceSpacesAndSecuritySuite({ getService }: TestIn dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, }, }, - ].forEach((scenario) => { - copyToSpaceTest(`user with no access from the ${scenario.spaceId} space`, { - spaceId: scenario.spaceId, - user: scenario.users.noAccess, + ].forEach(({ spaceId, ...scenario }) => { + const definitionNoAccess = (user: { username: string; password: string }) => ({ + spaceId, + user, tests: { - noConflictsWithoutReferences: { - statusCode: 404, - response: expectNotFoundResponse, - }, - noConflictsWithReferences: { - statusCode: 404, - response: expectNotFoundResponse, - }, - withConflictsOverwriting: { - statusCode: 404, - response: expectNotFoundResponse, - }, - withConflictsWithoutOverwriting: { - statusCode: 404, - response: expectNotFoundResponse, - }, + noConflictsWithoutReferences: { statusCode: 404, response: expectNotFoundResponse }, + noConflictsWithReferences: { statusCode: 404, response: expectNotFoundResponse }, + withConflictsOverwriting: { statusCode: 404, response: expectNotFoundResponse }, + withConflictsWithoutOverwriting: { statusCode: 404, response: expectNotFoundResponse }, multipleSpaces: { statusCode: 404, withConflictsResponse: expectNotFoundResponse, noConflictsResponse: expectNotFoundResponse, }, - nonExistentSpace: { - statusCode: 404, - response: expectNotFoundResponse, - }, + nonExistentSpace: { statusCode: 404, response: expectNotFoundResponse }, + multiNamespaceTestCases: createMultiNamespaceTestCases(spaceId, 'noAccess'), }, }); - - copyToSpaceTest(`superuser from the ${scenario.spaceId} space`, { - spaceId: scenario.spaceId, - user: scenario.users.superuser, - tests: { - noConflictsWithoutReferences: { - statusCode: 200, - response: expectNoConflictsWithoutReferencesResult, - }, - noConflictsWithReferences: { - statusCode: 200, - response: expectNoConflictsWithReferencesResult, - }, - withConflictsOverwriting: { - statusCode: 200, - response: createExpectWithConflictsOverwritingResult(scenario.spaceId), - }, - withConflictsWithoutOverwriting: { - statusCode: 200, - response: createExpectWithConflictsWithoutOverwritingResult(scenario.spaceId), - }, - multipleSpaces: { - statusCode: 200, - withConflictsResponse: createExpectWithConflictsOverwritingResult(scenario.spaceId), - noConflictsResponse: expectNoConflictsWithReferencesResult, - }, - nonExistentSpace: { - statusCode: 200, - response: expectNoConflictsForNonExistentSpaceResult, - }, + // In *this* test suite, a user who is unauthorized to write (but authorized to read) in the destination space will get the same exact + // results as a user who is unauthorized to read in the destination space. However, that may not *always* be the case depending on the + // input that is submitted, due to the `validateReferences` check that can trigger a `bulkGet` for the destination space. See also the + // integration tests in `./resolve_copy_to_space_conflicts`, which behave differently. + const commonUnauthorizedTests = { + noConflictsWithoutReferences: { + statusCode: 200, + response: createExpectUnauthorizedAtSpaceWithoutReferencesResult( + spaceId, + 'without-conflicts' + ), }, - }); - - copyToSpaceTest(`rbac user with all globally from the ${scenario.spaceId} space`, { - spaceId: scenario.spaceId, - user: scenario.users.allGlobally, - tests: { - noConflictsWithoutReferences: { - statusCode: 200, - response: expectNoConflictsWithoutReferencesResult, - }, - noConflictsWithReferences: { - statusCode: 200, - response: expectNoConflictsWithReferencesResult, - }, - withConflictsOverwriting: { - statusCode: 200, - response: createExpectWithConflictsOverwritingResult(scenario.spaceId), - }, - withConflictsWithoutOverwriting: { - statusCode: 200, - response: createExpectWithConflictsWithoutOverwritingResult(scenario.spaceId), - }, - multipleSpaces: { - statusCode: 200, - withConflictsResponse: createExpectWithConflictsOverwritingResult(scenario.spaceId), - noConflictsResponse: expectNoConflictsWithReferencesResult, - }, - nonExistentSpace: { - statusCode: 200, - response: expectNoConflictsForNonExistentSpaceResult, - }, + noConflictsWithReferences: { + statusCode: 200, + response: createExpectUnauthorizedAtSpaceWithReferencesResult( + spaceId, + 'without-conflicts' + ), }, - }); - - copyToSpaceTest(`dual-privileges user from the ${scenario.spaceId} space`, { - spaceId: scenario.spaceId, - user: scenario.users.dualAll, - tests: { - noConflictsWithoutReferences: { - statusCode: 200, - response: expectNoConflictsWithoutReferencesResult, - }, - noConflictsWithReferences: { - statusCode: 200, - response: expectNoConflictsWithReferencesResult, - }, - withConflictsOverwriting: { - statusCode: 200, - response: createExpectWithConflictsOverwritingResult(scenario.spaceId), - }, - withConflictsWithoutOverwriting: { - statusCode: 200, - response: createExpectWithConflictsWithoutOverwritingResult(scenario.spaceId), - }, - multipleSpaces: { - statusCode: 200, - withConflictsResponse: createExpectWithConflictsOverwritingResult(scenario.spaceId), - noConflictsResponse: expectNoConflictsWithReferencesResult, - }, - nonExistentSpace: { - statusCode: 200, - response: expectNoConflictsForNonExistentSpaceResult, - }, + withConflictsOverwriting: { + statusCode: 200, + response: createExpectUnauthorizedAtSpaceWithReferencesResult(spaceId, 'with-conflicts'), }, - }); - - copyToSpaceTest(`legacy user from the ${scenario.spaceId} space`, { - spaceId: scenario.spaceId, - user: scenario.users.legacyAll, - tests: { - noConflictsWithoutReferences: { - statusCode: 404, - response: expectNotFoundResponse, - }, - noConflictsWithReferences: { - statusCode: 404, - response: expectNotFoundResponse, - }, - withConflictsOverwriting: { - statusCode: 404, - response: expectNotFoundResponse, - }, - withConflictsWithoutOverwriting: { - statusCode: 404, - response: expectNotFoundResponse, - }, - multipleSpaces: { - statusCode: 404, - withConflictsResponse: expectNotFoundResponse, - noConflictsResponse: expectNotFoundResponse, - }, - nonExistentSpace: { - statusCode: 404, - response: expectNotFoundResponse, - }, + withConflictsWithoutOverwriting: { + statusCode: 200, + response: createExpectUnauthorizedAtSpaceWithReferencesResult(spaceId, 'with-conflicts'), }, - }); - - copyToSpaceTest(`rbac user with read globally from the ${scenario.spaceId} space`, { - spaceId: scenario.spaceId, - user: scenario.users.readGlobally, + multipleSpaces: { + statusCode: 200, + withConflictsResponse: createExpectUnauthorizedAtSpaceWithReferencesResult( + spaceId, + 'with-conflicts' + ), + noConflictsResponse: createExpectUnauthorizedAtSpaceWithReferencesResult( + spaceId, + 'without-conflicts' + ), + }, + nonExistentSpace: { + statusCode: 200, + response: createExpectUnauthorizedAtSpaceWithoutReferencesResult(spaceId, 'non-existent'), + }, + }; + const definitionUnauthorizedRead = (user: { username: string; password: string }) => ({ + spaceId, + user, tests: { - noConflictsWithoutReferences: { - statusCode: 200, - response: createExpectUnauthorizedAtSpaceWithoutReferencesResult( - scenario.spaceId, - 'without-conflicts' - ), - }, - noConflictsWithReferences: { - statusCode: 200, - response: createExpectUnauthorizedAtSpaceWithReferencesResult( - scenario.spaceId, - 'without-conflicts' - ), - }, - withConflictsOverwriting: { - statusCode: 200, - response: createExpectUnauthorizedAtSpaceWithReferencesResult( - scenario.spaceId, - 'with-conflicts' - ), - }, - withConflictsWithoutOverwriting: { - statusCode: 200, - response: createExpectUnauthorizedAtSpaceWithReferencesResult( - scenario.spaceId, - 'with-conflicts' - ), - }, - multipleSpaces: { - statusCode: 200, - withConflictsResponse: createExpectUnauthorizedAtSpaceWithReferencesResult( - scenario.spaceId, - 'with-conflicts' - ), - noConflictsResponse: createExpectUnauthorizedAtSpaceWithReferencesResult( - scenario.spaceId, - 'without-conflicts' - ), - }, - nonExistentSpace: { - statusCode: 200, - response: createExpectUnauthorizedAtSpaceWithoutReferencesResult( - scenario.spaceId, - 'non-existent' - ), - }, + ...commonUnauthorizedTests, + multiNamespaceTestCases: createMultiNamespaceTestCases(spaceId, 'unauthorizedRead'), }, }); - - copyToSpaceTest(`dual-privileges readonly user from the ${scenario.spaceId} space`, { - spaceId: scenario.spaceId, - user: scenario.users.dualRead, + const definitionUnauthorizedWrite = (user: { username: string; password: string }) => ({ + spaceId, + user, tests: { - noConflictsWithoutReferences: { - statusCode: 200, - response: createExpectUnauthorizedAtSpaceWithoutReferencesResult( - scenario.spaceId, - 'without-conflicts' - ), - }, - noConflictsWithReferences: { - statusCode: 200, - response: createExpectUnauthorizedAtSpaceWithReferencesResult( - scenario.spaceId, - 'without-conflicts' - ), - }, - withConflictsOverwriting: { - statusCode: 200, - response: createExpectUnauthorizedAtSpaceWithReferencesResult( - scenario.spaceId, - 'with-conflicts' - ), - }, - withConflictsWithoutOverwriting: { - statusCode: 200, - response: createExpectUnauthorizedAtSpaceWithReferencesResult( - scenario.spaceId, - 'with-conflicts' - ), - }, - multipleSpaces: { - statusCode: 200, - withConflictsResponse: createExpectUnauthorizedAtSpaceWithReferencesResult( - scenario.spaceId, - 'with-conflicts' - ), - noConflictsResponse: createExpectUnauthorizedAtSpaceWithReferencesResult( - scenario.spaceId, - 'without-conflicts' - ), - }, - nonExistentSpace: { - statusCode: 200, - response: createExpectUnauthorizedAtSpaceWithoutReferencesResult( - scenario.spaceId, - 'non-existent' - ), - }, + ...commonUnauthorizedTests, + multiNamespaceTestCases: createMultiNamespaceTestCases(spaceId, 'unauthorizedWrite'), }, }); - - copyToSpaceTest(`rbac user with all at space from the ${scenario.spaceId} space`, { - spaceId: scenario.spaceId, - user: scenario.users.allAtSpace, + const definitionAuthorized = (user: { username: string; password: string }) => ({ + spaceId, + user, tests: { noConflictsWithoutReferences: { statusCode: 200, - response: createExpectUnauthorizedAtSpaceWithoutReferencesResult( - scenario.spaceId, - 'without-conflicts' - ), + response: expectNoConflictsWithoutReferencesResult, }, noConflictsWithReferences: { statusCode: 200, - response: createExpectUnauthorizedAtSpaceWithReferencesResult( - scenario.spaceId, - 'without-conflicts' - ), + response: expectNoConflictsWithReferencesResult(spaceId), }, withConflictsOverwriting: { statusCode: 200, - response: createExpectUnauthorizedAtSpaceWithReferencesResult( - scenario.spaceId, - 'with-conflicts' - ), + response: createExpectWithConflictsOverwritingResult(spaceId), }, withConflictsWithoutOverwriting: { statusCode: 200, - response: createExpectUnauthorizedAtSpaceWithReferencesResult( - scenario.spaceId, - 'with-conflicts' - ), + response: createExpectWithConflictsWithoutOverwritingResult(spaceId), }, multipleSpaces: { statusCode: 200, - withConflictsResponse: createExpectUnauthorizedAtSpaceWithReferencesResult( - scenario.spaceId, - 'with-conflicts' - ), - noConflictsResponse: createExpectUnauthorizedAtSpaceWithReferencesResult( - scenario.spaceId, - 'without-conflicts' - ), + withConflictsResponse: createExpectWithConflictsOverwritingResult(spaceId), + noConflictsResponse: expectNoConflictsWithReferencesResult(spaceId), }, nonExistentSpace: { statusCode: 200, - response: createExpectUnauthorizedAtSpaceWithoutReferencesResult( - scenario.spaceId, - 'non-existent' - ), + response: expectNoConflictsForNonExistentSpaceResult, }, + multiNamespaceTestCases: createMultiNamespaceTestCases(spaceId, 'authorized'), }, }); + + copyToSpaceTest( + `user with no access from the ${spaceId} space`, + definitionNoAccess(scenario.users.noAccess) + ); + copyToSpaceTest( + `superuser from the ${spaceId} space`, + definitionAuthorized(scenario.users.superuser) + ); + copyToSpaceTest( + `rbac user with all globally from the ${spaceId} space`, + definitionAuthorized(scenario.users.allGlobally) + ); + copyToSpaceTest( + `dual-privileges user from the ${spaceId} space`, + definitionAuthorized(scenario.users.dualAll) + ); + copyToSpaceTest( + `legacy user from the ${spaceId} space`, + definitionNoAccess(scenario.users.legacyAll) + ); + copyToSpaceTest( + `rbac user with read globally from the ${spaceId} space`, + definitionUnauthorizedWrite(scenario.users.readGlobally) + ); + copyToSpaceTest( + `dual-privileges readonly user from the ${spaceId} space`, + definitionUnauthorizedWrite(scenario.users.dualRead) + ); + copyToSpaceTest( + `rbac user with all at space from the ${spaceId} space`, + definitionUnauthorizedRead(scenario.users.allAtSpace) + ); }); }); } diff --git a/x-pack/test/spaces_api_integration/security_and_spaces/apis/resolve_copy_to_space_conflicts.ts b/x-pack/test/spaces_api_integration/security_and_spaces/apis/resolve_copy_to_space_conflicts.ts index 472ec1a927126..b81f2965eba22 100644 --- a/x-pack/test/spaces_api_integration/security_and_spaces/apis/resolve_copy_to_space_conflicts.ts +++ b/x-pack/test/spaces_api_integration/security_and_spaces/apis/resolve_copy_to_space_conflicts.ts @@ -25,6 +25,7 @@ export default function resolveCopyToSpaceConflictsTestSuite({ getService }: Tes createExpectUnauthorizedAtSpaceWithReferencesResult, createExpectReadonlyAtSpaceWithReferencesResult, createExpectUnauthorizedAtSpaceWithoutReferencesResult, + createMultiNamespaceTestCases, NON_EXISTENT_SPACE_ID, } = resolveCopyToSpaceConflictsSuite(esArchiver, supertestWithAuth, supertestWithoutAuth); @@ -56,10 +57,10 @@ export default function resolveCopyToSpaceConflictsTestSuite({ getService }: Tes dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, }, }, - ].forEach((scenario) => { - resolveCopyToSpaceConflictsTest(`user with no access from the ${scenario.spaceId} space`, { - spaceId: scenario.spaceId, - user: scenario.users.noAccess, + ].forEach(({ spaceId, ...scenario }) => { + const definitionNoAccess = (user: { username: string; password: string }) => ({ + spaceId, + user, tests: { withReferencesNotOverwriting: { statusCode: 404, @@ -81,226 +82,131 @@ export default function resolveCopyToSpaceConflictsTestSuite({ getService }: Tes statusCode: 404, response: expectNotFoundResponse, }, + multiNamespaceTestCases: createMultiNamespaceTestCases(spaceId, 'noAccess'), }, }); - - resolveCopyToSpaceConflictsTest(`superuser from the ${scenario.spaceId} space`, { - spaceId: scenario.spaceId, - user: scenario.users.superuser, + const definitionUnauthorizedRead = (user: { username: string; password: string }) => ({ + spaceId, + user, tests: { withReferencesNotOverwriting: { statusCode: 200, - response: createExpectNonOverriddenResponseWithReferences(scenario.spaceId), + response: createExpectUnauthorizedAtSpaceWithReferencesResult(spaceId), }, withReferencesOverwriting: { statusCode: 200, - response: createExpectOverriddenResponseWithReferences(scenario.spaceId), + response: createExpectUnauthorizedAtSpaceWithReferencesResult(spaceId), }, withoutReferencesOverwriting: { statusCode: 200, - response: createExpectOverriddenResponseWithoutReferences(scenario.spaceId), + response: createExpectUnauthorizedAtSpaceWithoutReferencesResult(spaceId), }, withoutReferencesNotOverwriting: { statusCode: 200, - response: createExpectNonOverriddenResponseWithoutReferences(scenario.spaceId), + response: createExpectUnauthorizedAtSpaceWithoutReferencesResult(spaceId), }, nonExistentSpace: { statusCode: 200, - response: createExpectOverriddenResponseWithoutReferences( - scenario.spaceId, + response: createExpectUnauthorizedAtSpaceWithoutReferencesResult( + spaceId, NON_EXISTENT_SPACE_ID ), }, + multiNamespaceTestCases: createMultiNamespaceTestCases(spaceId, 'unauthorizedRead'), }, }); - - resolveCopyToSpaceConflictsTest( - `rbac user with all globally from the ${scenario.spaceId} space`, - { - spaceId: scenario.spaceId, - user: scenario.users.allGlobally, - tests: { - withReferencesNotOverwriting: { - statusCode: 200, - response: createExpectNonOverriddenResponseWithReferences(scenario.spaceId), - }, - withReferencesOverwriting: { - statusCode: 200, - response: createExpectOverriddenResponseWithReferences(scenario.spaceId), - }, - withoutReferencesOverwriting: { - statusCode: 200, - response: createExpectOverriddenResponseWithoutReferences(scenario.spaceId), - }, - withoutReferencesNotOverwriting: { - statusCode: 200, - response: createExpectNonOverriddenResponseWithoutReferences(scenario.spaceId), - }, - nonExistentSpace: { - statusCode: 200, - response: createExpectOverriddenResponseWithoutReferences( - scenario.spaceId, - NON_EXISTENT_SPACE_ID - ), - }, - }, - } - ); - - resolveCopyToSpaceConflictsTest(`dual-privileges user from the ${scenario.spaceId} space`, { - spaceId: scenario.spaceId, - user: scenario.users.dualAll, + const definitionUnauthorizedWrite = (user: { username: string; password: string }) => ({ + spaceId, + user, tests: { withReferencesNotOverwriting: { statusCode: 200, - response: createExpectNonOverriddenResponseWithReferences(scenario.spaceId), + response: createExpectReadonlyAtSpaceWithReferencesResult(spaceId), }, withReferencesOverwriting: { statusCode: 200, - response: createExpectOverriddenResponseWithReferences(scenario.spaceId), + response: createExpectReadonlyAtSpaceWithReferencesResult(spaceId), }, withoutReferencesOverwriting: { statusCode: 200, - response: createExpectOverriddenResponseWithoutReferences(scenario.spaceId), + response: createExpectUnauthorizedAtSpaceWithoutReferencesResult(spaceId), }, withoutReferencesNotOverwriting: { statusCode: 200, - response: createExpectNonOverriddenResponseWithoutReferences(scenario.spaceId), + response: createExpectUnauthorizedAtSpaceWithoutReferencesResult(spaceId), }, nonExistentSpace: { statusCode: 200, - response: createExpectOverriddenResponseWithoutReferences( - scenario.spaceId, + response: createExpectUnauthorizedAtSpaceWithoutReferencesResult( + spaceId, NON_EXISTENT_SPACE_ID ), }, + multiNamespaceTestCases: createMultiNamespaceTestCases(spaceId, 'unauthorizedWrite'), }, }); - - resolveCopyToSpaceConflictsTest(`legacy user from the ${scenario.spaceId} space`, { - spaceId: scenario.spaceId, - user: scenario.users.legacyAll, + const definitionAuthorized = (user: { username: string; password: string }) => ({ + spaceId, + user, tests: { withReferencesNotOverwriting: { - statusCode: 404, - response: expectNotFoundResponse, + statusCode: 200, + response: createExpectNonOverriddenResponseWithReferences(spaceId), }, withReferencesOverwriting: { - statusCode: 404, - response: expectNotFoundResponse, + statusCode: 200, + response: createExpectOverriddenResponseWithReferences(spaceId), }, withoutReferencesOverwriting: { - statusCode: 404, - response: expectNotFoundResponse, + statusCode: 200, + response: createExpectOverriddenResponseWithoutReferences(spaceId), }, withoutReferencesNotOverwriting: { - statusCode: 404, - response: expectNotFoundResponse, + statusCode: 200, + response: createExpectNonOverriddenResponseWithoutReferences(spaceId), }, nonExistentSpace: { - statusCode: 404, - response: expectNotFoundResponse, + statusCode: 200, + response: createExpectOverriddenResponseWithoutReferences( + spaceId, + NON_EXISTENT_SPACE_ID + ), }, + multiNamespaceTestCases: createMultiNamespaceTestCases(spaceId, 'authorized'), }, }); resolveCopyToSpaceConflictsTest( - `rbac user with read globally from the ${scenario.spaceId} space`, - { - spaceId: scenario.spaceId, - user: scenario.users.readGlobally, - tests: { - withReferencesNotOverwriting: { - statusCode: 200, - response: createExpectReadonlyAtSpaceWithReferencesResult(scenario.spaceId), - }, - withReferencesOverwriting: { - statusCode: 200, - response: createExpectReadonlyAtSpaceWithReferencesResult(scenario.spaceId), - }, - withoutReferencesOverwriting: { - statusCode: 200, - response: createExpectUnauthorizedAtSpaceWithoutReferencesResult(scenario.spaceId), - }, - withoutReferencesNotOverwriting: { - statusCode: 200, - response: createExpectUnauthorizedAtSpaceWithoutReferencesResult(scenario.spaceId), - }, - nonExistentSpace: { - statusCode: 200, - response: createExpectUnauthorizedAtSpaceWithoutReferencesResult( - scenario.spaceId, - NON_EXISTENT_SPACE_ID - ), - }, - }, - } + `user with no access from the ${spaceId} space`, + definitionNoAccess(scenario.users.noAccess) ); - resolveCopyToSpaceConflictsTest( - `dual-privileges readonly user from the ${scenario.spaceId} space`, - { - spaceId: scenario.spaceId, - user: scenario.users.dualRead, - tests: { - withReferencesNotOverwriting: { - statusCode: 200, - response: createExpectReadonlyAtSpaceWithReferencesResult(scenario.spaceId), - }, - withReferencesOverwriting: { - statusCode: 200, - response: createExpectReadonlyAtSpaceWithReferencesResult(scenario.spaceId), - }, - withoutReferencesOverwriting: { - statusCode: 200, - response: createExpectUnauthorizedAtSpaceWithoutReferencesResult(scenario.spaceId), - }, - withoutReferencesNotOverwriting: { - statusCode: 200, - response: createExpectUnauthorizedAtSpaceWithoutReferencesResult(scenario.spaceId), - }, - nonExistentSpace: { - statusCode: 200, - response: createExpectUnauthorizedAtSpaceWithoutReferencesResult( - scenario.spaceId, - NON_EXISTENT_SPACE_ID - ), - }, - }, - } + `superuser from the ${spaceId} space`, + definitionAuthorized(scenario.users.superuser) ); - resolveCopyToSpaceConflictsTest( - `rbac user with all at space from the ${scenario.spaceId} space`, - { - spaceId: scenario.spaceId, - user: scenario.users.allAtSpace, - tests: { - withReferencesNotOverwriting: { - statusCode: 200, - response: createExpectUnauthorizedAtSpaceWithReferencesResult(scenario.spaceId), - }, - withReferencesOverwriting: { - statusCode: 200, - response: createExpectUnauthorizedAtSpaceWithReferencesResult(scenario.spaceId), - }, - withoutReferencesOverwriting: { - statusCode: 200, - response: createExpectUnauthorizedAtSpaceWithoutReferencesResult(scenario.spaceId), - }, - withoutReferencesNotOverwriting: { - statusCode: 200, - response: createExpectUnauthorizedAtSpaceWithoutReferencesResult(scenario.spaceId), - }, - nonExistentSpace: { - statusCode: 200, - response: createExpectUnauthorizedAtSpaceWithoutReferencesResult( - scenario.spaceId, - NON_EXISTENT_SPACE_ID - ), - }, - }, - } + `rbac user with all globally from the ${spaceId} space`, + definitionAuthorized(scenario.users.allGlobally) + ); + resolveCopyToSpaceConflictsTest( + `dual-privileges user from the ${spaceId} space`, + definitionAuthorized(scenario.users.dualAll) + ); + resolveCopyToSpaceConflictsTest( + `legacy user from the ${spaceId} space`, + definitionNoAccess(scenario.users.legacyAll) + ); + resolveCopyToSpaceConflictsTest( + `rbac user with read globally from the ${spaceId} space`, + definitionUnauthorizedWrite(scenario.users.readGlobally) + ); + resolveCopyToSpaceConflictsTest( + `dual-privileges readonly user from the ${spaceId} space`, + definitionUnauthorizedWrite(scenario.users.dualRead) + ); + resolveCopyToSpaceConflictsTest( + `rbac user with all at space from the ${spaceId} space`, + definitionUnauthorizedRead(scenario.users.allAtSpace) ); }); }); diff --git a/x-pack/test/spaces_api_integration/security_and_spaces/apis/share_add.ts b/x-pack/test/spaces_api_integration/security_and_spaces/apis/share_add.ts index f3e6580e439bb..ddd029c8d7d68 100644 --- a/x-pack/test/spaces_api_integration/security_and_spaces/apis/share_add.ts +++ b/x-pack/test/spaces_api_integration/security_and_spaces/apis/share_add.ts @@ -25,7 +25,7 @@ const createTestCases = (spaceId: string) => { const namespaces = [spaceId]; return [ // Test cases to check adding the target namespace to different saved objects - { ...CASES.DEFAULT_SPACE_ONLY, namespaces, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, + { ...CASES.DEFAULT_ONLY, namespaces, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, { ...CASES.SPACE_1_ONLY, namespaces, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.SPACE_2_ONLY, namespaces, ...fail404(spaceId !== SPACE_2_ID) }, { ...CASES.DEFAULT_AND_SPACE_1, namespaces, ...fail404(spaceId === SPACE_2_ID) }, @@ -37,7 +37,7 @@ const createTestCases = (spaceId: string) => { // These are non-exhaustive, they only check cases for adding two additional namespaces to a saved object // More permutations are covered in the corresponding spaces_only test suite { - ...CASES.DEFAULT_SPACE_ONLY, + ...CASES.DEFAULT_ONLY, namespaces: [SPACE_1_ID, SPACE_2_ID], ...fail404(spaceId !== DEFAULT_SPACE_ID), }, diff --git a/x-pack/test/spaces_api_integration/security_and_spaces/apis/share_remove.ts b/x-pack/test/spaces_api_integration/security_and_spaces/apis/share_remove.ts index d83020a9598f1..4b120a71213b7 100644 --- a/x-pack/test/spaces_api_integration/security_and_spaces/apis/share_remove.ts +++ b/x-pack/test/spaces_api_integration/security_and_spaces/apis/share_remove.ts @@ -29,7 +29,7 @@ const createTestCases = (spaceId: string) => { // Test cases to check removing the target namespace from different saved objects let namespaces = [spaceId]; const singleSpace = [ - { id: CASES.DEFAULT_SPACE_ONLY.id, namespaces, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, + { id: CASES.DEFAULT_ONLY.id, namespaces, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, { id: CASES.SPACE_1_ONLY.id, namespaces, ...fail404(spaceId !== SPACE_1_ID) }, { id: CASES.SPACE_2_ONLY.id, namespaces, ...fail404(spaceId !== SPACE_2_ID) }, { id: CASES.DEFAULT_AND_SPACE_1.id, namespaces, ...fail404(spaceId === SPACE_2_ID) }, diff --git a/x-pack/test/spaces_api_integration/spaces_only/apis/copy_to_space.ts b/x-pack/test/spaces_api_integration/spaces_only/apis/copy_to_space.ts index 75b35fecd5d83..85efe797c7402 100644 --- a/x-pack/test/spaces_api_integration/spaces_only/apis/copy_to_space.ts +++ b/x-pack/test/spaces_api_integration/spaces_only/apis/copy_to_space.ts @@ -20,6 +20,7 @@ export default function copyToSpacesOnlySuite({ getService }: FtrProviderContext expectNoConflictsForNonExistentSpaceResult, createExpectWithConflictsOverwritingResult, createExpectWithConflictsWithoutOverwritingResult, + createMultiNamespaceTestCases, originSpaces, } = copyToSpaceTestSuiteFactory(es, esArchiver, supertestWithoutAuth); @@ -34,7 +35,7 @@ export default function copyToSpacesOnlySuite({ getService }: FtrProviderContext }, noConflictsWithReferences: { statusCode: 200, - response: expectNoConflictsWithReferencesResult, + response: expectNoConflictsWithReferencesResult(spaceId), }, withConflictsOverwriting: { statusCode: 200, @@ -47,12 +48,13 @@ export default function copyToSpacesOnlySuite({ getService }: FtrProviderContext multipleSpaces: { statusCode: 200, withConflictsResponse: createExpectWithConflictsOverwritingResult(spaceId), - noConflictsResponse: expectNoConflictsWithReferencesResult, + noConflictsResponse: expectNoConflictsWithReferencesResult(spaceId), }, nonExistentSpace: { statusCode: 200, response: expectNoConflictsForNonExistentSpaceResult, }, + multiNamespaceTestCases: createMultiNamespaceTestCases(spaceId), }, }); }); diff --git a/x-pack/test/spaces_api_integration/spaces_only/apis/resolve_copy_to_space_conflicts.ts b/x-pack/test/spaces_api_integration/spaces_only/apis/resolve_copy_to_space_conflicts.ts index ef2735de3d3db..5c84475d32850 100644 --- a/x-pack/test/spaces_api_integration/spaces_only/apis/resolve_copy_to_space_conflicts.ts +++ b/x-pack/test/spaces_api_integration/spaces_only/apis/resolve_copy_to_space_conflicts.ts @@ -19,6 +19,7 @@ export default function resolveCopyToSpaceConflictsTestSuite({ getService }: Ftr createExpectNonOverriddenResponseWithoutReferences, createExpectOverriddenResponseWithReferences, createExpectOverriddenResponseWithoutReferences, + createMultiNamespaceTestCases, NON_EXISTENT_SPACE_ID, originSpaces, } = resolveCopyToSpaceConflictsSuite(esArchiver, supertestWithAuth, supertestWithoutAuth); @@ -51,6 +52,7 @@ export default function resolveCopyToSpaceConflictsTestSuite({ getService }: Ftr NON_EXISTENT_SPACE_ID ), }, + multiNamespaceTestCases: createMultiNamespaceTestCases(spaceId), }, }); }); diff --git a/x-pack/test/spaces_api_integration/spaces_only/apis/share_add.ts b/x-pack/test/spaces_api_integration/spaces_only/apis/share_add.ts index 5cdebf9edfcfd..25ba986a12fd8 100644 --- a/x-pack/test/spaces_api_integration/spaces_only/apis/share_add.ts +++ b/x-pack/test/spaces_api_integration/spaces_only/apis/share_add.ts @@ -27,7 +27,7 @@ const { fail404 } = testCaseFailures; const createSingleTestCases = (spaceId: string) => { const namespaces = ['some-space-id']; return [ - { ...CASES.DEFAULT_SPACE_ONLY, namespaces, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, + { ...CASES.DEFAULT_ONLY, namespaces, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, { ...CASES.SPACE_1_ONLY, namespaces, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.SPACE_2_ONLY, namespaces, ...fail404(spaceId !== SPACE_2_ID) }, { ...CASES.DEFAULT_AND_SPACE_1, namespaces, ...fail404(spaceId === SPACE_2_ID) }, @@ -43,7 +43,7 @@ const createSingleTestCases = (spaceId: string) => { */ const createMultiTestCases = () => { const allSpaces = [DEFAULT_SPACE_ID, SPACE_1_ID, SPACE_2_ID]; - let id = CASES.DEFAULT_SPACE_ONLY.id; + let id = CASES.DEFAULT_ONLY.id; const one = [{ id, namespaces: allSpaces }]; id = CASES.DEFAULT_AND_SPACE_1.id; const two = [{ id, namespaces: allSpaces }]; diff --git a/x-pack/test/spaces_api_integration/spaces_only/apis/share_remove.ts b/x-pack/test/spaces_api_integration/spaces_only/apis/share_remove.ts index 8bcd294b38f3f..2c4506b723533 100644 --- a/x-pack/test/spaces_api_integration/spaces_only/apis/share_remove.ts +++ b/x-pack/test/spaces_api_integration/spaces_only/apis/share_remove.ts @@ -27,7 +27,7 @@ const { fail404 } = testCaseFailures; const createSingleTestCases = (spaceId: string) => { const namespaces = [spaceId]; return [ - { ...CASES.DEFAULT_SPACE_ONLY, namespaces, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, + { ...CASES.DEFAULT_ONLY, namespaces, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, { ...CASES.SPACE_1_ONLY, namespaces, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.SPACE_2_ONLY, namespaces, ...fail404(spaceId !== SPACE_2_ID) }, { ...CASES.DEFAULT_AND_SPACE_1, namespaces, ...fail404(spaceId === SPACE_2_ID) }, @@ -43,7 +43,7 @@ const createSingleTestCases = (spaceId: string) => { */ const createMultiTestCases = () => { const nonExistentSpaceId = 'does_not_exist'; // space that doesn't exist - let id = CASES.DEFAULT_SPACE_ONLY.id; + let id = CASES.DEFAULT_ONLY.id; const one = [ { id, namespaces: [nonExistentSpaceId] }, { id, namespaces: [DEFAULT_SPACE_ID, SPACE_1_ID, SPACE_2_ID] },