diff --git a/docs/api/saved-objects/import.asciidoc b/docs/api/saved-objects/import.asciidoc index abf20b44cd17f..634b800a60428 100644 --- a/docs/api/saved-objects/import.asciidoc +++ b/docs/api/saved-objects/import.asciidoc @@ -17,13 +17,22 @@ experimental[] Create sets of {kib} saved objects from a file created by the exp ==== Path parameters `space_id`:: - (Optional, string) An identifier for the space. If `space_id` is not provided in the URL, the default space is used. + (Optional, string) An identifier for the <>. If `space_id` is not provided in the URL, the default space is used. [[saved-objects-api-import-query-params]] ==== Query parameters +`createNewCopies`:: + (Optional, boolean) Creates new copies of saved objects, regenerating each object's ID and resetting its origin in the process. If this + option is used, potential conflict errors will be avoided. ++ +NOTE: This cannot be used with the `overwrite` option. + `overwrite`:: - (Optional, boolean) Overwrites saved objects. + (Optional, boolean) Overwrites saved objects if they already exist. If this option is used, potential conflict errors will be + automatically resolved by overwriting the destination object. ++ +NOTE: This cannot be used with the `createNewCopies` option. [[saved-objects-api-import-request-body]] ==== Request body @@ -37,22 +46,77 @@ The request body must include the multipart/form-data type. ==== Response body `success`:: - Top-level property that indicates if the import was successful. + (boolean) Indicates if the import was completely successful. When set to `false`, some objects may have been copied. For additional + information, refer to the `errors` and `successResults` properties. `successCount`:: - Indicates the number of successfully imported records. + (number) Indicates the number of successfully imported records. `errors`:: (array) Indicates the import was unsuccessful and specifies the objects that failed to import. +`successResults`:: + (array) Indicates the objects that were imported successfully, with any metadata if applicable. ++ +NOTE: No objects are actually created until all resolvable errors have been addressed! This includes conflict errors and missing references +errors. See the <> documentation for more information. + [[saved-objects-api-import-codes]] ==== Response code `200`:: Indicates a successful call. +[[saved-objects-api-import-example]] ==== Examples +[[saved-objects-api-import-example-1]] +===== 1. Successful import (with `createNewCopies` enabled) + +Import an index pattern and dashboard: + +[source,sh] +-------------------------------------------------- +$ curl -X POST api/saved_objects/_import?createNewCopies=true -H "kbn-xsrf: true" --form file=@file.ndjson +-------------------------------------------------- +// KIBANA + +The `file.ndjson` file contains the following: + +[source,sh] +-------------------------------------------------- +{"type":"index-pattern","id":"my-pattern","attributes":{"title":"my-pattern-*"}} +{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"}} +-------------------------------------------------- + +The API returns the following: + +[source,sh] +-------------------------------------------------- +{ + "success": true, + "successCount": 2, + "successResults": [ + { + "id": "my-pattern", + "type": "index-pattern", + "destinationId": "4aba3770-0d04-45e1-9e34-4cf0fd2165ae" + }, + { + "id": "my-dashboard", + "type": "dashboard", + "destinationId": "c31d1eca-9bc0-4a81-b5f9-30c442824c48" + } + ] +} +-------------------------------------------------- + +This result indicates that the import was successful, and both objects were created. Since these objects were created as new copies, each +entry in the `successResults` array includes a `destinationId` attribute. + +[[saved-objects-api-import-example-2]] +===== 2. Successful import (with `createNewCopies` disabled) + Import an index pattern and dashboard: [source,sh] @@ -75,11 +139,26 @@ The API returns the following: -------------------------------------------------- { "success": true, - "successCount": 2 + "successCount": 2, + "successResults": [ + { + "id": "my-pattern", + "type": "index-pattern" + }, + { + "id": "my-dashboard", + "type": "dashboard" + } + ] } -------------------------------------------------- -Import an index pattern and dashboard that includes a conflict on the index pattern: +This result indicates that the import was successful, and both objects were created. + +[[saved-objects-api-import-example-3]] +===== 3. Failed import (with conflict errors) + +Import an index pattern, visualization, canvas, and dashboard, where some objects already exists: [source,sh] -------------------------------------------------- @@ -92,6 +171,8 @@ The `file.ndjson` file contains the following: [source,sh] -------------------------------------------------- {"type":"index-pattern","id":"my-pattern","attributes":{"title":"my-pattern-*"}} +{"type":"visualization","id":"my-vis","attributes":{"title":"Look at my visualization"}} +{"type":"canvas-workpad","id":"my-canvas","attributes":{"name":"Look at my canvas"}} {"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"}} -------------------------------------------------- @@ -109,13 +190,71 @@ The API returns the following: "title": "my-pattern-*", "error": { "type": "conflict" - }, + } + }, + { + "id": "my-visualization", + "type": "my-vis", + "title": "Look at my visualization", + "error": { + "type": "conflict", + "destinationId": "another-vis" + } }, + { + "id": "my-canvas", + "type": "canvas-workpad", + "title": "Look at my canvas", + "error": { + "type": "ambiguous_conflict", + "destinations": [ + { + "id": "another-canvas", + "title": "Look at another canvas", + "updatedAt": "2020-07-08T16:36:32.377Z" + }, + { + "id": "yet-another-canvas", + "title": "Look at yet another canvas", + "updatedAt": "2020-07-05T12:29:54.849Z" + } + ] + } + } ], + "successResults": [ + { + "id": "my-dashboard", + "type": "dashboard" + } + ] } -------------------------------------------------- -Import a visualization and dashboard with an index pattern for the visualization reference that doesn't exist: +This result indicates that the import was not successful because the index pattern, visualization, and dashboard each resulted in a conflict +error: + +* An index pattern with the same ID already exists, so this resulted in a conflict error. This can be resolved by overwriting the existing +object, or skipping this object entirely. + +* A visualization with a different ID but the same "origin" already exists, so this resulted in a conflict error as well. The +`destinationId` field contains the `id` of the other visualization which caused this conflict. This behavior was added to ensure that new +objects which can be shared between <> behave in a similar way as legacy non-shareable objects. When a shareable object +is exported and then imported into a new space, it retains its origin so that its conflicts will be encountered as expected. This can be +resolved by overwriting the specified destination object, or skipping this object entirely. + +* Two canvases with different IDs but the same "origin" already exist, so this resulted in an ambiguous conflict error. The `destinations` +array describes to the other canvases which caused this conflict. When a shareable object is exported and then imported into a new space, +and is _then_ shared to another space where an object of the same origin exists, this situation may occur. This can be resolved by picking +one of the destination objects to overwrite, or skipping this object entirely. + +No objects will be imported until this error is resolved using the <>. + +[[saved-objects-api-import-example-4]] +===== 4. Failed import (with missing reference errors) + +Import a visualization and dashboard with an index pattern for the visualization reference that doesn\'t exist: [source,sh] -------------------------------------------------- @@ -127,7 +266,7 @@ The `file.ndjson` file contains the following: [source,sh] -------------------------------------------------- -{"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":"Look at my visualization"},"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"}]} -------------------------------------------------- @@ -141,7 +280,7 @@ The API returns the following: { "id": "my-vis", "type": "visualization", - "title": "my-vis", + "title": "Look at my visualization", "error": { "type": "missing_references", "references": [ @@ -160,3 +299,6 @@ The API returns the following: } ] -------------------------------------------------- + +This result indicates that the import was not successful because the visualization resulted in a missing references error. No objects will +be imported until this error is resolved using the <>. diff --git a/docs/api/saved-objects/resolve_import_errors.asciidoc b/docs/api/saved-objects/resolve_import_errors.asciidoc index 03c116c39dd80..7aa3e37756905 100644 --- a/docs/api/saved-objects/resolve_import_errors.asciidoc +++ b/docs/api/saved-objects/resolve_import_errors.asciidoc @@ -4,7 +4,7 @@ Resolve import errors ++++ -experimental[] Resolve errors from the import API. +experimental[] Resolve errors from the <>. To resolve errors, you can: @@ -25,7 +25,14 @@ To resolve errors, you can: ==== Path parameters `space_id`:: - (Optional, string) An identifier for the space. If `space_id` is not provided in the URL, the default space is used. + (Optional, string) An identifier for the <>. If `space_id` is not provided in the URL, the default space is used. + +[[saved-objects-api-resolve-import-errors-query-params]] +==== Query parameters + +`createNewCopies`:: + (Optional, boolean) Creates new copies of saved objects, regenerating each object's ID and resetting its origin in the process. If this + option was enabled during the initial import, it should also be enabled when resolving import errors. [[saved-objects-api-resolve-import-errors-request-body]] ==== Request body @@ -36,19 +43,42 @@ The request body must include the multipart/form-data type. The same file given to the import API. `retries`:: - (array) A list of `type`, `id`, `replaceReferences`, and `overwrite` objects to retry. The property `replaceReferences` is a list of `type`, `from`, and `to` used to change the object references. + (Required, array) The retry operations to attempt, which can specify how to resolve different types of errors. ++ +.Properties of `` +[%collapsible%open] +===== + `type`::: + (Required, string) The saved object type. + `id`::: + (Required, string) The saved object ID. + `overwrite`::: + (Optional, boolean) When set to `true`, the source object overwrites the conflicting destination object. When set to `false`, this does + nothing. + `destinationId`::: + (Optional, string) Specifies which destination ID the imported object should have (if different from the current ID). + `replaceReferences`::: + (Optional, array) A list of `type`, `from`, and `to` used to change the object references. +===== [[saved-objects-api-resolve-import-errors-response-body]] ==== Response body `success`:: - Top-level property that indicates if the errors successfully resolved. + (boolean) Indicates if the import was completely successful. When set to `false`, some objects may have been copied. For additional + information, refer to the `errors` and `successResults` properties. `successCount`:: - Indicates the number of successfully resolved records. + (number) Indicates the number of successfully resolved records. `errors`:: - (array) Specifies the objects that failed to resolve. + (Optional, array) Specifies the objects that failed to resolve. + +`successResults`:: + (Optional, array) Indicates the objects that were imported successfully, with any metadata if applicable. ++ +NOTE: No objects are actually created until all resolvable errors have been addressed! This includes conflict errors and missing references +errors. See the examples below for how to resolve these errors. [[saved-objects-api-resolve-import-errors-codes]] ==== Response code @@ -59,11 +89,16 @@ The request body must include the multipart/form-data type. [[saved-objects-api-resolve-import-errors-example]] ==== Examples -Retry a dashboard import: +[[saved-objects-api-resolve-import-errors-example-1]] +===== 1. Resolve conflict errors + +This example builds upon the <>. + +Resolve conflict errors for an index pattern, visualization, and canvas by overwriting the existing saved objects: [source,sh] -------------------------------------------------- -$ curl -X POST api/saved_objects/_resolve_import_errors -H "kbn-xsrf: true" --form file=@file.ndjson --form retries='[{"type":"dashboard","id":"my-dashboard"}]' +$ curl -X POST api/saved_objects/_resolve_import_errors -H "kbn-xsrf: true" --form file=@file.ndjson --form retries='[{"type":"index-pattern","id":"my-pattern","overwrite":true},{"type":"visualization","id":"my-vis","overwrite":true,"destinationId":"another-vis"},{"type":"canvas","id":"my-canvas","overwrite":true,"destinationId":"yet-another-canvas"},{"type":"dashboard","id":"my-dashboard"}]' -------------------------------------------------- // KIBANA @@ -71,6 +106,9 @@ The `file.ndjson` file contains the following: [source,sh] -------------------------------------------------- +{"type":"index-pattern","id":"my-pattern","attributes":{"title":"my-pattern-*"}} +{"type":"visualization","id":"my-vis","attributes":{"title":"Look at my visualization"}} +{"type":"canvas-workpad","id":"my-canvas","attributes":{"name":"Look at my canvas"}} {"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"}} -------------------------------------------------- @@ -80,41 +118,45 @@ The API returns the following: -------------------------------------------------- { "success": true, - "successCount": 1 + "successCount": 4, + "successResults": [ + { + "id": "my-pattern", + "type": "index-pattern" + }, + { + "id": "my-vis", + "type": "visualization", + "destinationId": "another-vis" + }, + { + "id": "my-canvas", + "type": "canvas-workpad", + "destinationId": "yet-another-canvas" + }, + { + "id": "my-dashboard", + "type": "dashboard" + } + ] } -------------------------------------------------- -Resolve errors for a dashboard and overwrite the existing saved object: +This result indicates that the import was successful, and all four objects were created. -[source,sh] --------------------------------------------------- -$ curl -X POST api/saved_objects/_resolve_import_errors -H "kbn-xsrf: true" --form file=@file.ndjson --form retries='[{"type":"dashboard","id":"my-dashboard","overwrite":true}]' --------------------------------------------------- -// KIBANA +TIP: If a prior import attempt resulted in resolvable errors, you must include a retry for each object you want to import, including any +that were returned in the `successResults` array. In this example, we retried importing the dashboard accordingly. -The `file.ndjson` file contains the following: +[[saved-objects-api-resolve-import-errors-example-2]] +===== 2. Resolve missing reference errors -[source,sh] --------------------------------------------------- -{"type":"index-pattern","id":"my-pattern","attributes":{"title":"my-pattern-*"}} -{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"}} --------------------------------------------------- +This example builds upon the <>. -The API returns the following: +Resolve a missing reference error for a visualization by replacing the index pattern with another: [source,sh] -------------------------------------------------- -{ - "success": true, - "successCount": 1 -} --------------------------------------------------- - -Resolve errors for a visualization by replacing the index pattern with another: - -[source,sh] --------------------------------------------------- -$ curl -X POST api/saved_objects/_resolve_import_errors -H "kbn-xsrf: true" --form file=@file.ndjson --form retries='[{"type":"visualization","id":"my-vis","replaceReferences":[{"type":"index-pattern","from":"missing","to":"existing"}]}]' +$ curl -X POST api/saved_objects/_resolve_import_errors -H "kbn-xsrf: true" --form file=@file.ndjson --form retries='[{"type":"visualization","id":"my-vis","replaceReferences":[{"type":"index-pattern","from":"my-pattern-*","to":"existing-pattern"}]},{"type":"dashboard","id":"my-dashboard"}]' -------------------------------------------------- // KIBANA @@ -122,7 +164,8 @@ The `file.ndjson` file contains the following: [source,sh] -------------------------------------------------- -{"type":"visualization","id":"my-vis","attributes":{"title":"Look at my visualization"},"references":[{"name":"ref_0","type":"index-pattern","id":"missing"}]} +{"type":"visualization","id":"my-vis","attributes":{"title":"Look at my visualization"},"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"}]} -------------------------------------------------- The API returns the following: @@ -131,6 +174,21 @@ The API returns the following: -------------------------------------------------- { "success": true, - "successCount": 1 + "successCount": 2, + "successResults": [ + { + "id": "my-pattern", + "type": "index-pattern" + }, + { + "id": "my-dashboard", + "type": "dashboard" + } + ] } -------------------------------------------------- + +This result indicates that the import was successful, and both objects were created. + +TIP: If a prior import attempt resulted in resolvable errors, you must include a retry for each object you want to import, including any +that were described in the missing error object's `blocked` array. In this example, we retried importing the dashboard accordingly. diff --git a/docs/api/spaces-management/copy_saved_objects.asciidoc b/docs/api/spaces-management/copy_saved_objects.asciidoc index d39b1e134c9dc..323f0f1805575 100644 --- a/docs/api/spaces-management/copy_saved_objects.asciidoc +++ b/docs/api/spaces-management/copy_saved_objects.asciidoc @@ -24,7 +24,8 @@ You can request to overwrite any objects that already exist in the target space ==== {api-path-parms-title} `space_id`:: -(Optional, string) The ID of the space that contains the saved objects you want to copy. When `space_id` is unspecified in the URL, the default space is used. + (Optional, string) The ID of the space that contains the saved objects you want to copy. When `space_id` is unspecified in the URL, the + default space is used. [role="child_attributes"] [[spaces-api-copy-saved-objects-request-body]] @@ -47,10 +48,12 @@ You can request to overwrite any objects that already exist in the target space ===== `includeReferences`:: - (Optional, boolean) When set to `true`, all saved objects related to the specified saved objects will also be copied into the target spaces. The default value is `false`. + (Optional, boolean) When set to `true`, all saved objects related to the specified saved objects will also be copied into the target + spaces. The default value is `false`. `overwrite`:: - (Optional, boolean) When set to `true`, all conflicts are automatically overidden. When a saved object with a matching `type` and `id` exists in the target space, that version is replaced with the version from the source space. The default value is `false`. + (Optional, boolean) When set to `true`, all conflicts are automatically overidden. When a saved object with a matching `type` and `id` + exists in the target space, that version is replaced with the version from the source space. The default value is `false`. [role="child_attributes"] [[spaces-api-copy-saved-objects-response-body]] @@ -63,7 +66,8 @@ You can request to overwrite any objects that already exist in the target space [%collapsible%open] ===== `success`::: - (boolean) The copy operation was successful. When set to `false`, some objects may have been copied. For additional information, refer to the `successCount` and `errors` properties. + (boolean) The copy operation was successful. When set to `false`, some objects may have been copied. For additional information, refer + to the `errors` and `successResults` properties. `successCount`::: (number) The number of objects that successfully copied. @@ -84,15 +88,33 @@ You can request to overwrite any objects that already exist in the target space .Properties of `error` [%collapsible%open] ======= - `type`::::: - (string) The type of error. For example, `unsupported_type`, `missing_references`, or `unknown`. Errors marked as `conflict` may be resolved by using the <>. + `type`:::: + (string) The type of error. For example, `conflict`, `ambiguous_conflict`, `missing_references`, `unsupported_type`, or `unknown`. + Errors marked as `conflict` or `ambiguous_conflict` may be resolved by using the <>. + `destinationId`:::: + (Optional, string) The destination ID that was used during the copy attempt. This is only present on `conflict` error types. + `destinations`:::: + (Optional, array) A list of possible object destinations with `id`, `title`, and `updatedAt` fields to describe each one. This is + only present on `ambiguous_conflict` error types. ======= ====== + + `successResults`::: + (Optional, array) Indicates the objects that were copied successfully, with any metadata if applicable. ++ +NOTE: No objects are actually created until all resolvable errors have been addressed! This includes conflict errors. See the examples below +for how to resolve these errors. + ===== [[spaces-api-copy-saved-objects-example]] ==== {api-examples-title} -Copy a dashboard with the `my-dashboard` ID, including all references from the `default` space to the `marketing` and `sales` spaces: +[[spaces-api-copy-saved-objects-example-1]] +===== 1. Successful copy (with `createNewCopies` enabled) + +Copy a dashboard with the `my-dashboard` ID, including all references from the `default` space to the `marketing` space. In this example, +the dashboard has a reference to a visualization, and that has a reference to an index pattern: [source,sh] ---- @@ -102,7 +124,60 @@ $ curl -X POST api/spaces/_copy_saved_objects "type": "dashboard", "id": "my-dashboard" }], - "spaces": ["marketing", "sales"], + "spaces": ["marketing"], + "includeReferences": true, + "createNewcopies": true +} +---- +// KIBANA + +The API returns the following: + +[source,sh] +---- +{ + "marketing": { + "success": true, + "successCount": 3, + "successResults": [ + { + "id": "my-dashboard", + "type": "dashboard", + "destinationId": "1e127098-5b80-417f-b0f1-c60c8395358f" + }, + { + "id": "my-vis", + "type": "visualization", + "destinationId": "a610ed80-1c73-4507-9e13-d3af736c8e04" + }, + { + "id": "my-index-pattern", + "type": "index-pattern", + "destinationId": "bc3c9c70-bf6f-4bec-b4ce-f4189aa9e26b" + } + ] + } +} +---- + +This result indicates that the copy was successful, and all three objects were created. Since these objects were created as new copies, each +entry in the `successResults` array includes a `destinationId` attribute. + +[[spaces-api-copy-saved-objects-example-2]] +===== 2. Successful copy (with `createNewCopies` disabled) + +Copy a dashboard with the `my-dashboard` ID, including all references from the `default` space to the `marketing` space. In this example, +the dashboard has a reference to a visualization, and that has a reference to an index pattern: + +[source,sh] +---- +$ curl -X POST api/spaces/_copy_saved_objects +{ + "objects": [{ + "type": "dashboard", + "id": "my-dashboard" + }], + "spaces": ["marketing"], "includeReferences": true } ---- @@ -115,35 +190,43 @@ The API returns the following: { "marketing": { "success": true, - "successCount": 5 - }, - "sales": { - "success": false, - "successCount": 4, - "errors": [{ - "id": "my-index-pattern", - "type": "index-pattern", - "error": { - "type": "conflict" + "successCount": 3, + "successResults": [ + { + "id": "my-dashboard", + "type": "dashboard" + }, + { + "id": "my-vis", + "type": "visualization" + }, + { + "id": "my-index-pattern", + "type": "index-pattern" } - }] + ] } } ---- -The `marketing` space succeeds, but the `sales` space fails due to a conflict in the index pattern. +This result indicates that the import was successful, and all three objects were created. + +[[spaces-api-copy-saved-objects-example-3]] +===== 3. Failed copy (with conflict errors) -Copy a visualization with the `my-viz` ID from the `marketing` space to the `default` space: +Copy a dashboard with the `my-dashboard` ID, including all references from the `default` space to the `marketing` and `sales` spaces. In +this example, the dashboard has a reference to a visualization and a canvas, and the visualization has a reference to an index pattern: [source,sh] ---- -$ curl -X POST s/marketing/api/spaces/_copy_saved_objects +$ curl -X POST api/spaces/_copy_saved_objects { "objects": [{ - "type": "visualization", - "id": "my-viz" + "type": "dashboard", + "id": "my-dashboard" }], - "spaces": ["default"] + "spaces": ["marketing", "sales"], + "includeReferences": true } ---- // KIBANA @@ -153,9 +236,95 @@ The API returns the following: [source,sh] ---- { - "default": { + "marketing": { "success": true, - "successCount": 1 + "successCount": 4, + "successResults": [ + { + "id": "other-dashboard", + "type": "dashboard" + }, + { + "id": "my-vis", + "type": "visualization" + }, + { + "id": "my-canvas", + "type": "canvas-workpad" + }, + { + "id": "my-index-pattern", + "type": "index-pattern" + } + ] + }, + "sales": { + "success": false, + "successCount": 1, + "errors": [ + { + "id": "my-pattern", + "type": "index-pattern", + "title": "my-pattern-*", + "error": { + "type": "conflict" + } + }, + { + "id": "my-visualization", + "type": "my-vis", + "title": "Look at my visualization", + "error": { + "type": "conflict", + "destinationId": "another-vis" + } + }, + { + "id": "my-canvas", + "type": "canvas-workpad", + "title": "Look at my canvas", + "error": { + "type": "ambiguous_conflict", + "destinations": [ + { + "id": "another-canvas", + "title": "Look at another canvas", + "updatedAt": "2020-07-08T16:36:32.377Z" + }, + { + "id": "yet-another-canvas", + "title": "Look at yet another canvas", + "updatedAt": "2020-07-05T12:29:54.849Z" + } + ] + } + } + ], + "successResults": [ + { + "id": "my-dashboard", + "type": "dashboard" + } + ] } } ---- + +This result indicates that the copy was successful for the `marketing` space, but it was not successful for the `sales` space because the +index pattern, visualization, and dashboard each resulted in a conflict error: + +* An index pattern with the same ID already exists, so this resulted in a conflict error. This can be resolved by overwriting the existing +object, or skipping this object entirely. + +* A visualization with a different ID but the same "origin" already exists, so this resulted in a conflict error as well. The +`destinationId` field contains the `id` of the other visualization which caused this conflict. This behavior was added to ensure that new +objects which can be shared between spaces behave in a similar way as legacy non-shareable objects. When a shareable object is copied into a +new space, it retains its origin so that its conflicts will be encountered as expected. This can be resolved by overwriting the specified +destination object, or skipping this object entirely. + +* Two canvases with different IDs but the same "origin" already exist, so this resulted in an ambiguous conflict error. The `destinations` +array describes to the other canvases which caused this conflict. When a shareable object is copied into a new space, and is _then_ shared +to another space where an object of the same origin exists, this situation may occur. This can be resolved by picking one of the destination +objects to overwrite, or skipping this object entirely. + +These errors need to be addressed using the <>. diff --git a/docs/api/spaces-management/resolve_copy_saved_objects_conflicts.asciidoc b/docs/api/spaces-management/resolve_copy_saved_objects_conflicts.asciidoc index 6c10ae9046cab..c1498f1b99878 100644 --- a/docs/api/spaces-management/resolve_copy_saved_objects_conflicts.asciidoc +++ b/docs/api/spaces-management/resolve_copy_saved_objects_conflicts.asciidoc @@ -46,7 +46,8 @@ Execute the <>, w (Optional, boolean) When set to `true`, all saved objects related to the specified saved objects are copied into the target spaces. The `includeReferences` must be the same values used during the failed <> operation. The default value is `false`. `retries`:: - (Required, object) The retry operations to attempt. Object keys represent the target space IDs. + (Required, object) The retry operations to attempt, which can specify how to resolve different types of errors. Object keys represent the + target space IDs. + .Properties of `retries` [%collapsible%open] @@ -64,6 +65,8 @@ Execute the <>, w (Required, string) The saved object ID. `overwrite`:::: (Required, boolean) When set to `true`, the saved object from the source space (desigated by the <>) overwrites the conflicting object in the destination space. When set to `false`, this does nothing. + `destinationId`:::: + (Optional, string) Specifies which destination ID the copied object should have (if different from the current ID). ====== ===== @@ -104,15 +107,32 @@ Execute the <>, w [%collapsible%open] ======= `type`:::: - (string) The type of error. For example, `unsupported_type`, `missing_references`, or `unknown`. + (string) The type of error. For example, `conflict`, `ambiguous_conflict`, `missing_references`, `unsupported_type`, or `unknown`. + `destinationId`:::: + (Optional, string) The destination ID that was used during the copy attempt. This is only present on `conflict` errors types. + `destinations`:::: + (Optional, array) A list of possible object destinations with `id`, `title`, and `updatedAt` fields to describe each one. This is + only present on `ambiguous_conflict` error types. ======= ====== + +`successResults`::: + (Optional, array) Indicates the objects that were copied successfully, with any metadata if applicable. ++ +NOTE: No objects are actually created until all resolvable errors have been addressed! This includes conflict errors. See the examples below +for how to resolve these errors. + ===== [[spaces-api-resolve-copy-saved-objects-conflicts-example]] ==== {api-examples-title} -Overwrite an index pattern in the `marketing` space, and a visualization in the `sales` space: +[[spaces-api-resolve-copy-saved-objects-conflicts-example-1]] +===== 1. Resolve conflict errors + +This example builds upon the <>. + +Resolve conflict errors for an index pattern, visualization, and canvas by overwriting the existing saved objects: [source,sh] ---- @@ -124,16 +144,29 @@ $ curl -X POST api/spaces/_resolve_copy_saved_objects_errors }], "includeReferences": true, "retries": { - "marketing": [{ - "type": "index-pattern", - "id": "my-pattern", - "overwrite": true - }], - "sales": [{ - "type": "visualization", - "id": "my-viz", - "overwrite": true - }] + "sales": [ + { + "type": "index-pattern", + "id": "my-pattern", + "overwrite": true + }, + { + "type": "visualization", + "id": "my-vis", + "overwrite": true, + "destinationId": "another-vis" + }, + { + "type": "canvas", + "id": "my-canvas", + "overwrite": true, + "destinationId": "yet-another-canvas" + }, + { + "type": "dashboard", + "id": "my-dashboard" + } + ] } } ---- @@ -144,13 +177,34 @@ The API returns the following: [source,sh] ---- { - "marketing": { - "success": true, - "successCount": 1 - }, "sales": { "success": true, - "successCount": 1 + "successCount": 4, + "successResults": [ + { + "id": "my-pattern", + "type": "index-pattern" + }, + { + "id": "my-vis", + "type": "visualization", + "destinationId": "another-vis" + }, + { + "id": "my-canvas", + "type": "canvas-workpad", + "destinationId": "yet-another-canvas" + }, + { + "id": "my-dashboard", + "type": "dashboard" + } + ] } } ---- + +This result indicates that the copy was successful, and all four objects were created. + +TIP: If a prior copy attempt resulted in resolvable errors, you must include a retry for each object you want to copy, including any that +were returned in the `successResults` array. In this example, we retried copying the dashboard accordingly. diff --git a/docs/development/core/public/kibana-plugin-core-public.md b/docs/development/core/public/kibana-plugin-core-public.md index 8f2bde3856019..b9533a66fa7b5 100644 --- a/docs/development/core/public/kibana-plugin-core-public.md +++ b/docs/development/core/public/kibana-plugin-core-public.md @@ -105,6 +105,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [PluginInitializerContext](./kibana-plugin-core-public.plugininitializercontext.md) | The available core services passed to a PluginInitializer | | [SavedObject](./kibana-plugin-core-public.savedobject.md) | | | [SavedObjectAttributes](./kibana-plugin-core-public.savedobjectattributes.md) | The data for a Saved Object is stored as an object in the attributes property. | +| [SavedObjectError](./kibana-plugin-core-public.savedobjecterror.md) | | | [SavedObjectReference](./kibana-plugin-core-public.savedobjectreference.md) | A reference to another saved object. | | [SavedObjectsBaseOptions](./kibana-plugin-core-public.savedobjectsbaseoptions.md) | | | [SavedObjectsBatchResponse](./kibana-plugin-core-public.savedobjectsbatchresponse.md) | | @@ -115,11 +116,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.savedobjecterror.error.md b/docs/development/core/public/kibana-plugin-core-public.savedobjecterror.error.md new file mode 100644 index 0000000000000..87180a520090f --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjecterror.error.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectError](./kibana-plugin-core-public.savedobjecterror.md) > [error](./kibana-plugin-core-public.savedobjecterror.error.md) + +## SavedObjectError.error property + +Signature: + +```typescript +error: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjecterror.md b/docs/development/core/public/kibana-plugin-core-public.savedobjecterror.md new file mode 100644 index 0000000000000..2117cea433b5c --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjecterror.md @@ -0,0 +1,21 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectError](./kibana-plugin-core-public.savedobjecterror.md) + +## SavedObjectError interface + +Signature: + +```typescript +export interface SavedObjectError +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [error](./kibana-plugin-core-public.savedobjecterror.error.md) | string | | +| [message](./kibana-plugin-core-public.savedobjecterror.message.md) | string | | +| [metadata](./kibana-plugin-core-public.savedobjecterror.metadata.md) | Record<string, unknown> | | +| [statusCode](./kibana-plugin-core-public.savedobjecterror.statuscode.md) | number | | + diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjecterror.message.md b/docs/development/core/public/kibana-plugin-core-public.savedobjecterror.message.md new file mode 100644 index 0000000000000..2a51d4d1a514d --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjecterror.message.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectError](./kibana-plugin-core-public.savedobjecterror.md) > [message](./kibana-plugin-core-public.savedobjecterror.message.md) + +## SavedObjectError.message property + +Signature: + +```typescript +message: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjecterror.metadata.md b/docs/development/core/public/kibana-plugin-core-public.savedobjecterror.metadata.md new file mode 100644 index 0000000000000..a2725f0206655 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjecterror.metadata.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectError](./kibana-plugin-core-public.savedobjecterror.md) > [metadata](./kibana-plugin-core-public.savedobjecterror.metadata.md) + +## SavedObjectError.metadata property + +Signature: + +```typescript +metadata?: Record; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjecterror.statuscode.md b/docs/development/core/public/kibana-plugin-core-public.savedobjecterror.statuscode.md new file mode 100644 index 0000000000000..75a57e98fece2 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjecterror.statuscode.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectError](./kibana-plugin-core-public.savedobjecterror.md) > [statusCode](./kibana-plugin-core-public.savedobjecterror.statuscode.md) + +## SavedObjectError.statusCode property + +Signature: + +```typescript +statusCode: number; +``` 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 70ad235fb8971..ebd0a99531755 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.md @@ -23,6 +23,7 @@ export interface SavedObjectsFindOptions | [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 \* | +| [rootSearchFields](./kibana-plugin-core-public.savedobjectsfindoptions.rootsearchfields.md) | string[] | The fields to perform the parsed query against. Unlike the searchFields argument, these are expected to be root fields 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.rootsearchfields.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.rootsearchfields.md new file mode 100644 index 0000000000000..faa971509eca2 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.rootsearchfields.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsFindOptions](./kibana-plugin-core-public.savedobjectsfindoptions.md) > [rootSearchFields](./kibana-plugin-core-public.savedobjectsfindoptions.rootsearchfields.md) + +## SavedObjectsFindOptions.rootSearchFields property + +The fields to perform the parsed query against. Unlike the `searchFields` argument, these are expected to be root fields and will not be modified. If used in conjunction with `searchFields`, both are concatenated together. + +Signature: + +```typescript +rootSearchFields?: 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.createnewcopy.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportretry.createnewcopy.md new file mode 100644 index 0000000000000..f60c713973d58 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportretry.createnewcopy.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsImportRetry](./kibana-plugin-core-public.savedobjectsimportretry.md) > [createNewCopy](./kibana-plugin-core-public.savedobjectsimportretry.createnewcopy.md) + +## SavedObjectsImportRetry.createNewCopy property + +If `createNewCopy` is specified, the new object has a new (undefined) origin ID. This is only needed for the case where `createNewCopies` mode is disabled and ambiguous source conflicts are detected. + +Signature: + +```typescript +createNewCopy?: boolean; +``` 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..a785438c55fd4 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportretry.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportretry.md @@ -16,6 +16,8 @@ export interface SavedObjectsImportRetry | Property | Type | Description | | --- | --- | --- | +| [createNewCopy](./kibana-plugin-core-public.savedobjectsimportretry.createnewcopy.md) | boolean | If createNewCopy is specified, the new object has a new (undefined) origin ID. This is only needed for the case where createNewCopies mode is disabled and ambiguous source conflicts are detected. | +| [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;
}> | | diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportsuccess.createnewcopy.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportsuccess.createnewcopy.md new file mode 100644 index 0000000000000..0598691fbd525 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsimportsuccess.createnewcopy.md @@ -0,0 +1,16 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsImportSuccess](./kibana-plugin-core-public.savedobjectsimportsuccess.md) > [createNewCopy](./kibana-plugin-core-public.savedobjectsimportsuccess.createnewcopy.md) + +## SavedObjectsImportSuccess.createNewCopy property + +> Warning: This API is now obsolete. +> +> If `createNewCopy` is specified, the new object has a new (undefined) origin ID. This is only needed for the case where `createNewCopies` mode is disabled and ambiguous source conflicts are detected. When `createNewCopies` mode is permanently enabled, this field will be redundant and can be removed. +> + +Signature: + +```typescript +createNewCopy?: 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..9eb39c96a1b78 --- /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 | +| --- | --- | --- | +| [createNewCopy](./kibana-plugin-core-public.savedobjectsimportsuccess.createnewcopy.md) | boolean | | +| [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 | | +| [type](./kibana-plugin-core-public.savedobjectsimportsuccess.type.md) | string | | + 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..cebebbaf94fe6 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, createNewCopies, savedObjectsClient, typeRegistry, namespace, }: SavedObjectsImportOptions): Promise; ``` ## Parameters | Parameter | Type | Description | | --- | --- | --- | -| { readStream, objectLimit, overwrite, savedObjectsClient, supportedTypes, namespace, } | SavedObjectsImportOptions | | +| { readStream, objectLimit, overwrite, createNewCopies, 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 61ffc532f0de5..3a193708e132f 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, createNewCopies, 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, createNewCopies, })](./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 @@ -153,6 +153,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. | @@ -166,12 +168,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..a2255613e0f6c 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, createNewCopies, }: SavedObjectsResolveImportErrorsOptions): Promise; ``` ## Parameters | Parameter | Type | Description | | --- | --- | --- | -| { readStream, objectLimit, retries, savedObjectsClient, supportedTypes, namespace, } | SavedObjectsResolveImportErrorsOptions | | +| { readStream, objectLimit, retries, savedObjectsClient, typeRegistry, namespace, createNewCopies, } | 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 67e931f0cb3b3..15a9d99b3d062 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.md @@ -23,6 +23,7 @@ export interface SavedObjectsFindOptions | [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 \* | +| [rootSearchFields](./kibana-plugin-core-server.savedobjectsfindoptions.rootsearchfields.md) | string[] | The fields to perform the parsed query against. Unlike the searchFields argument, these are expected to be root fields 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.rootsearchfields.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.rootsearchfields.md new file mode 100644 index 0000000000000..204342c45f64e --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.rootsearchfields.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsFindOptions](./kibana-plugin-core-server.savedobjectsfindoptions.md) > [rootSearchFields](./kibana-plugin-core-server.savedobjectsfindoptions.rootsearchfields.md) + +## SavedObjectsFindOptions.rootSearchFields property + +The fields to perform the parsed query against. Unlike the `searchFields` argument, these are expected to be root fields and will not be modified. If used in conjunction with `searchFields`, both are concatenated together. + +Signature: + +```typescript +rootSearchFields?: 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.createnewcopies.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportoptions.createnewcopies.md new file mode 100644 index 0000000000000..23c6fe0051746 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportoptions.createnewcopies.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsImportOptions](./kibana-plugin-core-server.savedobjectsimportoptions.md) > [createNewCopies](./kibana-plugin-core-server.savedobjectsimportoptions.createnewcopies.md) + +## SavedObjectsImportOptions.createNewCopies property + +If true, will create new copies of import objects, each with a random `id` and undefined `originId`. + +Signature: + +```typescript +createNewCopies: boolean; +``` 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..6578b01ffa609 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportoptions.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportoptions.md @@ -16,10 +16,11 @@ export interface SavedObjectsImportOptions | Property | Type | Description | | --- | --- | --- | +| [createNewCopies](./kibana-plugin-core-server.savedobjectsimportoptions.createnewcopies.md) | boolean | If true, will create new copies of import objects, each with a random id and undefined originId. | | [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 | If true, will override existing object if present. Note: this has no effect when used with the createNewCopies option. | | [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 | +| [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..1e9192c47679d 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,7 @@ ## SavedObjectsImportOptions.overwrite property -if true, will override existing object if present +If true, will override existing object if present. Note: this has no effect when used with the `createNewCopies` 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.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.createnewcopy.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportretry.createnewcopy.md new file mode 100644 index 0000000000000..e9cc92c55ded1 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportretry.createnewcopy.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsImportRetry](./kibana-plugin-core-server.savedobjectsimportretry.md) > [createNewCopy](./kibana-plugin-core-server.savedobjectsimportretry.createnewcopy.md) + +## SavedObjectsImportRetry.createNewCopy property + +If `createNewCopy` is specified, the new object has a new (undefined) origin ID. This is only needed for the case where `createNewCopies` mode is disabled and ambiguous source conflicts are detected. + +Signature: + +```typescript +createNewCopy?: boolean; +``` 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..258f18aebffc8 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportretry.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportretry.md @@ -16,6 +16,8 @@ export interface SavedObjectsImportRetry | Property | Type | Description | | --- | --- | --- | +| [createNewCopy](./kibana-plugin-core-server.savedobjectsimportretry.createnewcopy.md) | boolean | If createNewCopy is specified, the new object has a new (undefined) origin ID. This is only needed for the case where createNewCopies mode is disabled and ambiguous source conflicts are detected. | +| [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;
}> | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportsuccess.createnewcopy.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportsuccess.createnewcopy.md new file mode 100644 index 0000000000000..66b7a268f2ed5 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportsuccess.createnewcopy.md @@ -0,0 +1,16 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsImportSuccess](./kibana-plugin-core-server.savedobjectsimportsuccess.md) > [createNewCopy](./kibana-plugin-core-server.savedobjectsimportsuccess.createnewcopy.md) + +## SavedObjectsImportSuccess.createNewCopy property + +> Warning: This API is now obsolete. +> +> If `createNewCopy` is specified, the new object has a new (undefined) origin ID. This is only needed for the case where `createNewCopies` mode is disabled and ambiguous source conflicts are detected. When `createNewCopies` mode is permanently enabled, this field will be redundant and can be removed. +> + +Signature: + +```typescript +createNewCopy?: 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..8887dbe33d019 --- /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 | +| --- | --- | --- | +| [createNewCopy](./kibana-plugin-core-server.savedobjectsimportsuccess.createnewcopy.md) | boolean | | +| [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 | | +| [type](./kibana-plugin-core-server.savedobjectsimportsuccess.type.md) | string | | + 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 6c41441302c0b..1b562263145da 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, namespaces, type, filter, preference, }: SavedObjectsFindOptions): Promise>; +find({ search, defaultSearchOperator, searchFields, rootSearchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespaces, type, filter, preference, }: SavedObjectsFindOptions): Promise>; ``` ## Parameters | Parameter | Type | Description | | --- | --- | --- | -| { search, defaultSearchOperator, searchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespaces, type, filter, preference, } | SavedObjectsFindOptions | | +| { search, defaultSearchOperator, searchFields, rootSearchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespaces, 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 5b02707a3c0f4..14d3741425987 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, namespaces, type, filter, preference, })](./kibana-plugin-core-server.savedobjectsrepository.find.md) | | | +| [find({ search, defaultSearchOperator, searchFields, rootSearchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespaces, 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.createnewcopies.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.createnewcopies.md new file mode 100644 index 0000000000000..82831eae37d7b --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.createnewcopies.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsResolveImportErrorsOptions](./kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.md) > [createNewCopies](./kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.createnewcopies.md) + +## SavedObjectsResolveImportErrorsOptions.createNewCopies property + +If true, will create new copies of import objects, each with a random `id` and undefined `originId`. + +Signature: + +```typescript +createNewCopies: boolean; +``` 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..f97bf284375d1 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.md @@ -16,10 +16,11 @@ export interface SavedObjectsResolveImportErrorsOptions | Property | Type | Description | | --- | --- | --- | +| [createNewCopies](./kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.createnewcopies.md) | boolean | If true, will create new copies of import objects, each with a random id and undefined originId. | | [namespace](./kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.namespace.md) | string | if specified, will import in given namespace | | [objectLimit](./kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.objectlimit.md) | number | The maximum number of object to import | | [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 | +| [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.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/package.json b/package.json index 0d6bc8cc1fceb..d465fef07c124 100644 --- a/package.json +++ b/package.json @@ -229,6 +229,7 @@ "node-forge": "^0.9.1", "opn": "^5.5.0", "oppsy": "^2.0.0", + "p-map": "^4.0.0", "pegjs": "0.10.0", "postcss-loader": "^3.0.0", "prop-types": "15.6.0", diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 3e4e70fb99508..77553d76050b2 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -140,6 +140,7 @@ export { SavedObjectAttribute, SavedObjectAttributes, SavedObjectAttributeSingle, + SavedObjectError, SavedObjectReference, SavedObjectsBaseOptions, SavedObjectsFindOptions, @@ -148,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 c811209dfa80f..e8a28c897b014 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -1171,13 +1171,11 @@ export type PublicUiSettingsParams = Omit; export interface SavedObject { attributes: T; // (undocumented) - error?: { - message: string; - statusCode: number; - }; + error?: SavedObjectError; id: string; migrationVersion?: SavedObjectsMigrationVersion; namespaces?: string[]; + originId?: string; references: SavedObjectReference[]; type: string; updated_at?: string; @@ -1196,6 +1194,20 @@ export interface SavedObjectAttributes { // @public export type SavedObjectAttributeSingle = string | number | boolean | null | undefined | SavedObjectAttributes; +// Warning: (ae-missing-release-tag) "SavedObjectError" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface SavedObjectError { + // (undocumented) + error: string; + // (undocumented) + message: string; + // (undocumented) + metadata?: Record; + // (undocumented) + statusCode: number; +} + // @public export interface SavedObjectReference { // (undocumented) @@ -1300,6 +1312,7 @@ export interface SavedObjectsFindOptions { // (undocumented) perPage?: number; preference?: string; + rootSearchFields?: string[]; search?: string; searchFields?: string[]; // (undocumented) @@ -1320,8 +1333,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'; } @@ -1329,7 +1356,7 @@ export interface SavedObjectsImportConflictError { // @public export interface SavedObjectsImportError { // (undocumented) - error: SavedObjectsImportConflictError | SavedObjectsImportUnsupportedTypeError | SavedObjectsImportMissingReferencesError | SavedObjectsImportUnknownError; + error: SavedObjectsImportConflictError | SavedObjectsImportAmbiguousConflictError | SavedObjectsImportUnsupportedTypeError | SavedObjectsImportMissingReferencesError | SavedObjectsImportUnknownError; // (undocumented) id: string; // (undocumented) @@ -1362,10 +1389,14 @@ export interface SavedObjectsImportResponse { success: boolean; // (undocumented) successCount: number; + // (undocumented) + successResults?: SavedObjectsImportSuccess[]; } // @public export interface SavedObjectsImportRetry { + createNewCopy?: boolean; + destinationId?: string; // (undocumented) id: string; // (undocumented) @@ -1380,6 +1411,17 @@ export interface SavedObjectsImportRetry { type: string; } +// @public +export interface SavedObjectsImportSuccess { + // @deprecated (undocumented) + createNewCopy?: boolean; + destinationId?: string; + // (undocumented) + id: string; + // (undocumented) + type: string; +} + // @public export interface SavedObjectsImportUnknownError { // (undocumented) diff --git a/src/core/public/saved_objects/index.ts b/src/core/public/saved_objects/index.ts index 13b4a12893666..f49a5e0a8a5c4 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, @@ -49,5 +51,6 @@ export { SavedObjectAttribute, SavedObjectAttributes, SavedObjectAttributeSingle, + SavedObjectError, SavedObjectReference, } from '../../types'; diff --git a/src/core/public/saved_objects/saved_objects_client.ts b/src/core/public/saved_objects/saved_objects_client.ts index 209f489e29139..351020004b0e7 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' | 'rootSearchFields' +>; type PromiseType> = T extends Promise ? U : never; diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 706ec88c6ebfd..3cdee684fc13e 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -228,6 +228,8 @@ export { SavedObjectsBulkUpdateOptions, SavedObjectsBulkResponse, SavedObjectsBulkUpdateResponse, + SavedObjectsCheckConflictsObject, + SavedObjectsCheckConflictsResponse, SavedObjectsClient, SavedObjectsClientProviderOptions, SavedObjectsClientWrapperFactory, @@ -241,11 +243,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 27c0a5205ae38..85b3a281aef7f 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 @@ -665,6 +665,33 @@ 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([ + createSavedObject({ type: 'multi', id: '1' }), + createSavedObject({ type: 'multi', id: '2' }), + createSavedObject({ type: 'other', id: '3' }), + 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 6cfe6f1be5669..94f727e238ecf 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) { 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..a0b34af272fbf --- /dev/null +++ b/src/core/server/saved_objects/import/check_conflicts.test.ts @@ -0,0 +1,165 @@ +/* + * 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 CheckConflictsParams = Parameters[0]; + +/** + * 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']; + + const setupParams = (partial: { + objects: SavedObjectType[]; + namespace?: string; + ignoreRegularConflicts?: boolean; + createNewCopies?: boolean; + }): CheckConflictsParams => { + savedObjectsClient = savedObjectsClientMock.create(); + socCheckConflicts = savedObjectsClient.checkConflicts; + socCheckConflicts.mockResolvedValue({ errors: [] }); // by default, mock to empty results + return { ...partial, savedObjectsClient }; + }; + + beforeEach(() => { + mockUuidv4.mockReset(); + mockUuidv4.mockReturnValueOnce(`new-object-id`); + }); + + it('exits early if there are no objects to check', async () => { + const namespace = 'foo-namespace'; + const params = setupParams({ objects: [], namespace }); + + const checkConflictsResult = await checkConflicts(params); + expect(socCheckConflicts).not.toHaveBeenCalled(); + expect(checkConflictsResult).toEqual({ + filteredObjects: [], + errors: [], + importIdMap: new Map(), + }); + }); + + it('calls checkConflicts with expected inputs', async () => { + const namespace = 'foo-namespace'; + const params = setupParams({ objects, namespace }); + + await checkConflicts(params); + expect(socCheckConflicts).toHaveBeenCalledTimes(1); + expect(socCheckConflicts).toHaveBeenCalledWith(objects, { namespace }); + }); + + it('returns expected result', async () => { + const namespace = 'foo-namespace'; + const params = setupParams({ objects, namespace }); + socCheckConflicts.mockResolvedValue({ errors: [obj2Error, obj3Error, obj4Error] }); + + const checkConflictsResult = await checkConflicts(params); + 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` }]]), + }); + }); + + it('does not return errors for regular conflicts when ignoreRegularConflicts=true', async () => { + const namespace = 'foo-namespace'; + const params = setupParams({ objects, namespace, ignoreRegularConflicts: true }); + socCheckConflicts.mockResolvedValue({ errors: [obj2Error, obj3Error, obj4Error] }); + + const checkConflictsResult = await checkConflicts(params); + 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 createNewCopies=true', async () => { + const namespace = 'foo-namespace'; + const params = setupParams({ objects, namespace, createNewCopies: true }); + socCheckConflicts.mockResolvedValue({ errors: [obj2Error, obj3Error, obj4Error] }); + + const checkConflictsResult = await checkConflicts(params); + 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..eec902494ec41 --- /dev/null +++ b/src/core/server/saved_objects/import/check_conflicts.ts @@ -0,0 +1,84 @@ +/* + * 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 CheckConflictsParams { + objects: Array>; + savedObjectsClient: SavedObjectsClientContract; + namespace?: string; + ignoreRegularConflicts?: boolean; + createNewCopies?: boolean; +} + +const isUnresolvableConflict = (error: SavedObjectError) => + error.statusCode === 409 && error.metadata?.isNotOverwritable; + +export async function checkConflicts({ + objects, + savedObjectsClient, + namespace, + ignoreRegularConflicts, + createNewCopies, +}: CheckConflictsParams) { + const filteredObjects: Array> = []; + const errors: SavedObjectsImportError[] = []; + const importIdMap = new Map(); + + // exit early if there are no objects to check + if (objects.length === 0) { + return { filteredObjects, errors, importIdMap }; + } + + 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; + 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: createNewCopies }); + 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 }; +} 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..05bc9ae0af004 --- /dev/null +++ b/src/core/server/saved_objects/import/check_origin_conflicts.test.ts @@ -0,0 +1,584 @@ +/* + * 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 CheckOriginConflictsParams = Parameters[0]; + +/** + * 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 setupParams = (partial: { + objects: SavedObjectType[]; + namespace?: string; + importIdMap?: Map; + ignoreRegularConflicts?: boolean; + }): CheckOriginConflictsParams => { + 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 { + importIdMap: new Map(), // empty by default + ...partial, + savedObjectsClient, + typeRegistry, + }; + }; + + 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 expectedArgs = expect.objectContaining({ type, search }); + // exclude rootSearchFields, page, perPage, and fields attributes from assertion -- these are constant + // exclude namespace from assertion -- a separate test covers that + expect(find).toHaveBeenNthCalledWith(n, expectedArgs); + }; + + test('does not execute searches for non-multi-namespace objects', async () => { + const objects = [otherObj, otherObjWithOriginId]; + const params = setupParams({ objects }); + + await checkOriginConflicts(params); + expect(find).not.toHaveBeenCalled(); + }); + + test('executes searches for multi-namespace objects', async () => { + const objects = [multiNsObj, otherObj, multiNsObjWithOriginId, otherObjWithOriginId]; + const params1 = setupParams({ objects }); + + await checkOriginConflicts(params1); + expect(find).toHaveBeenCalledTimes(2); + expectFindArgs(1, multiNsObj, ''); + expectFindArgs(2, multiNsObjWithOriginId, ''); + + find.mockClear(); + const params2 = setupParams({ objects, namespace: 'some-namespace' }); + await checkOriginConflicts(params2); + expect(find).toHaveBeenCalledTimes(2); + expectFindArgs(1, multiNsObj, 'some-namespace:'); + expectFindArgs(2, multiNsObjWithOriginId, 'some-namespace:'); + }); + + test('searches within the current `namespace`', async () => { + const objects = [multiNsObj]; + const namespace = 'some-namespace'; + const params = setupParams({ objects, namespace }); + + await checkOriginConflicts(params); + expect(find).toHaveBeenCalledTimes(1); + expect(find).toHaveBeenCalledWith(expect.objectContaining({ namespaces: [namespace] })); + }); + + test('search query escapes quote and backslash characters in `id` and/or `originId`', async () => { + const weirdId = `some"weird\\id`; + const objects = [ + createObject(MULTI_NS_TYPE, weirdId), + createObject(MULTI_NS_TYPE, 'some-id', weirdId), + ]; + const params = setupParams({ objects }); + + await checkOriginConflicts(params); + const escapedId = `some\\"weird\\\\id`; + const expectedQuery = `"${MULTI_NS_TYPE}:${escapedId}" | "${escapedId}"`; + expect(find).toHaveBeenCalledTimes(2); + expect(find).toHaveBeenNthCalledWith(1, expect.objectContaining({ search: expectedQuery })); + expect(find).toHaveBeenNthCalledWith(2, expect.objectContaining({ search: expectedQuery })); + }); + }); + + describe('results', () => { + 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 objects = [obj1, obj2, obj3, obj4]; + const params = setupParams({ objects }); + + // 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(params); + + const expectedResult = { + filteredObjects: objects, + 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 objects = [obj2, obj4]; + const params = setupParams({ + objects, + importIdMap: new Map([ + [`${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(params); + const expectedResult = { + filteredObjects: objects, + 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 objects = [obj3]; + const params = setupParams({ + objects, + importIdMap: new Map([ + [`${obj1.type}:${obj1.id}`, {}], + [`${obj2.type}:${obj2.id}`, {}], + [`${obj3.type}:${obj3.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(params); + const expectedResult = { + filteredObjects: objects, + 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 objects = [obj1, obj2]; + + const setup = (ignoreRegularConflicts: boolean) => { + const params = setupParams({ objects, 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 params; + }; + + test('returns conflict error when ignoreRegularConflicts=false', async () => { + const params = setup(false); + const checkOriginConflictsResult = await checkOriginConflicts(params); + 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 params = setup(true); + const checkOriginConflictsResult = await checkOriginConflicts(params); + const expectedResult = { + filteredObjects: objects, + 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 objects = [obj2, obj4]; + + const setup = (ignoreRegularConflicts: boolean) => { + const params = setupParams({ + objects, + ignoreRegularConflicts, + importIdMap: new Map([ + [`${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 params; + }; + + test('returns conflict error when ignoreRegularConflicts=false', async () => { + const params = setup(false); + const checkOriginConflictsResult = await checkOriginConflicts(params); + 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 params = setup(true); + const checkOriginConflictsResult = await checkOriginConflicts(params); + const expectedResult = { + filteredObjects: objects, + 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 objects = [obj1, obj2, obj3, obj4]; + const params = setupParams({ objects }); + 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(params); + const expectedResult = { + filteredObjects: objects, + 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 objects = [obj1, obj2]; + const params = setupParams({ objects }); + 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(params); + 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 objects = [obj1, obj2, obj3, obj4]; + const params = setupParams({ objects }); + 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(params); + const expectedResult = { + filteredObjects: objects, + 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 importIdMap = new Map([...objects, obj3].map(({ type, id }) => [`${type}:${id}`, {}])); + + const setup = (ignoreRegularConflicts: boolean) => { + const params = setupParams({ objects, importIdMap, 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 params; + }; + + test('returns errors for regular conflicts when ignoreRegularConflicts=false', async () => { + const params = setup(false); + const checkOriginConflictsResult = await checkOriginConflicts(params); + 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 params = setup(true); + const checkOriginConflictsResult = await checkOriginConflicts(params); + 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 createRetry = ( + { type, id }: { type: string; id: string }, + params: { destinationId?: string; createNewCopy?: boolean } = {} + ): SavedObjectsImportRetry => { + const { destinationId, createNewCopy } = params; + return { type, id, overwrite: false, destinationId, replaceReferences: [], createNewCopy }; + }; + + test('throws an error if retry is not found for an object', async () => { + const obj1 = createObject(MULTI_NS_TYPE, 'id-1'); + const obj2 = createObject(MULTI_NS_TYPE, 'id-2'); + const objects = [obj1, obj2]; + const retries = [createRetry(obj1)]; + const params = { objects, retries, createNewCopies: false }; + + expect(() => getImportIdMapForRetries(params)).toThrowErrorMatchingInlineSnapshot( + `"Retry was expected for \\"multi:id-2\\" but not found"` + ); + }); + + test('returns expected results', async () => { + const obj1 = createObject('type-1', 'id-1'); + const obj2 = createObject('type-2', 'id-2'); + const obj3 = createObject('type-3', 'id-3'); + const obj4 = createObject('type-4', 'id-4'); + const objects = [obj1, obj2, obj3, obj4]; + const retries = [ + createRetry(obj1), // retries that do not have `destinationId` specified are ignored + createRetry(obj2, { destinationId: obj2.id }), // retries that have `id` that matches `destinationId` are ignored + createRetry(obj3, { destinationId: 'id-X' }), // this retry will get added to the `importIdMap`! + createRetry(obj4, { destinationId: 'id-Y', createNewCopy: true }), // this retry will get added to the `importIdMap`! + ]; + const params = { objects, retries, createNewCopies: false }; + + const checkOriginConflictsResult = await getImportIdMapForRetries(params); + expect(checkOriginConflictsResult).toEqual( + new Map([ + [`${obj3.type}:${obj3.id}`, { id: 'id-X', omitOriginId: false }], + [`${obj4.type}:${obj4.id}`, { id: 'id-Y', omitOriginId: true }], + ]) + ); + }); + + test('omits origin ID in `importIdMap` entries when createNewCopies=true', async () => { + const obj = createObject('type-1', 'id-1'); + const objects = [obj]; + const retries = [createRetry(obj, { destinationId: 'id-X' })]; + const params = { objects, retries, createNewCopies: true }; + + const checkOriginConflictsResult = await getImportIdMapForRetries(params); + expect(checkOriginConflictsResult).toEqual( + new Map([[`${obj.type}:${obj.id}`, { id: 'id-X', omitOriginId: true }]]) + ); + }); +}); diff --git a/src/core/server/saved_objects/import/check_origin_conflicts.ts b/src/core/server/saved_objects/import/check_origin_conflicts.ts new file mode 100644 index 0000000000000..5dcaf1f09eb48 --- /dev/null +++ b/src/core/server/saved_objects/import/check_origin_conflicts.ts @@ -0,0 +1,251 @@ +/* + * 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 pMap from 'p-map'; +import { v4 as uuidv4 } from 'uuid'; +import { + SavedObject, + SavedObjectsClientContract, + SavedObjectsImportError, + SavedObjectsImportRetry, +} from '../types'; +import { ISavedObjectTypeRegistry } from '..'; + +interface CheckOriginConflictsParams { + objects: Array>; + savedObjectsClient: SavedObjectsClientContract; + typeRegistry: ISavedObjectTypeRegistry; + namespace?: string; + ignoreRegularConflicts?: boolean; + importIdMap: Map; +} + +type CheckOriginConflictParams = Omit & { + object: SavedObject<{ title?: string }>; +}; + +interface GetImportIdMapForRetriesParams { + objects: SavedObject[]; + retries: SavedObjectsImportRetry[]; + createNewCopies: boolean; +} + +interface InexactMatch { + object: SavedObject; + destinations: Array<{ id: string; title?: string; updatedAt?: string }>; +} +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 MAX_CONCURRENT_SEARCHES = 10; + +const createQueryTerm = (input: string) => input.replace(/\\/g, '\\\\').replace(/\"/g, '\\"'); +const createQuery = (type: string, id: string, rawIdPrefix: string) => + `"${createQueryTerm(`${rawIdPrefix}${type}:${id}`)}" | "${createQueryTerm(id)}"`; +const 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 ( + params: CheckOriginConflictParams +): Promise> => { + const { object, savedObjectsClient, typeRegistry, namespace, importIdMap } = params; + const importIds = new Set(importIdMap.keys()); + 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, + rootSearchFields: ['_id', 'originId'], + page: 1, + perPage: 10, + fields: ['title'], + ...(namespace && { namespaces: [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, ...params }: CheckOriginConflictsParams) { + // Check each object for possible destination conflicts, ensuring we don't too many concurrent searches running. + const mapper = async (object: SavedObject<{ title?: string }>) => + checkOriginConflict({ object, ...params }); + const checkOriginConflictResults = await pMap(objects, mapper, { + concurrency: MAX_CONCURRENT_SEARCHES, + }); + + // 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 (params.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., the same outcome as if `createNewCopies` was enabled for the entire import operation). + 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 beginning of `resolveSavedObjectsImportErrors`). + */ +export function getImportIdMapForRetries(params: GetImportIdMapForRetriesParams) { + const { objects, retries, createNewCopies } = params; + + const retryMap = retries.reduce( + (acc, cur) => acc.set(`${cur.type}:${cur.id}`, cur), + new Map() + ); + const importIdMap = new Map(); + + objects.forEach(({ type, id }) => { + const retry = retryMap.get(`${type}:${id}`); + if (!retry) { + throw new Error(`Retry was expected for "${type}:${id}" but not found`); + } + const { destinationId } = retry; + const omitOriginId = createNewCopies || Boolean(retry.createNewCopy); + if (destinationId && destinationId !== id) { + importIdMap.set(`${type}:${id}`, { id: destinationId, omitOriginId }); + } + }); + + return importIdMap; +} diff --git a/src/core/server/saved_objects/import/collect_saved_objects.test.ts b/src/core/server/saved_objects/import/collect_saved_objects.test.ts index 9cccc3942f655..f2be46ce960d2 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,188 @@ * 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 './get_non_unique_entries'; + +jest.mock('./create_limit_stream'); +jest.mock('./get_non_unique_entries'); + +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: [], importIdMap: new Map() }); + }); + + 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: {} }]; + const importIdMap = new Map([[`${obj1.type}:${obj1.id}`, {}]]); + expect(result).toEqual({ collectedObjects, errors: [], importIdMap }); + }); + + 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, importIdMap: new Map() }); + }); + + 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 importIdMap = new Map([[`${obj2.type}:${obj2.id}`, {}]]); + const error = { type: 'unsupported_type' }; + const errors = [{ error, type: obj1.type, id: obj1.id, title: obj1.attributes.title }]; + expect(result).toEqual({ collectedObjects, errors, importIdMap }); + }); + + 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, importIdMap: new Map() }); + }); + + 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 importIdMap = new Map([[`${obj2.type}:${obj2.id}`, {}]]); + const error = { type: 'unsupported_type' }; + const errors = [{ error, type: obj1.type, id: obj1.id, title: obj1.attributes.title }]; + expect(result).toEqual({ collectedObjects, errors, importIdMap }); + }); }); - 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..c3b581dc6478f 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 './get_non_unique_entries'; +import { SavedObjectsErrorHelpers } from '..'; interface CollectSavedObjectsOptions { readStream: Readable; @@ -42,10 +44,13 @@ export async function collectSavedObjects({ supportedTypes, }: CollectSavedObjectsOptions) { const errors: SavedObjectsImportError[] = []; + const entries: Array<{ type: string; id: string }> = []; + const importIdMap = new Map(); 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; } @@ -61,13 +66,24 @@ export async function collectSavedObjects({ }), createFilterStream((obj) => (filter ? filter(obj) : true)), createMapStream((obj: SavedObject) => { + importIdMap.set(`${obj.type}:${obj.id}`, {}); // Ensure migrations execute on every saved object return Object.assign({ migrationVersion: {} }, obj); }), 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, + importIdMap, }; } 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..192714390f365 --- /dev/null +++ b/src/core/server/saved_objects/import/create_saved_objects.test.ts @@ -0,0 +1,288 @@ +/* + * 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 CreateSavedObjectsParams = Parameters[0]; + +/** + * 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 setupParams = (partial: { + objects: SavedObject[]; + accumulatedErrors?: SavedObjectsImportError[]; + namespace?: string; + overwrite?: boolean; + }): CreateSavedObjectsParams => { + savedObjectsClient = savedObjectsClientMock.create(); + bulkCreate = savedObjectsClient.bulkCreate; + return { accumulatedErrors: [], ...partial, savedObjectsClient, importIdMap }; + }; + + const getExpectedBulkCreateArgsObjects = (objects: SavedObject[], retry?: boolean) => + objects.map(({ type, id, attributes, 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: CreateSavedObjectsParams) => { + 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 }: CreateSavedObjectsParams + ): 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 = setupParams({ objects: [] }); + + 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: CreateSavedObjectsParams) => { + 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 = setupParams({ objects: objs, accumulatedErrors: [error] }); + await createSavedObjects(options); + expect(bulkCreate).not.toHaveBeenCalled(); + } + }); + + test('calls bulkCreate when unresolvable errors or no errors are present', async () => { + for (const error of unresolvableErrors) { + const options = setupParams({ objects: objs, accumulatedErrors: [error] }); + setupMockResults(options); + await createSavedObjects(options); + expect(bulkCreate).toHaveBeenCalledTimes(1); + bulkCreate.mockClear(); + } + const options = setupParams({ objects: objs }); + setupMockResults(options); + await createSavedObjects(options); + expect(bulkCreate).toHaveBeenCalledTimes(1); + }); + }); + + const testBulkCreateObjects = async (namespace?: string) => { + const options = setupParams({ objects: objs, namespace }); + setupMockResults(options); + + await createSavedObjects(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 = setupParams({ objects: objs, namespace, overwrite }); + setupMockResults(options); + + await createSavedObjects(options); + expect(bulkCreate).toHaveBeenCalledTimes(1); + expectBulkCreateArgs.options(1, options); + }; + const testReturnValue = async (namespace?: string) => { + const options = setupParams({ objects: objs, namespace }); + setupMockResults(options); + + const results = await createSavedObjects(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..3fce2b39c148a --- /dev/null +++ b/src/core/server/saved_objects/import/create_saved_objects.ts @@ -0,0 +1,103 @@ +/* + * 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'; +import { CreatedObject } from './types'; + +interface CreateSavedObjectsParams { + objects: Array>; + accumulatedErrors: SavedObjectsImportError[]; + savedObjectsClient: SavedObjectsClientContract; + importIdMap: Map; + namespace?: string; + overwrite?: boolean; +} +interface CreateSavedObjectsResult { + createdObjects: Array>; + 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, + accumulatedErrors, + savedObjectsClient, + importIdMap, + namespace, + overwrite, +}: CreateSavedObjectsParams): Promise> => { + // exit early if there are no objects to create + if (objects.length === 0) { + return { createdObjects: [], errors: [] }; + } + + // 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 reference is being created with the correct ID + const references = object.references?.map((reference) => { + const { type, id } = reference; + const importIdEntry = importIdMap.get(`${type}:${id}`); + if (importIdEntry?.id) { + return { ...reference, id: importIdEntry.id }; + } + return reference; + }); + // use the import ID map to ensure that each object is being created with the correct ID, also ensure that the `originId` is set on + // the created object if it did not have one (or is omitted if specified) + const importIdEntry = importIdMap.get(`${object.type}:${object.id}`); + if (importIdEntry?.id) { + objectIdMap.set(`${object.type}:${importIdEntry.id}`, object); + const originId = importIdEntry.omitOriginId ? undefined : object.originId ?? object.id; + return { ...object, id: importIdEntry.id, originId, ...(references && { references }) }; + } + 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>((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..f0c9e4ac292b8 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,8 @@ import { SavedObject } from '../types'; import { extractErrors } from './extract_errors'; +import { SavedObjectsErrorHelpers } from '..'; +import { CreatedObject } from './types'; describe('extractErrors()', () => { test('returns empty array when no errors exist', () => { @@ -28,7 +30,7 @@ describe('extractErrors()', () => { }); test('extracts errors from saved objects', () => { - const savedObjects: SavedObject[] = [ + const savedObjects: Array> = [ { id: '1', type: 'dashboard', @@ -44,10 +46,7 @@ describe('extractErrors()', () => { title: 'My Dashboard 2', }, references: [], - error: { - statusCode: 409, - message: 'Conflict', - }, + error: SavedObjectsErrorHelpers.createConflictError('dashboard', '2').output.payload, }, { id: '3', @@ -56,10 +55,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 +81,7 @@ Array [ }, Object { "error": Object { + "error": "Bad Request", "message": "Bad Request", "statusCode": 400, "type": "unknown", @@ -83,6 +90,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..b3223ade4b59d 100644 --- a/src/core/server/saved_objects/import/extract_errors.ts +++ b/src/core/server/saved_objects/import/extract_errors.ts @@ -17,11 +17,11 @@ * under the License. */ import { SavedObject } from '../types'; -import { SavedObjectsImportError } from './types'; +import { SavedObjectsImportError, CreatedObject } from './types'; export function extractErrors( // TODO: define saved object type - savedObjectResults: Array>, + savedObjectResults: Array>, 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/get_non_unique_entries.test.ts b/src/core/server/saved_objects/import/get_non_unique_entries.test.ts new file mode 100644 index 0000000000000..a66fa437142d3 --- /dev/null +++ b/src/core/server/saved_objects/import/get_non_unique_entries.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 './get_non_unique_entries'; + +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/get_non_unique_entries.ts b/src/core/server/saved_objects/import/get_non_unique_entries.ts new file mode 100644 index 0000000000000..468bf73d9b2db --- /dev/null +++ b/src/core/server/saved_objects/import/get_non_unique_entries.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/import_saved_objects.test.ts b/src/core/server/saved_objects/import/import_saved_objects.test.ts index e204cd7bddfc7..8d0ea687e9252 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,368 @@ */ 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); - }, +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: [], + importIdMap: new Map(), }); - savedObjectsClient.find.mockResolvedValueOnce(emptyResponse); - savedObjectsClient.bulkCreate.mockResolvedValue({ - saved_objects: savedObjects, + getMockFn(regenerateIds).mockReturnValue(new Map()); + getMockFn(validateReferences).mockResolvedValue({ errors: [], filteredObjects: [] }); + getMockFn(checkConflicts).mockResolvedValue({ + errors: [], + filteredObjects: [], + importIdMap: new Map(), }); - 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 = (createNewCopies: 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, + createNewCopies, + }; + }; + 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); - }, + /** + * 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); }); - 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: [], - })), + + test('validates references', async () => { + const options = setupOptions(); + const collectedObjects = [createObject()]; + getMockFn(collectSavedObjects).mockResolvedValue({ + errors: [], + collectedObjects, + importIdMap: new Map(), + }); + + await importSavedObjectsFromStream(options); + expect(validateReferences).toHaveBeenCalledWith( + collectedObjects, + savedObjectsClient, + namespace + ); }); - const result = await importSavedObjectsFromStream({ - readStream, - objectLimit: 4, - overwrite: false, - savedObjectsClient, - supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'], + + describe('with createNewCopies disabled', () => { + test('does not regenerate object IDs', async () => { + const options = setupOptions(); + const collectedObjects = [createObject()]; + getMockFn(collectSavedObjects).mockResolvedValue({ + errors: [], + collectedObjects, + importIdMap: new Map(), + }); + + 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 checkConflictsParams = { + objects: filteredObjects, + savedObjectsClient, + namespace, + ignoreRegularConflicts: overwrite, + }; + expect(checkConflicts).toHaveBeenCalledWith(checkConflictsParams); + }); + + test('checks origin conflicts', async () => { + const options = setupOptions(); + const filteredObjects = [createObject()]; + const importIdMap = new Map(); + getMockFn(checkConflicts).mockResolvedValue({ + errors: [], + filteredObjects, + importIdMap, + }); + + await importSavedObjectsFromStream(options); + const checkOriginConflictsParams = { + objects: filteredObjects, + savedObjectsClient, + typeRegistry, + namespace, + ignoreRegularConflicts: overwrite, + importIdMap, + }; + expect(checkOriginConflicts).toHaveBeenCalledWith(checkOriginConflictsParams); + }); + + 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 + importIdMap: new Map([ + ['foo', {}], + ['bar', {}], + ['baz', {}], + ]), + }); + getMockFn(validateReferences).mockResolvedValue({ + errors: [errors[1]], + filteredObjects: [], // doesn't matter + }); + getMockFn(checkConflicts).mockResolvedValue({ + errors: [errors[2]], + filteredObjects, + importIdMap: new Map([['bar', { id: 'newId1' }]]), + }); + getMockFn(checkOriginConflicts).mockResolvedValue({ + errors: [errors[3]], + filteredObjects, + importIdMap: new Map([['baz', { id: 'newId2' }]]), + }); + + await importSavedObjectsFromStream(options); + const importIdMap = new Map([ + ['foo', {}], + ['bar', { id: 'newId1' }], + ['baz', { id: 'newId2' }], + ]); + const createSavedObjectsParams = { + objects: filteredObjects, + accumulatedErrors: errors, + savedObjectsClient, + importIdMap, + overwrite, + namespace, + }; + expect(createSavedObjects).toHaveBeenCalledWith(createSavedObjectsParams); + }); }); - 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 createNewCopies enabled', () => { + test('regenerates object IDs', async () => { + const options = setupOptions(true); + const collectedObjects = [createObject()]; + getMockFn(collectSavedObjects).mockResolvedValue({ + errors: [], + collectedObjects, + importIdMap: new Map(), // doesn't matter }); - this.push({ - id: '3', - type: 'visualization', - attributes: { - title: 'My Visualization', - }, - references: [ - { - name: 'ref_0', - type: 'search', - id: '1', - }, - ], + + 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 + importIdMap: new Map([ + ['foo', {}], + ['bar', {}], + ]), }); - this.push(null); - }, + getMockFn(validateReferences).mockResolvedValue({ errors: [errors[1]], filteredObjects }); + // this importIdMap is not composed with the one obtained from `collectSavedObjects` + const importIdMap = new Map().set(`id1`, { id: `newId1` }); + getMockFn(regenerateIds).mockReturnValue(importIdMap); + + await importSavedObjectsFromStream(options); + const createSavedObjectsParams = { + objects: filteredObjects, + accumulatedErrors: errors, + savedObjectsClient, + importIdMap, + overwrite, + namespace, + }; + expect(createSavedObjects).toHaveBeenCalledWith(createSavedObjectsParams); + }); }); - savedObjectsClient.bulkGet.mockResolvedValueOnce({ - saved_objects: [ - { - type: 'index-pattern', - id: '2', - error: { - statusCode: 404, - message: 'Not found', - }, - attributes: {}, - references: [], - }, - ], + }); + + 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 }); }); - const result = await importSavedObjectsFromStream({ - readStream, - objectLimit: 4, - overwrite: false, - savedObjectsClient, - supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'], + + test('returns success=false if an error occurred', async () => { + const options = setupOptions(); + const errors = [createError()]; + getMockFn(collectSavedObjects).mockResolvedValue({ + errors, + collectedObjects: [], + importIdMap: new Map(), // doesn't matter + }); + + const result = await importSavedObjectsFromStream(options); + expect(result).toEqual({ success: false, successCount: 0, errors }); }); - 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 {}, + + 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 createNewCopies 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 }, + // `createNewCopies` 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 new 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, + createNewCopy: true, }, - ], - } - `); - }); + ]; + expect(result).toEqual({ success: false, successCount: 3, successResults, errors }); + }); - 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); - }, - }); - savedObjectsClient.find.mockResolvedValueOnce(emptyResponse); - savedObjectsClient.bulkCreate.mockResolvedValue({ - saved_objects: savedObjects, + test('with createNewCopies 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 createNewCopies 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 }); + }); }); - const result = await importSavedObjectsFromStream({ - readStream, - objectLimit: 5, - overwrite: false, - savedObjectsClient, - supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'], + + test('accumulates multiple errors', async () => { + const options = setupOptions(); + const errors = [createError(), createError(), createError(), createError(), createError()]; + getMockFn(collectSavedObjects).mockResolvedValue({ + errors: [errors[0]], + collectedObjects: [], + importIdMap: new Map(), // doesn't matter + }); + getMockFn(validateReferences).mockResolvedValue({ errors: [errors[1]], filteredObjects: [] }); + getMockFn(checkConflicts).mockResolvedValue({ + errors: [errors[2]], + filteredObjects: [], + importIdMap: new Map(), // doesn't matters + }); + getMockFn(checkOriginConflicts).mockResolvedValue({ + errors: [errors[3]], + filteredObjects: [], + importIdMap: new Map(), // doesn't matters + }); + 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..08a3c80ba947c 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,49 +39,89 @@ export async function importSavedObjectsFromStream({ readStream, objectLimit, overwrite, + createNewCopies, savedObjectsClient, - supportedTypes, + typeRegistry, namespace, }: SavedObjectsImportOptions): Promise { let errorAccumulator: SavedObjectsImportError[] = []; + const supportedTypes = typeRegistry.getImportableAndExportableTypes().map((type) => type.name); // Get the objects to import - const { - errors: collectorErrors, - collectedObjects: objectsFromStream, - } = await collectSavedObjects({ readStream, objectLimit, supportedTypes }); - errorAccumulator = [...errorAccumulator, ...collectorErrors]; + const collectSavedObjectsResult = await collectSavedObjects({ + readStream, + objectLimit, + supportedTypes, + }); + errorAccumulator = [...errorAccumulator, ...collectSavedObjectsResult.errors]; + /** Map of all IDs for objects that we are attempting to import; each value is empty by default */ + let importIdMap = collectSavedObjectsResult.importIdMap; // Validate references - const { filteredObjects, errors: validationErrors } = await validateReferences( - objectsFromStream, + const validateReferencesResult = await validateReferences( + collectSavedObjectsResult.collectedObjects, savedObjectsClient, namespace ); - errorAccumulator = [...errorAccumulator, ...validationErrors]; + errorAccumulator = [...errorAccumulator, ...validateReferencesResult.errors]; + + let objectsToCreate = validateReferencesResult.filteredObjects; + if (createNewCopies) { + importIdMap = regenerateIds(collectSavedObjectsResult.collectedObjects); + } else { + // Check single-namespace objects for conflicts in this namespace, and check multi-namespace objects for conflicts across all namespaces + const checkConflictsParams = { + objects: validateReferencesResult.filteredObjects, + savedObjectsClient, + namespace, + ignoreRegularConflicts: overwrite, + }; + const checkConflictsResult = await checkConflicts(checkConflictsParams); + errorAccumulator = [...errorAccumulator, ...checkConflictsResult.errors]; + importIdMap = new Map([...importIdMap, ...checkConflictsResult.importIdMap]); - // Exit early if no objects to import - if (filteredObjects.length === 0) { - return { - success: errorAccumulator.length === 0, - successCount: 0, - ...(errorAccumulator.length ? { errors: errorAccumulator } : {}), + // Check multi-namespace object types for origin conflicts in this namespace + const checkOriginConflictsParams = { + objects: checkConflictsResult.filteredObjects, + savedObjectsClient, + typeRegistry, + namespace, + ignoreRegularConflicts: overwrite, + importIdMap, }; + const checkOriginConflictsResult = await checkOriginConflicts(checkOriginConflictsParams); + errorAccumulator = [...errorAccumulator, ...checkOriginConflictsResult.errors]; + importIdMap = new Map([...importIdMap, ...checkOriginConflictsResult.importIdMap]); + objectsToCreate = checkOriginConflictsResult.filteredObjects; } // Create objects in bulk - const bulkCreateResult = await savedObjectsClient.bulkCreate(filteredObjects, { + const createSavedObjectsParams = { + objects: objectsToCreate, + accumulatedErrors: errorAccumulator, + savedObjectsClient, + importIdMap, overwrite, namespace, - }); - errorAccumulator = [ - ...errorAccumulator, - ...extractErrors(bulkCreateResult.saved_objects, filteredObjects), - ]; + }; + const createSavedObjectsResult = await createSavedObjects(createSavedObjectsParams); + errorAccumulator = [...errorAccumulator, ...createSavedObjectsResult.errors]; + + const successResults = createSavedObjectsResult.createdObjects.map( + ({ type, id, destinationId, originId }) => { + return { + type, + id, + ...(destinationId && { destinationId }), + ...(destinationId && !originId && !createNewCopies && { createNewCopy: 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..1bbc2693e4f49 --- /dev/null +++ b/src/core/server/saved_objects/import/regenerate_ids.test.ts @@ -0,0 +1,53 @@ +/* + * 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 SavedObject[]; + + test('returns expected values', () => { + mockUuidv4 + .mockReturnValueOnce('uuidv4 #1') + .mockReturnValueOnce('uuidv4 #2') + .mockReturnValueOnce('uuidv4 #3'); + expect(regenerateIds(objects)).toMatchInlineSnapshot(` + Map { + "foo:1" => Object { + "id": "uuidv4 #1", + "omitOriginId": true, + }, + "bar:2" => Object { + "id": "uuidv4 #2", + "omitOriginId": true, + }, + "baz:3" => Object { + "id": "uuidv4 #3", + "omitOriginId": true, + }, + } + `); + }); +}); 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..647386ed16469 --- /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: SavedObject[]) => { + 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..f5dba716ea348 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,445 @@ */ 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 { regenerateIds } from './regenerate_ids'; +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('./regenerate_ids'); +jest.mock('./validate_references'); +jest.mock('./check_conflicts'); +jest.mock('./check_origin_conflicts'); +jest.mock('./split_overwrites'); +jest.mock('./create_saved_objects'); + +const getMockFn = any, U>(fn: (...args: Parameters) => U) => + fn as jest.MockedFunction<(...args: Parameters) => U>; - test('works with empty parameters', async () => { - const readStream = new Readable({ - objectMode: true, - read() { - savedObjects.forEach((obj) => this.push(obj)); - this.push(null); - }, +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: [], + importIdMap: new Map(), }); - savedObjectsClient.bulkCreate.mockResolvedValue({ - saved_objects: [], + getMockFn(regenerateIds).mockReturnValue(new Map()); + getMockFn(validateReferences).mockResolvedValue({ errors: [], filteredObjects: [] }); + getMockFn(checkConflicts).mockResolvedValue({ + errors: [], + filteredObjects: [], + importIdMap: new Map(), }); - const result = await resolveSavedObjectsImportErrors({ - readStream, - objectLimit: 4, - retries: [], - savedObjectsClient, - supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'], + getMockFn(getImportIdMapForRetries).mockReturnValue(new Map()); + getMockFn(splitOverwrites).mockReturnValue({ + objectsToOverwrite: [], + objectsToNotOverwrite: [], }); - expect(result).toMatchInlineSnapshot(` - Object { - "success": true, - "successCount": 0, - } - `); - expect(savedObjectsClient.bulkCreate).toMatchInlineSnapshot(`[MockFunction]`); + getMockFn(createSavedObjects).mockResolvedValue({ errors: [], createdObjects: [] }); }); - 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'), - }); - const result = await resolveSavedObjectsImportErrors({ + let readStream: Readable; + const objectLimit = 10; + let savedObjectsClient: jest.Mocked; + let typeRegistry: jest.Mocked; + const namespace = 'some-namespace'; + + const setupOptions = ( + retries: SavedObjectsImportRetry[] = [], + createNewCopies: boolean = false + ): SavedObjectsResolveImportErrorsOptions => { + readStream = new Readable(); + savedObjectsClient = savedObjectsClientMock.create(); + typeRegistry = typeRegistryMock.create(); + return { readStream, - objectLimit: 4, - retries: [ - { - type: 'visualization', - id: '3', - replaceReferences: [], - overwrite: false, - }, - ], + 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 Visualization", - }, - "id": "3", - "migrationVersion": Object {}, - "references": Array [], - "type": "visualization", - }, - ], - Object { - "namespace": undefined, - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], - } - `); - }); + typeRegistry, + // namespace and createNewCopies don't matter, as they don't change the logic in this module, they just get passed to sub-module methods + namespace, + createNewCopies, + }; + }; - 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 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]); + + await resolveSavedObjectsImportErrors(options); + expect(validateRetries).toHaveBeenCalledWith([retry]); }); - const result = await resolveSavedObjectsImportErrors({ - readStream, - objectLimit: 4, - retries: [ - { - type: 'index-pattern', - id: '1', - overwrite: true, - replaceReferences: [], - }, - ], - savedObjectsClient, - supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'], + + test('creates objects filter', async () => { + const retry = createRetry(); + const options = setupOptions([retry]); + + await resolveSavedObjectsImportErrors(options); + expect(createObjectsFilter).toHaveBeenCalledWith([retry]); }); - 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 {}, - }, - ], - } - `); - }); - test('works wtih replaceReferences', async () => { - const readStream = new Readable({ - objectMode: true, - read() { - savedObjects.forEach((obj) => this.push(obj)); - this.push(null); - }, + 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); }); - savedObjectsClient.bulkCreate.mockResolvedValue({ - saved_objects: savedObjects.filter((obj) => obj.type === 'dashboard' && obj.id === '4'), + + test('validates references', async () => { + const options = setupOptions(); + const collectedObjects = [createObject()]; + getMockFn(collectSavedObjects).mockResolvedValue({ + errors: [], + collectedObjects, + importIdMap: new Map(), // doesn't matter + }); + + await resolveSavedObjectsImportErrors(options); + expect(validateReferences).toHaveBeenCalledWith( + collectedObjects, + savedObjectsClient, + namespace + ); }); - 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('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], + importIdMap: new Map(), // doesn't matter + }); + + await resolveSavedObjectsImportErrors(options); + const objectWithReplacedReferences = { + ...object, + references: [{ ...object.references[0], id: 'def' }], + }; + expect(validateReferences).toHaveBeenCalledWith( + [objectWithReplacedReferences], + savedObjectsClient, + namespace + ); }); - 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('checks conflicts', async () => { + const createNewCopies = (Symbol() as unknown) as boolean; + const options = setupOptions([], createNewCopies); + const filteredObjects = [createObject()]; + getMockFn(validateReferences).mockResolvedValue({ errors: [], filteredObjects }); + + await resolveSavedObjectsImportErrors(options); + const checkConflictsParams = { + objects: filteredObjects, + savedObjectsClient, + namespace, + ignoreRegularConflicts: true, + createNewCopies, + }; + expect(checkConflicts).toHaveBeenCalledWith(checkConflictsParams); }); - savedObjectsClient.bulkCreate.mockResolvedValue({ - saved_objects: savedObjects.map((savedObject) => ({ - type: savedObject.type, - id: savedObject.id, - error: { - statusCode: 409, - message: 'conflict', - }, - attributes: {}, - references: [], - })), + + test('gets import ID map for retries', async () => { + const retries = [createRetry()]; + const createNewCopies = (Symbol() as unknown) as boolean; + const options = setupOptions(retries, createNewCopies); + const filteredObjects = [createObject()]; + getMockFn(checkConflicts).mockResolvedValue({ + errors: [], + filteredObjects, + importIdMap: new Map(), + }); + + await resolveSavedObjectsImportErrors(options); + const getImportIdMapForRetriesParams = { objects: filteredObjects, retries, createNewCopies }; + expect(getImportIdMapForRetries).toHaveBeenCalledWith(getImportIdMapForRetriesParams); }); - 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('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(), + }); + + await resolveSavedObjectsImportErrors(options); + expect(splitOverwrites).toHaveBeenCalledWith(filteredObjects, retries); }); - 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 createNewCopies disabled', () => { + test('does not regenerate object IDs', async () => { + const options = setupOptions(); + const collectedObjects = [createObject()]; + getMockFn(collectSavedObjects).mockResolvedValue({ + errors: [], + collectedObjects, + importIdMap: new Map(), // doesn't matter }); - this.push({ - id: '3', - type: 'visualization', - attributes: { - title: 'My Visualization', - }, - references: [ - { - name: 'ref_0', - type: 'search', - id: '1', - }, - ], + + await resolveSavedObjectsImportErrors(options); + expect(regenerateIds).not.toHaveBeenCalled(); + }); + + test('creates saved objects', async () => { + const options = setupOptions(); + const errors = [createError(), createError(), createError()]; + getMockFn(collectSavedObjects).mockResolvedValue({ + errors: [errors[0]], + collectedObjects: [], // doesn't matter + importIdMap: new Map(), // 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(validateReferences).mockResolvedValue({ + errors: [errors[1]], + filteredObjects: [], // doesn't matter + }); + getMockFn(checkConflicts).mockResolvedValue({ + errors: [errors[2]], + filteredObjects: [], + importIdMap: new Map([ + ['foo', {}], + ['bar', {}], + ]), + }); + getMockFn(getImportIdMapForRetries).mockReturnValue(new Map([['bar', { id: 'newId' }]])); + const importIdMap = new Map([ + ['foo', {}], + ['bar', { id: 'newId' }], + ]); + 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 partialCreateSavedObjectsParams = { + accumulatedErrors: errors, + savedObjectsClient, + importIdMap, + namespace, + }; + expect(createSavedObjects).toHaveBeenNthCalledWith(1, { + ...partialCreateSavedObjectsParams, + objects: objectsToOverwrite, + overwrite: true, + }); + expect(createSavedObjects).toHaveBeenNthCalledWith(2, { + ...partialCreateSavedObjectsParams, + objects: objectsToNotOverwrite, + }); + }); }); - 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'], + + describe('with createNewCopies enabled', () => { + test('regenerates object IDs', async () => { + const options = setupOptions([], true); + const collectedObjects = [createObject()]; + getMockFn(collectSavedObjects).mockResolvedValue({ + errors: [], + collectedObjects, + importIdMap: new Map(), // doesn't matter + }); + + await resolveSavedObjectsImportErrors(options); + expect(regenerateIds).toHaveBeenCalledWith(collectedObjects); + }); + + test('creates saved objects', async () => { + const options = setupOptions([], true); + const errors = [createError(), createError(), createError()]; + getMockFn(collectSavedObjects).mockResolvedValue({ + errors: [errors[0]], + collectedObjects: [], // doesn't matter + importIdMap: new Map(), // doesn't matter + }); + getMockFn(validateReferences).mockResolvedValue({ + errors: [errors[1]], + filteredObjects: [], // doesn't matter + }); + getMockFn(regenerateIds).mockReturnValue( + new Map([ + ['foo', { id: 'randomId1' }], + ['bar', { id: 'randomId2' }], + ['baz', { id: 'randomId3' }], + ]) + ); + getMockFn(checkConflicts).mockResolvedValue({ + errors: [errors[2]], + filteredObjects: [], + importIdMap: new Map([ + ['bar', {}], + ['baz', {}], + ]), + }); + getMockFn(getImportIdMapForRetries).mockReturnValue(new Map([['baz', { id: 'newId' }]])); + const importIdMap = new Map([ + ['foo', { id: 'randomId1' }], + ['bar', {}], + ['baz', { id: 'newId' }], + ]); + 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 partialCreateSavedObjectsParams = { + accumulatedErrors: errors, + savedObjectsClient, + importIdMap, + namespace, + }; + expect(createSavedObjects).toHaveBeenNthCalledWith(1, { + ...partialCreateSavedObjectsParams, + objects: objectsToOverwrite, + overwrite: true, + }); + expect(createSavedObjects).toHaveBeenNthCalledWith(2, { + ...partialCreateSavedObjectsParams, + objects: objectsToNotOverwrite, + }); + }); }); - 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: [], + importIdMap: new Map(), // doesn't matter + }); + + 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 new 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, createNewCopy: 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: [], + importIdMap: new Map(), // doesn't matter + }); + 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..7dc45a929fbe4 100644 --- a/src/core/server/saved_objects/import/resolve_import_errors.ts +++ b/src/core/server/saved_objects/import/resolve_import_errors.ts @@ -18,14 +18,19 @@ */ import { collectSavedObjects } from './collect_saved_objects'; import { createObjectsFilter } from './create_objects_filter'; -import { extractErrors } from './extract_errors'; import { splitOverwrites } from './split_overwrites'; import { SavedObjectsImportError, SavedObjectsImportResponse, SavedObjectsResolveImportErrorsOptions, } from './types'; +import { regenerateIds } from './regenerate_ids'; 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 +43,17 @@ export async function resolveSavedObjectsImportErrors({ objectLimit, retries, savedObjectsClient, - supportedTypes, + typeRegistry, namespace, + createNewCopies, }: 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 +92,78 @@ 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]; - // 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), - ]; - successCount += bulkCreateResult.saved_objects.filter((obj) => !obj.error).length; + if (createNewCopies) { + // In case any missing reference errors were resolved, ensure that we regenerate those object IDs as well + // This is because a retry to resolve a missing reference error may not necessarily specify a destinationId + importIdMap = regenerateIds(objectsToResolve); } - if (objectsToNotOverwrite.length) { - const bulkCreateResult = await savedObjectsClient.bulkCreate(objectsToNotOverwrite, { + + // Check single-namespace objects for conflicts in this namespace, and check multi-namespace objects for conflicts across all namespaces + const checkConflictsParams = { + objects: validateReferencesResult.filteredObjects, + savedObjectsClient, + namespace, + ignoreRegularConflicts: true, + createNewCopies, + }; + const checkConflictsResult = await checkConflicts(checkConflictsParams); + errorAccumulator = [...errorAccumulator, ...checkConflictsResult.errors]; + importIdMap = new Map([...importIdMap, ...checkConflictsResult.importIdMap]); + + // Check multi-namespace object types for regular conflicts and ambiguous conflicts + const getImportIdMapForRetriesParams = { + objects: checkConflictsResult.filteredObjects, + retries, + createNewCopies, + }; + const importIdMapForRetries = getImportIdMapForRetries(getImportIdMapForRetriesParams); + importIdMap = new Map([...importIdMap, ...importIdMapForRetries]); + + // Bulk create in two batches, overwrites and non-overwrites + let successResults: Array<{ type: string; id: string; destinationId?: string }> = []; + const accumulatedErrors = [...errorAccumulator]; + const bulkCreateObjects = async (objects: Array>, overwrite?: boolean) => { + const createSavedObjectsParams = { + objects, + accumulatedErrors, + savedObjectsClient, + importIdMap, namespace, - }); - errorAccumulator = [ - ...errorAccumulator, - ...extractErrors(bulkCreateResult.saved_objects, objectsToNotOverwrite), + overwrite, + }; + const { createdObjects, errors: bulkCreateErrors } = await createSavedObjects( + createSavedObjectsParams + ); + errorAccumulator = [...errorAccumulator, ...bulkCreateErrors]; + successCount += createdObjects.length; + successResults = [ + ...successResults, + ...createdObjects.map(({ type, id, destinationId, originId }) => ({ + type, + id, + ...(destinationId && { destinationId }), + ...(destinationId && !originId && !createNewCopies && { createNewCopy: true }), + })), ]; - 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..e0abe23a70372 100644 --- a/src/core/server/saved_objects/import/types.ts +++ b/src/core/server/saved_objects/import/types.ts @@ -18,7 +18,8 @@ */ import { Readable } from 'stream'; -import { SavedObjectsClientContract } from '../types'; +import { SavedObjectsClientContract, SavedObject } from '../types'; +import { ISavedObjectTypeRegistry } from '..'; /** * Describes a retry operation for importing a saved object. @@ -28,11 +29,20 @@ 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; }>; + /** + * If `createNewCopy` is specified, the new object has a new (undefined) origin ID. This is only needed for the case where + * `createNewCopies` mode is disabled and ambiguous source conflicts are detected. + */ + createNewCopy?: boolean; } /** @@ -41,6 +51,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 +107,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 `createNewCopy` is specified, the new object has a new (undefined) origin ID. This is only needed for the case where + * `createNewCopies` mode is disabled and ambiguous source conflicts are detected. When `createNewCopies` mode is permanently enabled, + * this field will be redundant and can be removed. + */ + createNewCopy?: boolean; +} + /** * The response describing the result of an import. * @public @@ -99,6 +140,7 @@ export interface SavedObjectsImportError { export interface SavedObjectsImportResponse { success: boolean; successCount: number; + successResults?: SavedObjectsImportSuccess[]; errors?: SavedObjectsImportError[]; } @@ -111,14 +153,16 @@ export interface SavedObjectsImportOptions { readStream: Readable; /** The maximum number of object to import */ objectLimit: number; - /** if true, will override existing object if present */ + /** If true, will override existing object if present. Note: this has no effect when used with the `createNewCopies` 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; + /** If true, will create new copies of import objects, each with a random `id` and undefined `originId`. */ + createNewCopies: boolean; } /** @@ -132,10 +176,14 @@ 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; + /** If true, will create new copies of import objects, each with a random `id` and undefined `originId`. */ + createNewCopies: boolean; } + +export type CreatedObject = SavedObject & { destinationId?: string }; 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..fd3c1e9795f9f --- /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 './get_non_unique_entries'; +jest.mock('./get_non_unique_entries'); +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..f625436edb636 --- /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 './get_non_unique_entries'; +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 f8b203bf66d6a..691ad8dfa84f4 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: { @@ -240,6 +244,7 @@ describe('IndexMigrator', () => { migrationVersion: '4a1746014a75ade3a714e1db5763276f', namespace: '2f4316de49999235636386fe51dc06c1', namespaces: '2f4316de49999235636386fe51dc06c1', + originId: '2f4316de49999235636386fe51dc06c1', references: '7997cf5a56cc02bdc9c93361bde732b0', type: '2f4316de49999235636386fe51dc06c1', updated_at: '00da57df13e94e9d98437d13ace4bfe0', @@ -251,6 +256,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..4fac8fede0cd9 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 }), + createNewCopies: schema.boolean({ defaultValue: false }), + }, + { + validate: (object) => { + if (object.overwrite && object.createNewCopies) { + return 'cannot use [overwrite] with [createNewCopies]'; + } + }, + } + ), body: schema.object({ file: schema.stream(), }), }, }, router.handleLegacyErrors(async (context, req, res) => { - const { overwrite } = req.query; + const { overwrite, createNewCopies } = 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, + createNewCopies, }); 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..bf3731f4a7d52 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.any(Object) // 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.any(Object) // options + ); + expect(savedObjectsClient.bulkCreate).not.toHaveBeenCalled(); // no objects were created + }); + + describe('createNewCopies 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}?createNewCopies=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.any(Object) // 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..42f28eb468b52 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 @@ -17,34 +17,56 @@ * under the License. */ +import { mockUuidv4 } from '../../import/__mocks__'; import supertest from 'supertest'; import { UnwrapPromise } from '@kbn/utility-types'; 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 () => { + mockUuidv4.mockReset(); + mockUuidv4.mockImplementation(() => jest.requireActual('uuidv4')); ({ server, httpSetup, handlerContext } = await setupServer()); handlerContext.savedObjects.typeRegistry.getImportableAndExportableTypes.mockReturnValue( allowedTypes.map(createExportableType) ); savedObjectsClient = handlerContext.savedObjects.client; + savedObjectsClient.checkConflicts.mockResolvedValue({ errors: [] }); const router = httpSetup.createRouter('/api/saved_objects/'); registerResolveImportErrorsRoute(router, config); @@ -58,7 +80,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 +99,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 +124,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.any(Object) // 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 +156,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 +189,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 +226,81 @@ 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.any(Object) // options + ); + }); + + describe('createNewCopies enabled', () => { + it('imports objects, regenerating all IDs/reference IDs present, and resetting all origin IDs', async () => { + mockUuidv4.mockReturnValue('new-id-1'); + 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}?createNewCopies=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","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.any(Object) // 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..bdafdaaaf019a 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({ + createNewCopies: 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: [] } ), + createNewCopy: 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, + createNewCopies: req.query.createNewCopies, }); 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 d563edbe66c9b..87265910a3ba5 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) => [ @@ -504,9 +512,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); @@ -555,7 +563,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 () => { @@ -625,7 +633,7 @@ describe('SavedObjectsRepository', () => { { ...obj2, type: MULTI_NAMESPACE_TYPE }, ]; await bulkCreateSuccess(objects, { namespace }); - expectClusterCallArgsAction(objects, { method: 'create', getId }); + expectClusterCallArgsAction(objects, { method: 'create', getId }, 2); }); }); @@ -693,8 +701,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)], }); }); @@ -862,6 +871,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', @@ -997,6 +1007,7 @@ describe('SavedObjectsRepository', () => { type, id, namespaces: doc._source.namespaces ?? ['default'], + ...(doc._source.originId && { originId: doc._source.originId }), ...(doc._source.updated_at && { updated_at: doc._source.updated_at }), version: encodeHitVersion(doc), attributes: doc._source[type], @@ -1063,27 +1074,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); @@ -1361,9 +1380,10 @@ describe('SavedObjectsRepository', () => { }); describe('returns', () => { - const expectSuccessResult = ({ type, id, attributes, references, namespaces }) => ({ + const expectSuccessResult = ({ type, id, attributes, references, namespaces, originId }) => ({ type, id, + originId, attributes, references, version: mockVersion, @@ -1411,6 +1431,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 + ], + }); + }); }); }); @@ -1426,6 +1578,7 @@ describe('SavedObjectsRepository', () => { const attributes = { title: 'Logstash' }; const id = 'logstash-*'; const namespace = 'foo-namespace'; + const originId = 'some-origin-id'; const references = [ { name: 'ref_0', @@ -1502,6 +1655,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' }); @@ -1655,10 +1822,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, @@ -1940,6 +2113,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': { @@ -2046,6 +2220,7 @@ describe('SavedObjectsRepository', () => { 'references', 'migrationVersion', 'updated_at', + 'originId', 'title', ], }); @@ -2142,6 +2317,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, @@ -2166,6 +2342,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, @@ -2295,9 +2472,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); @@ -2406,6 +2591,11 @@ describe('SavedObjectsRepository', () => { namespaces: ['default'], }); }); + + it(`includes originId property if present in cluster call response`, async () => { + const result = await getSuccess(type, id, {}, true); + expect(result).toMatchObject({ originId }); + }); }); }); @@ -2414,6 +2604,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); @@ -2585,6 +2776,7 @@ describe('SavedObjectsRepository', () => { buildNum: 8468, defaultIndex: 'logstash-*', }, + originId, }, }, })); @@ -2607,6 +2799,7 @@ describe('SavedObjectsRepository', () => { buildNum: 8468, defaultIndex: 'logstash-*', }, + originId, }); }); }); @@ -2914,8 +3107,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', ...) @@ -2926,7 +3120,14 @@ describe('SavedObjectsRepository', () => { result: 'updated', // don't need the rest of the source for test purposes, just the namespace and namespaces attributes get: { - _source: { namespaces: [options?.namespace ?? 'default'], namespace: options?.namespace }, + _source: { + namespaces: [options?.namespace ?? 'default'], + 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 }), + }, }, }); // this._writeToCluster('update', ...) const result = await savedObjectsRepository.update(type, id, attributes, options); @@ -3027,7 +3228,7 @@ describe('SavedObjectsRepository', () => { it(`includes _sourceIncludes when type is multi-namespace`, async () => { await updateSuccess(MULTI_NAMESPACE_TYPE, id, attributes); - expectClusterCallArgs({ _sourceIncludes: ['namespace', 'namespaces'] }, 2); + expectClusterCallArgs({ _sourceIncludes: ['namespace', 'namespaces', 'originId'] }, 2); }); it(`includes _sourceIncludes when type is not multi-namespace`, async () => { @@ -3035,7 +3236,7 @@ describe('SavedObjectsRepository', () => { expect(callAdminCluster).toHaveBeenLastCalledWith( expect.any(String), expect.objectContaining({ - _sourceIncludes: ['namespace', 'namespaces'], + _sourceIncludes: ['namespace', 'namespaces', 'originId'], }) ); }); @@ -3126,6 +3327,11 @@ describe('SavedObjectsRepository', () => { namespaces: ['default'], }); }); + + 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 7a5ac9204627c..cdfa79d6ab972 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -26,7 +26,7 @@ import { getRootPropertiesObjects, IndexMapping } from '../../mappings'; import { getSearchDsl } from './search_dsl'; import { includedFields } from './included_fields'; import { decorateEsError } from './decorate_es_error'; -import { SavedObjectsErrorHelpers } from './errors'; +import { SavedObjectsErrorHelpers, DecoratedError } from './errors'; import { decodeRequestVersion, encodeVersion, encodeHitVersion } from '../../version'; import { KibanaMigrator } from '../../migrations'; import { @@ -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, @@ -292,14 +296,13 @@ export class SavedObjectsRepository { error: { id: object.id, type: object.type, - error: SavedObjectsErrorHelpers.createUnsupportedTypeError(object.type).output.payload, + error: errorContent(SavedObjectsErrorHelpers.createUnsupportedTypeError(object.type)), }, }; } 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: { + ...errorContent(SavedObjectsErrorHelpers.createConflictError(type, id)), + 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: errorContent(SavedObjectsErrorHelpers.createUnsupportedTypeError(type)), + }, + }; + } + + 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: { + ...errorContent(SavedObjectsErrorHelpers.createConflictError(type, id)), + ...(!this.rawDocExistsInNamespace(doc, namespace) && { + metadata: { isNotOverwritable: true }, + }), + }, + }); + } + }); + + return { errors }; + } + /** * Deletes an object * @@ -584,6 +668,7 @@ export class SavedObjectsRepository { search, defaultSearchOperator = 'OR', searchFields, + rootSearchFields, hasReference, page = 1, perPage = 20, @@ -648,6 +733,7 @@ export class SavedObjectsRepository { search, defaultSearchOperator, searchFields, + rootSearchFields, type: allowedTypes, sortField, sortOrder, @@ -718,7 +804,7 @@ export class SavedObjectsRepository { error: { id, type, - error: SavedObjectsErrorHelpers.createUnsupportedTypeError(type).output.payload, + error: errorContent(SavedObjectsErrorHelpers.createUnsupportedTypeError(type)), }, }; } @@ -763,12 +849,11 @@ export class SavedObjectsRepository { return ({ id, type, - error: SavedObjectsErrorHelpers.createGenericNotFoundError(type, id).output.payload, + error: errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id)), } as any) as SavedObject; } - const time = doc._source.updated_at; - + const { originId, updated_at: updatedAt } = doc._source; let namespaces = []; if (!this._registry.isNamespaceAgnostic(type)) { namespaces = doc._source.namespaces ?? [getNamespaceString(doc._source.namespace)]; @@ -778,7 +863,8 @@ export class SavedObjectsRepository { id, type, namespaces, - ...(time && { updated_at: time }), + ...(originId && { originId }), + ...(updatedAt && { updated_at: updatedAt }), version: encodeHitVersion(doc), attributes: doc._source[type], references: doc._source.references || [], @@ -821,7 +907,7 @@ export class SavedObjectsRepository { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } - const { updated_at: updatedAt } = response._source; + const { originId, updated_at: updatedAt } = response._source; let namespaces = []; if (!this._registry.isNamespaceAgnostic(type)) { @@ -832,6 +918,7 @@ export class SavedObjectsRepository { id, type, namespaces, + ...(originId && { originId }), ...(updatedAt && { updated_at: updatedAt }), version: encodeHitVersion(response), attributes: response._source[type], @@ -885,7 +972,7 @@ export class SavedObjectsRepository { body: { doc, }, - _sourceIncludes: ['namespace', 'namespaces'], + _sourceIncludes: ['namespace', 'namespaces', 'originId'], }); if (updateResponse.status === 404) { @@ -893,6 +980,7 @@ export class SavedObjectsRepository { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } + const { originId } = updateResponse.get._source; let namespaces = []; if (!this._registry.isNamespaceAgnostic(type)) { namespaces = updateResponse.get._source.namespaces ?? [ @@ -906,6 +994,7 @@ export class SavedObjectsRepository { updated_at: time, version: encodeHitVersion(updateResponse), namespaces, + ...(originId && { originId }), references, attributes, }; @@ -1089,7 +1178,7 @@ export class SavedObjectsRepository { error: { id, type, - error: SavedObjectsErrorHelpers.createGenericNotFoundError(type, id).output.payload, + error: errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id)), }, }; } @@ -1154,7 +1243,7 @@ export class SavedObjectsRepository { error: { id, type, - error: SavedObjectsErrorHelpers.createGenericNotFoundError(type, id).output.payload, + error: errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id)), }, }; } @@ -1197,6 +1286,7 @@ export class SavedObjectsRepository { ? await this._writeToCluster('bulk', { refresh, body: bulkUpdateParams, + _sourceIncludes: ['originId'], }) : {}; @@ -1208,7 +1298,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; @@ -1220,10 +1312,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, @@ -1248,7 +1343,7 @@ export class SavedObjectsRepository { id: string, counterFieldName: string, options: SavedObjectsIncrementCounterOptions = {} - ) { + ): Promise { if (typeof type !== 'string') { throw new Error('"type" argument must be a string'); } @@ -1311,9 +1406,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), @@ -1459,9 +1557,9 @@ export class SavedObjectsRepository { function getBulkOperationError(error: { type: string; reason?: string }, type: string, id: string) { switch (error.type) { case 'version_conflict_engine_exception': - return SavedObjectsErrorHelpers.createConflictError(type, id).output.payload; + return errorContent(SavedObjectsErrorHelpers.createConflictError(type, id)); case 'document_missing_exception': - return SavedObjectsErrorHelpers.createGenericNotFoundError(type, id).output.payload; + return errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id)); default: return { message: error.reason || JSON.stringify(error), @@ -1513,4 +1611,9 @@ function getSavedObjectNamespaces( return [getNamespaceString(namespace)]; } +/** + * Extracts the contents of a decorated error to return the attributes for bulk operations. + */ +const errorContent = (error: DecoratedError) => error.output.payload; + const unique = (array: string[]) => [...new Set(array)]; 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 f916638c5251b..85c47029e36d5 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 @@ -309,13 +309,19 @@ describe('#getQueryParams', () => { }); }); - describe('`searchFields` parameter', () => { + describe('`searchFields` and `rootSearchFields` 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, + rootSearchFields, + }: { + searchFields?: string[]; + rootSearchFields?: string[]; + }) => { for (const typeOrTypes of ALL_TYPE_SUBSETS) { const result = getQueryParams({ mappings, @@ -323,8 +329,12 @@ describe('#getQueryParams', () => { type: typeOrTypes, search, searchFields, + rootSearchFields, }); - const fields = getExpectedFields(searchFields, typeOrTypes); + let fields = rootSearchFields || []; + if (searchFields) { + fields = fields.concat(getExpectedFields(searchFields, typeOrTypes)); + } expectResult(result, expect.objectContaining({ fields })); } // also test with no specified type/s @@ -334,31 +344,63 @@ describe('#getQueryParams', () => { type: undefined, search, searchFields, + rootSearchFields, }); - const fields = getExpectedFields(searchFields, ALL_TYPES); + let fields = rootSearchFields || []; + 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, + rootSearchFields: ['foo', 'bar.baz'], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"rootSearchFields entry \\"bar.baz\\" is invalid: cannot contain \\".\\" character"` + ); + }); + + it('includes lenient flag and all fields when `searchFields` and `rootSearchFields` are not specified', () => { const result = getQueryParams({ mappings, registry, search, searchFields: undefined, + rootSearchFields: 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({ rootSearchFields: ['_id'] }); + }); + + it('supports multiple raw search fields', () => { + test({ rootSearchFields: ['_id', 'originId'] }); }); - it('supports multiple fields', () => { - test(['title, title.raw']); + it('supports search fields and raw search fields', () => { + test({ searchFields: ['title'], rootSearchFields: ['_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 164756f9796a5..ad1a08187dc32 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 rootSearchFields */ -function getFieldsForTypes(types: string[], searchFields?: string[]) { - if (!searchFields || !searchFields.length) { +function getFieldsForTypes( + types: string[], + searchFields: string[] = [], + rootSearchFields: string[] = [] +) { + if (!searchFields.length && !rootSearchFields.length) { return { lenient: true, fields: ['*'], }; } - let fields: string[] = []; + let fields = [...rootSearchFields]; + fields.forEach((field) => { + if (field.indexOf('.') !== -1) { + throw new Error(`rootSearchFields entry "${field}" is invalid: cannot contain "." character`); + } + }); + for (const field of searchFields) { fields = fields.concat(types.map((prefix) => `${prefix}.${field}`)); } @@ -119,6 +129,7 @@ interface QueryParams { type?: string | string[]; search?: string; searchFields?: string[]; + rootSearchFields?: string[]; defaultSearchOperator?: string; hasReference?: HasReferenceQueryParams; kueryNode?: KueryNode; @@ -134,6 +145,7 @@ export function getQueryParams({ type, search, searchFields, + rootSearchFields, defaultSearchOperator, hasReference, kueryNode, @@ -199,7 +211,7 @@ export function getQueryParams({ { simple_query_string: { query: search, - ...getFieldsForTypes(types, searchFields), + ...getFieldsForTypes(types, searchFields, rootSearchFields), ...(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 08ad72397e4a2..62e629ad33cc8 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, namespaces, type, search, searchFields, hasReference) to getQueryParams', () => { + it('passes (mappings, schema, namespaces, type, search, searchFields, rootSearchFields, hasReference) to getQueryParams', () => { const opts = { namespaces: ['foo-namespace'], type: 'foo', search: 'bar', searchFields: ['baz'], + rootSearchFields: ['qux'], defaultSearchOperator: 'AND', hasReference: { type: 'bar', @@ -79,6 +80,7 @@ describe('getSearchDsl', () => { type: opts.type, search: opts.search, searchFields: opts.searchFields, + rootSearchFields: opts.rootSearchFields, 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 6de868c320240..ddf20606800c8 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[]; + rootSearchFields?: string[]; sortField?: string; sortOrder?: string; namespaces?: string[]; @@ -51,6 +52,7 @@ export function getSearchDsl( search, defaultSearchOperator, searchFields, + rootSearchFields, sortField, sortOrder, namespaces, @@ -74,6 +76,7 @@ export function getSearchDsl( type, search, searchFields, + rootSearchFields, 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 f9301d6598b1d..7b3697173ef0d 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,11 @@ export interface SavedObjectsFindOptions { 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 root fields and will not + * be modified. If used in conjunction with `searchFields`, both are concatenated together. + */ + rootSearchFields?: 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 4b6bcbc8ad7a0..cb651043844c2 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -885,7 +885,7 @@ export interface ImageValidation { } // @public -export function importSavedObjectsFromStream({ readStream, objectLimit, overwrite, savedObjectsClient, supportedTypes, namespace, }: SavedObjectsImportOptions): Promise; +export function importSavedObjectsFromStream({ readStream, objectLimit, overwrite, createNewCopies, savedObjectsClient, typeRegistry, namespace, }: SavedObjectsImportOptions): Promise; // @public (undocumented) export interface IndexSettingsDeprecationInfo { @@ -1764,7 +1764,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, createNewCopies, }: SavedObjectsResolveImportErrorsOptions): Promise; // @public export type ResponseError = string | Error | { @@ -1866,14 +1866,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; @@ -1940,6 +1940,7 @@ export interface SavedObjectsBulkCreateObject { // (undocumented) id?: string; migrationVersion?: SavedObjectsMigrationVersion; + originId?: string; // (undocumented) references?: SavedObjectReference[]; // (undocumented) @@ -1985,6 +1986,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 @@ -1993,6 +2012,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<{}>; @@ -2074,6 +2094,7 @@ export interface SavedObjectsCoreFieldMapping { export interface SavedObjectsCreateOptions extends SavedObjectsBaseOptions { id?: string; migrationVersion?: SavedObjectsMigrationVersion; + originId?: string; overwrite?: boolean; // (undocumented) references?: SavedObjectReference[]; @@ -2194,6 +2215,7 @@ export interface SavedObjectsFindOptions { // (undocumented) perPage?: number; preference?: string; + rootSearchFields?: string[]; search?: string; searchFields?: string[]; // (undocumented) @@ -2221,8 +2243,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'; } @@ -2230,7 +2266,7 @@ export interface SavedObjectsImportConflictError { // @public export interface SavedObjectsImportError { // (undocumented) - error: SavedObjectsImportConflictError | SavedObjectsImportUnsupportedTypeError | SavedObjectsImportMissingReferencesError | SavedObjectsImportUnknownError; + error: SavedObjectsImportConflictError | SavedObjectsImportAmbiguousConflictError | SavedObjectsImportUnsupportedTypeError | SavedObjectsImportMissingReferencesError | SavedObjectsImportUnknownError; // (undocumented) id: string; // (undocumented) @@ -2257,12 +2293,13 @@ export interface SavedObjectsImportMissingReferencesError { // @public export interface SavedObjectsImportOptions { + createNewCopies: boolean; namespace?: string; objectLimit: number; overwrite: boolean; readStream: Readable; savedObjectsClient: SavedObjectsClientContract; - supportedTypes: string[]; + typeRegistry: ISavedObjectTypeRegistry; } // @public @@ -2273,10 +2310,14 @@ export interface SavedObjectsImportResponse { success: boolean; // (undocumented) successCount: number; + // (undocumented) + successResults?: SavedObjectsImportSuccess[]; } // @public export interface SavedObjectsImportRetry { + createNewCopy?: boolean; + destinationId?: string; // (undocumented) id: string; // (undocumented) @@ -2291,6 +2332,17 @@ export interface SavedObjectsImportRetry { type: string; } +// @public +export interface SavedObjectsImportSuccess { + // @deprecated (undocumented) + createNewCopy?: boolean; + destinationId?: string; + // (undocumented) + id: string; + // (undocumented) + type: string; +} + // @public export interface SavedObjectsImportUnknownError { // (undocumented) @@ -2392,6 +2444,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 // @@ -2401,16 +2454,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, namespaces, type, filter, preference, }: SavedObjectsFindOptions): Promise>; + find({ search, defaultSearchOperator, searchFields, rootSearchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespaces, 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>; } @@ -2422,12 +2468,13 @@ export interface SavedObjectsRepositoryFactory { // @public export interface SavedObjectsResolveImportErrorsOptions { + createNewCopies: boolean; namespace?: string; objectLimit: number; readStream: Readable; retries: SavedObjectsImportRetry[]; savedObjectsClient: SavedObjectsClientContract; - supportedTypes: string[]; + 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 ed259ccec0114..d9d9dc6dd86fa 100644 --- a/test/api_integration/apis/saved_objects/migrations.js +++ b/test/api_integration/apis/saved_objects/migrations.js @@ -293,13 +293,10 @@ export default ({ getService }) => { ); // It only created the original and the dest - assert.deepEqual( - _.map( - 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 5d4ea5a6370e4..f8d66b8ecac27 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 3246457179f68..a2725cbc6a274 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, @@ -48,6 +49,13 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon public readonly errors = options.baseClient.errors ) {} + 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/kibana/assets/install.ts b/x-pack/plugins/ingest_manager/server/services/epm/kibana/assets/install.ts index a3fe444b19b1a..787171df3f233 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/kibana/assets/install.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/kibana/assets/install.ts @@ -20,7 +20,9 @@ import { import { deleteKibanaSavedObjectsAssets } from '../../packages/remove'; import { getInstallationObject, savedObjectTypes } from '../../packages'; -type SavedObjectToBe = Required & { type: AssetType }; +type SavedObjectToBe = Required> & { + type: AssetType; +}; export type ArchiveAsset = Pick< SavedObject, 'id' | 'attributes' | 'migrationVersion' | 'references' 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 1cf879adc5415..48ced20a87947 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 @@ -62,7 +62,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 ); @@ -87,7 +87,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, @@ -96,7 +96,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'] @@ -109,7 +109,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 @@ -477,6 +477,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 621299a0f025e..e9941cf77cc28 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..d49dfa2015dc6 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 @@ -3,14 +3,20 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import { Readable } from 'stream'; import { SavedObjectsImportResponse, SavedObjectsImportOptions, SavedObjectsExportOptions, + SavedObjectsImportSuccess, } from 'src/core/server'; +import { + coreMock, + httpServerMock, + savedObjectsTypeRegistryMock, + savedObjectsClientMock, +} from 'src/core/server/mocks'; import { copySavedObjectsToSpacesFactory } from './copy_to_spaces'; -import { Readable } from 'stream'; -import { coreMock, savedObjectsTypeRegistryMock, httpServerMock } from 'src/core/server/mocks'; jest.mock('../../../../../../src/core/server', () => { return { @@ -31,6 +37,8 @@ interface SetupOpts { ) => Promise; } +const EXPORT_LIMIT = 1000; + const expectStreamToContainObjects = async ( stream: Readable, expectedObjects: SetupOpts['objects'] @@ -50,23 +58,29 @@ const expectStreamToContainObjects = async ( }; describe('copySavedObjectsToSpaces', () => { + const mockExportResults = [ + { type: 'dashboard', id: 'my-dashboard', attributes: {} }, + { type: 'visualization', id: 'my-viz', attributes: {} }, + { type: 'index-pattern', id: 'my-index-pattern', attributes: {} }, + { type: 'globaltype', id: 'my-globaltype', attributes: {} }, + ]; + const setup = (setupOpts: SetupOpts) => { const coreStart = coreMock.createStart(); + const savedObjectsClient = savedObjectsClientMock.create(); const typeRegistry = savedObjectsTypeRegistryMock.create(); - typeRegistry.getAllTypes.mockReturnValue([ + coreStart.savedObjects.getScopedClient.mockReturnValue(savedObjectsClient); + coreStart.savedObjects.getTypeRegistry.mockReturnValue(typeRegistry); + + typeRegistry.getImportableAndExportableTypes.mockReturnValue([ + // don't need to include all types, just need a positive case (agnostic) and a negative case (non-agnostic) { name: 'dashboard', namespaceType: 'single', hidden: false, mappings: { properties: {} }, }, - { - name: 'visualization', - namespaceType: 'single', - hidden: false, - mappings: { properties: {} }, - }, { name: 'globaltype', namespaceType: 'agnostic', @@ -74,13 +88,12 @@ describe('copySavedObjectsToSpaces', () => { mappings: { properties: {} }, }, ]); - typeRegistry.isNamespaceAgnostic.mockImplementation((type: string) => - typeRegistry.getAllTypes().some((t) => t.name === type && t.namespaceType === 'agnostic') + typeRegistry + .getImportableAndExportableTypes() + .some((t) => t.name === type && t.namespaceType === 'agnostic') ); - coreStart.savedObjects.getTypeRegistry.mockReturnValue(typeRegistry); - (exportSavedObjectsToStream as jest.Mock).mockImplementation( async (opts: SavedObjectsExportOptions) => { return ( @@ -100,10 +113,15 @@ describe('copySavedObjectsToSpaces', () => { (importSavedObjectsFromStream as jest.Mock).mockImplementation( async (opts: SavedObjectsImportOptions) => { const defaultImpl = async () => { - await expectStreamToContainObjects(opts.readStream, setupOpts.objects); + // namespace-agnostic types should be filtered out before import + const filteredObjects = setupOpts.objects.filter(({ type }) => type !== 'globaltype'); + await expectStreamToContainObjects(opts.readStream, filteredObjects); const response: SavedObjectsImportResponse = { success: true, - successCount: setupOpts.objects.length, + successCount: filteredObjects.length, + successResults: [ + ('Some success(es) occurred!' as unknown) as SavedObjectsImportSuccess, + ], }; return Promise.resolve(response); @@ -115,261 +133,95 @@ describe('copySavedObjectsToSpaces', () => { return { savedObjects: coreStart.savedObjects, + savedObjectsClient, + typeRegistry, }; }; it('uses the Saved Objects Service to perform an export followed by a series of imports', async () => { - const { savedObjects } = setup({ - objects: [ - { - type: 'dashboard', - id: 'my-dashboard', - attributes: {}, - }, - { - type: 'visualization', - id: 'my-viz', - attributes: {}, - }, - { - type: 'index-pattern', - id: 'my-index-pattern', - attributes: {}, - }, - ], + const { savedObjects, savedObjectsClient, typeRegistry } = setup({ + objects: mockExportResults, }); const request = httpServerMock.createKibanaRequest(); const copySavedObjectsToSpaces = copySavedObjectsToSpacesFactory( savedObjects, - () => 1000, + () => EXPORT_LIMIT, request ); - const result = await copySavedObjectsToSpaces('sourceSpace', ['destination1', 'destination2'], { + const namespace = 'sourceSpace'; + const objects = [{ type: 'dashboard', id: 'my-dashboard' }]; + const result = await copySavedObjectsToSpaces(namespace, ['destination1', 'destination2'], { includeReferences: true, overwrite: true, - objects: [ - { - type: 'dashboard', - id: 'my-dashboard', - }, - ], + objects, + createNewCopies: false, }); expect(result).toMatchInlineSnapshot(` - Object { - "destination1": Object { - "errors": undefined, - "success": true, - "successCount": 3, - }, - "destination2": Object { - "errors": undefined, - "success": true, - "successCount": 3, - }, - } - `); - - expect((exportSavedObjectsToStream as jest.Mock).mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - Object { - "excludeExportDetails": true, - "exportSizeLimit": 1000, - "includeReferencesDeep": true, - "namespace": "sourceSpace", - "objects": Array [ - Object { - "id": "my-dashboard", - "type": "dashboard", - }, - ], - "savedObjectsClient": Object { - "addToNamespaces": [MockFunction], - "bulkCreate": [MockFunction], - "bulkGet": [MockFunction], - "bulkUpdate": [MockFunction], - "create": [MockFunction], - "delete": [MockFunction], - "deleteFromNamespaces": [MockFunction], - "errors": [Function], - "find": [MockFunction], - "get": [MockFunction], - "update": [MockFunction], - }, - }, - ], - ] + Object { + "destination1": Object { + "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!", + ], + }, + } `); - expect((importSavedObjectsFromStream as jest.Mock).mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - Object { - "namespace": "destination1", - "objectLimit": 1000, - "overwrite": true, - "readStream": Readable { - "_events": Object { - "data": [Function], - "end": [Function], - "error": [Function], - }, - "_eventsCount": 3, - "_maxListeners": undefined, - "_read": [Function], - "_readableState": ReadableState { - "autoDestroy": false, - "awaitDrain": 0, - "buffer": BufferList { - "head": null, - "length": 0, - "tail": null, - }, - "decoder": null, - "defaultEncoding": "utf8", - "destroyed": false, - "emitClose": true, - "emittedReadable": false, - "encoding": null, - "endEmitted": true, - "ended": true, - "flowing": true, - "highWaterMark": 16, - "length": 0, - "needReadable": false, - "objectMode": true, - "paused": false, - "pipes": null, - "pipesCount": 0, - "readableListening": false, - "reading": false, - "readingMore": false, - "resumeScheduled": false, - "sync": false, - }, - "readable": false, - }, - "savedObjectsClient": Object { - "addToNamespaces": [MockFunction], - "bulkCreate": [MockFunction], - "bulkGet": [MockFunction], - "bulkUpdate": [MockFunction], - "create": [MockFunction], - "delete": [MockFunction], - "deleteFromNamespaces": [MockFunction], - "errors": [Function], - "find": [MockFunction], - "get": [MockFunction], - "update": [MockFunction], - }, - "supportedTypes": Array [ - "dashboard", - "visualization", - ], - }, - ], - Array [ - Object { - "namespace": "destination2", - "objectLimit": 1000, - "overwrite": true, - "readStream": Readable { - "_events": Object { - "data": [Function], - "end": [Function], - "error": [Function], - }, - "_eventsCount": 3, - "_maxListeners": undefined, - "_read": [Function], - "_readableState": ReadableState { - "autoDestroy": false, - "awaitDrain": 0, - "buffer": BufferList { - "head": null, - "length": 0, - "tail": null, - }, - "decoder": null, - "defaultEncoding": "utf8", - "destroyed": false, - "emitClose": true, - "emittedReadable": false, - "encoding": null, - "endEmitted": true, - "ended": true, - "flowing": true, - "highWaterMark": 16, - "length": 0, - "needReadable": false, - "objectMode": true, - "paused": false, - "pipes": null, - "pipesCount": 0, - "readableListening": false, - "reading": false, - "readingMore": false, - "resumeScheduled": false, - "sync": false, - }, - "readable": false, - }, - "savedObjectsClient": Object { - "addToNamespaces": [MockFunction], - "bulkCreate": [MockFunction], - "bulkGet": [MockFunction], - "bulkUpdate": [MockFunction], - "create": [MockFunction], - "delete": [MockFunction], - "deleteFromNamespaces": [MockFunction], - "errors": [Function], - "find": [MockFunction], - "get": [MockFunction], - "update": [MockFunction], - }, - "supportedTypes": Array [ - "dashboard", - "visualization", - ], - }, - ], - ] - `); + expect(exportSavedObjectsToStream).toHaveBeenCalledWith({ + excludeExportDetails: true, + exportSizeLimit: EXPORT_LIMIT, + includeReferencesDeep: true, + namespace, + objects, + savedObjectsClient, + }); + + const importOptions = { + createNewCopies: false, + objectLimit: EXPORT_LIMIT, + overwrite: true, + readStream: expect.any(Readable), + savedObjectsClient, + typeRegistry, + }; + expect(importSavedObjectsFromStream).toHaveBeenNthCalledWith(1, { + ...importOptions, + namespace: 'destination1', + }); + expect(importSavedObjectsFromStream).toHaveBeenNthCalledWith(2, { + ...importOptions, + namespace: 'destination2', + }); }); it(`doesn't stop copy if some spaces fail`, async () => { - const objects = [ - { - type: 'dashboard', - id: 'my-dashboard', - attributes: {}, - }, - { - type: 'visualization', - id: 'my-viz', - attributes: {}, - }, - { - type: 'index-pattern', - id: 'my-index-pattern', - attributes: {}, - }, - ]; - const { savedObjects } = setup({ - objects, + objects: mockExportResults, importSavedObjectsFromStreamImpl: async (opts) => { if (opts.namespace === 'failure-space') { throw new Error(`Some error occurred!`); } - await expectStreamToContainObjects(opts.readStream, objects); + // namespace-agnostic types should be filtered out before import + const filteredObjects = mockExportResults.filter(({ type }) => type !== 'globaltype'); + await expectStreamToContainObjects(opts.readStream, filteredObjects); return Promise.resolve({ success: true, - successCount: 3, + successCount: filteredObjects.length, + successResults: [('Some success(es) occurred!' as unknown) as SavedObjectsImportSuccess], }); }, }); @@ -378,7 +230,7 @@ describe('copySavedObjectsToSpaces', () => { const copySavedObjectsToSpaces = copySavedObjectsToSpacesFactory( savedObjects, - () => 1000, + () => EXPORT_LIMIT, request ); @@ -388,58 +240,44 @@ describe('copySavedObjectsToSpaces', () => { { includeReferences: true, overwrite: true, - objects: [ - { - type: 'dashboard', - id: 'my-dashboard', - }, - ], + objects: [{ type: 'dashboard', id: 'my-dashboard' }], + createNewCopies: false, } ); expect(result).toMatchInlineSnapshot(` - Object { - "failure-space": Object { - "errors": Array [ - [Error: Some error occurred!], - ], - "success": false, - "successCount": 0, - }, - "marketing": Object { - "errors": undefined, - "success": true, - "successCount": 3, - }, - "non-existent-space": Object { - "errors": undefined, - "success": true, - "successCount": 3, - }, - } - `); + Object { + "failure-space": Object { + "errors": Array [ + [Error: Some error occurred!], + ], + "success": false, + "successCount": 0, + }, + "marketing": Object { + "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!", + ], + }, + } + `); }); it(`handles stream read errors`, async () => { const { savedObjects } = setup({ - objects: [ - { - type: 'dashboard', - id: 'my-dashboard', - attributes: {}, - }, - { - type: 'visualization', - id: 'my-viz', - attributes: {}, - }, - { - type: 'index-pattern', - id: 'my-index-pattern', - attributes: {}, - }, - ], - exportSavedObjectsToStreamImpl: (opts) => { + objects: mockExportResults, + exportSavedObjectsToStreamImpl: (_opts) => { return Promise.resolve( new Readable({ objectMode: true, @@ -455,7 +293,7 @@ describe('copySavedObjectsToSpaces', () => { const copySavedObjectsToSpaces = copySavedObjectsToSpacesFactory( savedObjects, - () => 1000, + () => EXPORT_LIMIT, request ); @@ -466,12 +304,8 @@ describe('copySavedObjectsToSpaces', () => { { includeReferences: true, overwrite: true, - objects: [ - { - type: 'dashboard', - id: 'my-dashboard', - }, - ], + objects: [{ type: 'dashboard', id: 'my-dashboard' }], + createNewCopies: 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..5575052d7bbb8 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,11 +12,11 @@ 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'; import { COPY_TO_SPACES_SAVED_OBJECTS_CLIENT_OPTS } from './lib/saved_objects_client_opts'; +import { getIneligibleTypes } from './lib/get_ineligible_types'; export function copySavedObjectsToSpacesFactory( savedObjects: CoreStart['savedObjects'], @@ -27,8 +27,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 +54,15 @@ export function copySavedObjectsToSpacesFactory( objectLimit: getImportExportObjectLimit(), overwrite: options.overwrite, savedObjectsClient, - supportedTypes: eligibleTypes, + typeRegistry: getTypeRegistry(), readStream: objectsStream, + createNewCopies: options.createNewCopies, }); return { success: importResponse.success, successCount: importResponse.successCount, + successResults: importResponse.successResults, errors: importResponse.errors, }; } catch (error) { @@ -78,11 +78,15 @@ export function copySavedObjectsToSpacesFactory( const response: CopyResponse = {}; const exportedSavedObjects = await exportRequestedObjects(sourceSpaceId, options); + const ineligibleTypes = getIneligibleTypes(getTypeRegistry()); + const filteredObjects = exportedSavedObjects.filter( + ({ type }) => !ineligibleTypes.includes(type) + ); for (const spaceId of destinationSpaceIds) { response[spaceId] = await importObjectsToSpace( spaceId, - createReadableStreamFromArray(exportedSavedObjects), + createReadableStreamFromArray(filteredObjects), options ); } diff --git a/x-pack/plugins/spaces/server/lib/copy_to_spaces/lib/get_eligible_types.ts b/x-pack/plugins/spaces/server/lib/copy_to_spaces/lib/get_eligible_types.ts deleted file mode 100644 index e5f2c5b18bd00..0000000000000 --- a/x-pack/plugins/spaces/server/lib/copy_to_spaces/lib/get_eligible_types.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { SavedObjectTypeRegistry } from 'src/core/server'; - -export function getEligibleTypes( - typeRegistry: Pick -) { - return typeRegistry - .getAllTypes() - .filter((type) => !typeRegistry.isNamespaceAgnostic(type.name)) - .map((type) => type.name); -} diff --git a/x-pack/plugins/spaces/server/lib/copy_to_spaces/lib/get_ineligible_types.ts b/x-pack/plugins/spaces/server/lib/copy_to_spaces/lib/get_ineligible_types.ts new file mode 100644 index 0000000000000..91d4cb13b98eb --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/copy_to_spaces/lib/get_ineligible_types.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectTypeRegistry } from 'src/core/server'; + +/** + * This function returns any importable/exportable saved object types that are namespace-agnostic. Even if these are eligible for + * import/export, we should not include them in the copy operation because it will result in a conflict that needs to overwrite itself to be + * resolved. + */ +export function getIneligibleTypes( + typeRegistry: Pick< + SavedObjectTypeRegistry, + 'getImportableAndExportableTypes' | 'isNamespaceAgnostic' + > +) { + return typeRegistry + .getImportableAndExportableTypes() + .filter((type) => typeRegistry.isNamespaceAgnostic(type.name)) + .map((type) => type.name); +} 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..6a77bf7397cb5 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 @@ -3,13 +3,19 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import { Readable } from 'stream'; import { SavedObjectsImportResponse, SavedObjectsResolveImportErrorsOptions, SavedObjectsExportOptions, + SavedObjectsImportSuccess, } from 'src/core/server'; -import { coreMock, savedObjectsTypeRegistryMock, httpServerMock } from 'src/core/server/mocks'; -import { Readable } from 'stream'; +import { + coreMock, + httpServerMock, + savedObjectsTypeRegistryMock, + savedObjectsClientMock, +} from 'src/core/server/mocks'; import { resolveCopySavedObjectsToSpacesConflictsFactory } from './resolve_copy_conflicts'; jest.mock('../../../../../../src/core/server', () => { @@ -31,6 +37,8 @@ interface SetupOpts { ) => Promise; } +const EXPORT_LIMIT = 1000; + const expectStreamToContainObjects = async ( stream: Readable, expectedObjects: SetupOpts['objects'] @@ -50,23 +58,28 @@ const expectStreamToContainObjects = async ( }; describe('resolveCopySavedObjectsToSpacesConflicts', () => { + const mockExportResults = [ + { type: 'dashboard', id: 'my-dashboard', attributes: {} }, + { type: 'visualization', id: 'my-viz', attributes: {} }, + { type: 'index-pattern', id: 'my-index-pattern', attributes: {} }, + ]; + const setup = (setupOpts: SetupOpts) => { const coreStart = coreMock.createStart(); + const savedObjectsClient = savedObjectsClientMock.create(); const typeRegistry = savedObjectsTypeRegistryMock.create(); - typeRegistry.getAllTypes.mockReturnValue([ + coreStart.savedObjects.getScopedClient.mockReturnValue(savedObjectsClient); + coreStart.savedObjects.getTypeRegistry.mockReturnValue(typeRegistry); + + typeRegistry.getImportableAndExportableTypes.mockReturnValue([ + // don't need to include all types, just need a positive case (agnostic) and a negative case (non-agnostic) { name: 'dashboard', namespaceType: 'single', hidden: false, mappings: { properties: {} }, }, - { - name: 'visualization', - namespaceType: 'single', - hidden: false, - mappings: { properties: {} }, - }, { name: 'globaltype', namespaceType: 'agnostic', @@ -74,13 +87,12 @@ describe('resolveCopySavedObjectsToSpacesConflicts', () => { mappings: { properties: {} }, }, ]); - typeRegistry.isNamespaceAgnostic.mockImplementation((type: string) => - typeRegistry.getAllTypes().some((t) => t.name === type && t.namespaceType === 'agnostic') + typeRegistry + .getImportableAndExportableTypes() + .some((t) => t.name === type && t.namespaceType === 'agnostic') ); - coreStart.savedObjects.getTypeRegistry.mockReturnValue(typeRegistry); - (exportSavedObjectsToStream as jest.Mock).mockImplementation( async (opts: SavedObjectsExportOptions) => { return ( @@ -100,11 +112,16 @@ describe('resolveCopySavedObjectsToSpacesConflicts', () => { (resolveSavedObjectsImportErrors as jest.Mock).mockImplementation( async (opts: SavedObjectsResolveImportErrorsOptions) => { const defaultImpl = async () => { - await expectStreamToContainObjects(opts.readStream, setupOpts.objects); + // namespace-agnostic types should be filtered out before import + const filteredObjects = setupOpts.objects.filter(({ type }) => type !== 'globaltype'); + await expectStreamToContainObjects(opts.readStream, filteredObjects); const response: SavedObjectsImportResponse = { success: true, - successCount: setupOpts.objects.length, + successCount: filteredObjects.length, + successResults: [ + ('Some success(es) occurred!' as unknown) as SavedObjectsImportSuccess, + ], }; return response; @@ -116,290 +133,100 @@ describe('resolveCopySavedObjectsToSpacesConflicts', () => { return { savedObjects: coreStart.savedObjects, + savedObjectsClient, + typeRegistry, }; }; it('uses the Saved Objects Service to perform an export followed by a series of conflict resolution calls', async () => { - const { savedObjects } = setup({ - objects: [ - { - type: 'dashboard', - id: 'my-dashboard', - attributes: {}, - }, - { - type: 'visualization', - id: 'my-viz', - attributes: {}, - }, - { - type: 'index-pattern', - id: 'my-index-pattern', - attributes: {}, - }, - ], + const { savedObjects, savedObjectsClient, typeRegistry } = setup({ + objects: mockExportResults, }); const request = httpServerMock.createKibanaRequest(); const resolveCopySavedObjectsToSpacesConflicts = resolveCopySavedObjectsToSpacesConflictsFactory( savedObjects, - () => 1000, + () => EXPORT_LIMIT, request ); - const result = await resolveCopySavedObjectsToSpacesConflicts('sourceSpace', { + const namespace = 'sourceSpace'; + const objects = [{ type: 'dashboard', id: 'my-dashboard' }]; + const retries = { + destination1: [{ type: 'visualization', id: 'my-visualization', overwrite: true }], + destination2: [{ type: 'visualization', id: 'my-visualization', overwrite: false }], + }; + const result = await resolveCopySavedObjectsToSpacesConflicts(namespace, { includeReferences: true, - objects: [ - { - type: 'dashboard', - id: 'my-dashboard', - }, - ], - retries: { - destination1: [ - { - type: 'visualization', - id: 'my-visualization', - overwrite: true, - }, - ], - destination2: [ - { - type: 'visualization', - id: 'my-visualization', - overwrite: false, - }, - ], - }, + objects, + retries, + createNewCopies: false, }); expect(result).toMatchInlineSnapshot(` - Object { - "destination1": Object { - "errors": undefined, - "success": true, - "successCount": 3, - }, - "destination2": Object { - "errors": undefined, - "success": true, - "successCount": 3, - }, - } - `); - - expect((exportSavedObjectsToStream as jest.Mock).mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - Object { - "excludeExportDetails": true, - "exportSizeLimit": 1000, - "includeReferencesDeep": true, - "namespace": "sourceSpace", - "objects": Array [ - Object { - "id": "my-dashboard", - "type": "dashboard", - }, - ], - "savedObjectsClient": Object { - "addToNamespaces": [MockFunction], - "bulkCreate": [MockFunction], - "bulkGet": [MockFunction], - "bulkUpdate": [MockFunction], - "create": [MockFunction], - "delete": [MockFunction], - "deleteFromNamespaces": [MockFunction], - "errors": [Function], - "find": [MockFunction], - "get": [MockFunction], - "update": [MockFunction], - }, - }, - ], - ] + Object { + "destination1": Object { + "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!", + ], + }, + } `); - expect((resolveSavedObjectsImportErrors as jest.Mock).mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - Object { - "namespace": "destination1", - "objectLimit": 1000, - "readStream": Readable { - "_events": Object { - "data": [Function], - "end": [Function], - "error": [Function], - }, - "_eventsCount": 3, - "_maxListeners": undefined, - "_read": [Function], - "_readableState": ReadableState { - "autoDestroy": false, - "awaitDrain": 0, - "buffer": BufferList { - "head": null, - "length": 0, - "tail": null, - }, - "decoder": null, - "defaultEncoding": "utf8", - "destroyed": false, - "emitClose": true, - "emittedReadable": false, - "encoding": null, - "endEmitted": true, - "ended": true, - "flowing": true, - "highWaterMark": 16, - "length": 0, - "needReadable": false, - "objectMode": true, - "paused": false, - "pipes": null, - "pipesCount": 0, - "readableListening": false, - "reading": false, - "readingMore": false, - "resumeScheduled": false, - "sync": false, - }, - "readable": false, - }, - "retries": Array [ - Object { - "id": "my-visualization", - "overwrite": true, - "replaceReferences": Array [], - "type": "visualization", - }, - ], - "savedObjectsClient": Object { - "addToNamespaces": [MockFunction], - "bulkCreate": [MockFunction], - "bulkGet": [MockFunction], - "bulkUpdate": [MockFunction], - "create": [MockFunction], - "delete": [MockFunction], - "deleteFromNamespaces": [MockFunction], - "errors": [Function], - "find": [MockFunction], - "get": [MockFunction], - "update": [MockFunction], - }, - "supportedTypes": Array [ - "dashboard", - "visualization", - ], - }, - ], - Array [ - Object { - "namespace": "destination2", - "objectLimit": 1000, - "readStream": Readable { - "_events": Object { - "data": [Function], - "end": [Function], - "error": [Function], - }, - "_eventsCount": 3, - "_maxListeners": undefined, - "_read": [Function], - "_readableState": ReadableState { - "autoDestroy": false, - "awaitDrain": 0, - "buffer": BufferList { - "head": null, - "length": 0, - "tail": null, - }, - "decoder": null, - "defaultEncoding": "utf8", - "destroyed": false, - "emitClose": true, - "emittedReadable": false, - "encoding": null, - "endEmitted": true, - "ended": true, - "flowing": true, - "highWaterMark": 16, - "length": 0, - "needReadable": false, - "objectMode": true, - "paused": false, - "pipes": null, - "pipesCount": 0, - "readableListening": false, - "reading": false, - "readingMore": false, - "resumeScheduled": false, - "sync": false, - }, - "readable": false, - }, - "retries": Array [ - Object { - "id": "my-visualization", - "overwrite": false, - "replaceReferences": Array [], - "type": "visualization", - }, - ], - "savedObjectsClient": Object { - "addToNamespaces": [MockFunction], - "bulkCreate": [MockFunction], - "bulkGet": [MockFunction], - "bulkUpdate": [MockFunction], - "create": [MockFunction], - "delete": [MockFunction], - "deleteFromNamespaces": [MockFunction], - "errors": [Function], - "find": [MockFunction], - "get": [MockFunction], - "update": [MockFunction], - }, - "supportedTypes": Array [ - "dashboard", - "visualization", - ], - }, - ], - ] - `); + expect(exportSavedObjectsToStream).toHaveBeenCalledWith({ + excludeExportDetails: true, + exportSizeLimit: EXPORT_LIMIT, + includeReferencesDeep: true, + namespace, + objects, + savedObjectsClient, + }); + + const importOptions = { + createNewCopies: false, + objectLimit: EXPORT_LIMIT, + readStream: expect.any(Readable), + savedObjectsClient, + typeRegistry, + }; + expect(resolveSavedObjectsImportErrors).toHaveBeenNthCalledWith(1, { + ...importOptions, + namespace: 'destination1', + retries: [{ ...retries.destination1[0], replaceReferences: [] }], + }); + expect(resolveSavedObjectsImportErrors).toHaveBeenNthCalledWith(2, { + ...importOptions, + namespace: 'destination2', + retries: [{ ...retries.destination2[0], replaceReferences: [] }], + }); }); it(`doesn't stop resolution if some spaces fail`, async () => { - const objects = [ - { - type: 'dashboard', - id: 'my-dashboard', - attributes: {}, - }, - { - type: 'visualization', - id: 'my-viz', - attributes: {}, - }, - { - type: 'index-pattern', - id: 'my-index-pattern', - attributes: {}, - }, - ]; - const { savedObjects } = setup({ - objects, + objects: mockExportResults, resolveSavedObjectsImportErrorsImpl: async (opts) => { if (opts.namespace === 'failure-space') { throw new Error(`Some error occurred!`); } - await expectStreamToContainObjects(opts.readStream, objects); + // namespace-agnostic types should be filtered out before import + const filteredObjects = mockExportResults.filter(({ type }) => type !== 'globaltype'); + await expectStreamToContainObjects(opts.readStream, filteredObjects); return Promise.resolve({ success: true, - successCount: 3, + successCount: filteredObjects.length, + successResults: [('Some success(es) occurred!' as unknown) as SavedObjectsImportSuccess], }); }, }); @@ -408,64 +235,50 @@ describe('resolveCopySavedObjectsToSpacesConflicts', () => { const resolveCopySavedObjectsToSpacesConflicts = resolveCopySavedObjectsToSpacesConflictsFactory( savedObjects, - () => 1000, + () => EXPORT_LIMIT, request ); const result = await resolveCopySavedObjectsToSpacesConflicts('sourceSpace', { includeReferences: true, - objects: [ - { - type: 'dashboard', - id: 'my-dashboard', - }, - ], + objects: [{ type: 'dashboard', id: 'my-dashboard' }], retries: { - ['failure-space']: [ - { - type: 'visualization', - id: 'my-visualization', - overwrite: true, - }, - ], + ['failure-space']: [{ type: 'visualization', id: 'my-visualization', overwrite: true }], ['non-existent-space']: [ - { - type: 'visualization', - id: 'my-visualization', - overwrite: false, - }, - ], - ['marketing']: [ - { - type: 'visualization', - id: 'my-visualization', - overwrite: true, - }, + { type: 'visualization', id: 'my-visualization', overwrite: false }, ], + marketing: [{ type: 'visualization', id: 'my-visualization', overwrite: true }], }, + createNewCopies: false, }); expect(result).toMatchInlineSnapshot(` - Object { - "failure-space": Object { - "errors": Array [ - [Error: Some error occurred!], - ], - "success": false, - "successCount": 0, - }, - "marketing": Object { - "errors": undefined, - "success": true, - "successCount": 3, - }, - "non-existent-space": Object { - "errors": undefined, - "success": true, - "successCount": 3, - }, - } - `); + Object { + "failure-space": Object { + "errors": Array [ + [Error: Some error occurred!], + ], + "success": false, + "successCount": 0, + }, + "marketing": Object { + "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!", + ], + }, + } + `); }); it(`handles stream read errors`, async () => { @@ -487,7 +300,7 @@ describe('resolveCopySavedObjectsToSpacesConflicts', () => { const resolveCopySavedObjectsToSpacesConflicts = resolveCopySavedObjectsToSpacesConflictsFactory( savedObjects, - () => 1000, + () => EXPORT_LIMIT, request ); @@ -496,6 +309,7 @@ describe('resolveCopySavedObjectsToSpacesConflicts', () => { includeReferences: true, objects: [], retries: {}, + createNewCopies: 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..d433712bb9412 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,18 +5,18 @@ */ 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'; import { COPY_TO_SPACES_SAVED_OBJECTS_CLIENT_OPTS } from './lib/saved_objects_client_opts'; +import { getIneligibleTypes } from './lib/get_ineligible_types'; export function resolveCopySavedObjectsToSpacesConflictsFactory( savedObjects: CoreStart['savedObjects'], @@ -27,8 +27,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 +45,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[], + createNewCopies: boolean ) => { try { const importResponse = await resolveSavedObjectsImportErrors({ namespace: spaceIdToNamespace(spaceId), objectLimit: getImportExportObjectLimit(), savedObjectsClient, - supportedTypes: eligibleTypes, + typeRegistry: getTypeRegistry(), readStream: objectsStream, retries, + createNewCopies, }); return { success: importResponse.success, successCount: importResponse.successCount, + successResults: importResponse.successResults, errors: importResponse.errors, }; } catch (error) { @@ -84,6 +80,10 @@ export function resolveCopySavedObjectsToSpacesConflictsFactory( includeReferences: options.includeReferences, objects: options.objects, }); + const ineligibleTypes = getIneligibleTypes(getTypeRegistry()); + const filteredObjects = exportedSavedObjects.filter( + ({ type }) => !ineligibleTypes.includes(type) + ); for (const entry of Object.entries(options.retries)) { const [spaceId, entryRetries] = entry; @@ -92,8 +92,9 @@ export function resolveCopySavedObjectsToSpacesConflictsFactory( response[spaceId] = await resolveConflictsForSpace( spaceId, - createReadableStreamFromArray(exportedSavedObjects), - retries + createReadableStreamFromArray(filteredObjects), + retries, + options.createNewCopies ); } 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..8d4169f972795 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; + createNewCopies: 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>; }; + createNewCopies: 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..bec3a5dcb0b71 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 "createNewCopies"`, async () => { const payload = { spaces: ['a-space'], - objects: [ - { type: 'foo', id: 'bar' }, - { type: 'foo', id: 'bar' }, - ], + objects: [{ type: 'foo', id: 'bar' }], + overwrite: true, + createNewCopies: 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 [createNewCopies]"`); }); - 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 87c2fee4ea9bf..804820cd42b92 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 }), + createNewCopies: schema.boolean({ defaultValue: false }), + }, + { + validate: (object) => { + if (object.overwrite && object.createNewCopies) { + return 'cannot use [overwrite] with [createNewCopies]'; + } + }, + } + ), }, }, 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, + createNewCopies, + } = request.body; const sourceSpaceId = spacesService.getSpaceId(request); const copyResponse = await copySavedObjectsToSpaces(sourceSpaceId, destinationSpaceIds, { objects, includeReferences, overwrite, + createNewCopies, }); 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()), + createNewCopy: schema.maybe(schema.boolean()), }) ) ), @@ -122,6 +141,7 @@ export function initCopyToSpacesApi(deps: ExternalRouteDeps) { } ), includeReferences: schema.boolean({ defaultValue: false }), + createNewCopies: 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, createNewCopies } = request.body; const sourceSpaceId = spacesService.getSpaceId(request); const resolveConflictsResponse = await resolveCopySavedObjectsToSpacesConflicts( sourceSpaceId, @@ -141,6 +161,7 @@ export function initCopyToSpacesApi(deps: ExternalRouteDeps) { objects, includeReferences, retries, + createNewCopies, } ); 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 4d0d75cd4595c..33adcb6ca0e5f 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 @@ -90,7 +90,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; const type = Symbol(); const id = Symbol(); const options = Object.freeze({ foo: 'bar' }); - // @ts-ignore + // @ts-expect-error const actualReturnValue = await client.get(type, id, options); expect(actualReturnValue).toBe(expectedReturnValue); @@ -117,7 +117,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; const objects = [{ type: 'foo' }]; const options = Object.freeze({ foo: 'bar' }); - // @ts-ignore + // @ts-expect-error const actualReturnValue = await client.bulkGet(objects, options); expect(actualReturnValue).toBe(expectedReturnValue); @@ -263,6 +263,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-expect-error + 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-expect-error + 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(); @@ -280,7 +308,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; const type = Symbol(); const attributes = Symbol(); const options = Object.freeze({ foo: 'bar' }); - // @ts-ignore + // @ts-expect-error const actualReturnValue = await client.create(type, attributes, options); expect(actualReturnValue).toBe(expectedReturnValue); @@ -307,7 +335,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; const objects = [{ type: 'foo' }]; const options = Object.freeze({ foo: 'bar' }); - // @ts-ignore + // @ts-expect-error const actualReturnValue = await client.bulkCreate(objects, options); expect(actualReturnValue).toBe(expectedReturnValue); @@ -323,7 +351,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; const { client } = await createSpacesSavedObjectsClient(); await expect( - // @ts-ignore + // @ts-expect-error client.update(null, null, null, { namespace: 'bar' }) ).rejects.toThrow(ERROR_NAMESPACE_SPECIFIED); }); @@ -337,7 +365,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; const id = Symbol(); const attributes = Symbol(); const options = Object.freeze({ foo: 'bar' }); - // @ts-ignore + // @ts-expect-error const actualReturnValue = await client.update(type, id, attributes, options); expect(actualReturnValue).toBe(expectedReturnValue); @@ -353,7 +381,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; const { client } = await createSpacesSavedObjectsClient(); await expect( - // @ts-ignore + // @ts-expect-error client.bulkUpdate(null, { namespace: 'bar' }) ).rejects.toThrow(ERROR_NAMESPACE_SPECIFIED); }); @@ -387,7 +415,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; const { client } = await createSpacesSavedObjectsClient(); await expect( - // @ts-ignore + // @ts-expect-error client.delete(null, null, { namespace: 'bar' }) ).rejects.toThrow(ERROR_NAMESPACE_SPECIFIED); }); @@ -400,7 +428,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; const type = Symbol(); const id = Symbol(); const options = Object.freeze({ foo: 'bar' }); - // @ts-ignore + // @ts-expect-error const actualReturnValue = await client.delete(type, id, options); expect(actualReturnValue).toBe(expectedReturnValue); @@ -416,7 +444,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; const { client } = await createSpacesSavedObjectsClient(); await expect( - // @ts-ignore + // @ts-expect-error client.addToNamespaces(null, null, null, { namespace: 'bar' }) ).rejects.toThrow(ERROR_NAMESPACE_SPECIFIED); }); @@ -430,7 +458,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; const id = Symbol(); const namespaces = Symbol(); const options = Object.freeze({ foo: 'bar' }); - // @ts-ignore + // @ts-expect-error const actualReturnValue = await client.addToNamespaces(type, id, namespaces, options); expect(actualReturnValue).toBe(expectedReturnValue); @@ -446,7 +474,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; const { client } = await createSpacesSavedObjectsClient(); await expect( - // @ts-ignore + // @ts-expect-error client.deleteFromNamespaces(null, null, null, { namespace: 'bar' }) ).rejects.toThrow(ERROR_NAMESPACE_SPECIFIED); }); @@ -460,7 +488,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; const id = Symbol(); const namespaces = Symbol(); const options = Object.freeze({ foo: 'bar' }); - // @ts-ignore + // @ts-expect-error const actualReturnValue = await client.deleteFromNamespaces(type, id, namespaces, options); expect(actualReturnValue).toBe(expectedReturnValue); 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 7e2b302d7cff5..4e830d6149537 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, @@ -59,6 +60,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 5d08421038d3f..595986c08efc1 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 @@ -168,7 +168,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 bc356927cc0af..e3163ef77d427 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 ff22cdaeafd06..4a8eff1fb380c 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 882451c28bfe4..bab4a4d88534a 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 @@ -43,6 +43,36 @@ export interface FindTestCase { }; } +// additional sharedtype objects that exist but do not have common test cases defined +const CONFLICT_1_OBJ = Object.freeze({ + type: 'sharedtype', + id: 'conflict_1', + namespaces: ['default', 'space_1', 'space_2'], +}); +const CONFLICT_2A_OBJ = Object.freeze({ + type: 'sharedtype', + id: 'conflict_2a', + originId: 'conflict_2', + namespaces: ['default', 'space_1', 'space_2'], +}); +const CONFLICT_2B_OBJ = Object.freeze({ + type: 'sharedtype', + id: 'conflict_2b', + originId: 'conflict_2', + namespaces: ['default', 'space_1', 'space_2'], +}); +const CONFLICT_3_OBJ = Object.freeze({ + type: 'sharedtype', + id: 'conflict_3', + namespaces: ['default', 'space_1', 'space_2'], +}); +const CONFLICT_4A_OBJ = Object.freeze({ + type: 'sharedtype', + id: 'conflict_4a', + originId: 'conflict_4', + namespaces: ['default', 'space_1', 'space_2'], +}); + const TEST_CASES = [ { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, namespaces: ['default'] }, { ...CASES.SINGLE_NAMESPACE_SPACE_1, namespaces: ['space_1'] }, @@ -110,7 +140,13 @@ export const getTestCases = ( query: `type=sharedtype&fields=title${namespacesQueryParam}`, successResult: { // expected depends on which spaces the user is authorized against... - savedObjects: getExpectedSavedObjects((t) => t.type === 'sharedtype'), + savedObjects: getExpectedSavedObjects((t) => t.type === 'sharedtype').concat( + CONFLICT_1_OBJ, + CONFLICT_2A_OBJ, + CONFLICT_2B_OBJ, + CONFLICT_3_OBJ, + CONFLICT_4A_OBJ + ), }, } 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 ed57c6eb16b9a..5036d7b200881 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,33 +8,66 @@ 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; + createNewCopies: 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) { @@ -42,6 +75,9 @@ export function importTestSuiteFactory(es: any, esArchiver: any, supertest: Supe const expectResponseBody = ( testCases: ImportTestCase | ImportTestCase[], statusCode: 200 | 403, + singleRequest: boolean, + overwrite: boolean, + createNewCopies: boolean, spaceId = SPACES.DEFAULT.spaceId ): ExpectResponseBody => async (response: Record) => { const testCaseArray = Array.isArray(testCases) ? testCases : [testCases]; @@ -50,7 +86,7 @@ export function importTestSuiteFactory(es: any, esArchiver: any, supertest: Supe 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); @@ -61,12 +97,53 @@ 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 === 'createNewCopies' || successParam === 'createNewCopy') { + // 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 `createNewCopies` mode is disabled and ambiguous source conflicts are detected. + // When `createNewCopies` mode is permanently enabled, this field will be removed, and this assertion will be redundant and can be + // removed too. + const createNewCopy = object!.createNewCopy as boolean | undefined; + if (successParam === 'createNewCopy') { + expect(createNewCopy).to.be(true); + } else { + expect(createNewCopy).to.be(undefined); + } + + if (!singleRequest || overwrite || createNewCopies) { + // 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 +153,24 @@ 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`)], + }; + } + expect(object!.error).to.eql(error); } } } @@ -84,7 +178,9 @@ export function importTestSuiteFactory(es: any, esArchiver: any, supertest: Supe const createTestDefinitions = ( testCases: ImportTestCase | ImportTestCase[], forbidden: boolean, - options?: { + options: { + overwrite?: boolean; + createNewCopies?: boolean; spaceId?: string; singleRequest?: boolean; responseBodyOverride?: ExpectResponseBody; @@ -92,7 +188,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, + createNewCopies = 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 +203,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, createNewCopies, spaceId), + overwrite, + createNewCopies, })); } // batch into a single request to save time during test execution @@ -111,8 +216,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, createNewCopies, spaceId), + overwrite, + createNewCopies, }, ]; }; @@ -134,8 +241,13 @@ 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.createNewCopies + ? '?createNewCopies=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 822214cd6dc6a..0482711ad2bf1 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; + createNewCopies: 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 === 'createNewCopy' && { createNewCopy: 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,49 @@ 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 === 'createNewCopies' || successParam === 'createNewCopy') { + expect(destinationId).to.be(expectedNewId!); + } else { + expect(destinationId).to.be(undefined); + } + + // This assertion is only needed for the case where `createNewCopies` mode is disabled and ambiguous source conflicts are detected. + // When `createNewCopies` mode is permanently enabled, this field will be removed, and this assertion will be redundant and can be + // removed too. + const createNewCopy = object!.createNewCopy as boolean | undefined; + if (successParam === 'createNewCopy') { + expect(createNewCopy).to.be(true); + } else { + expect(createNewCopy).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 +169,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 +180,9 @@ export function resolveImportErrorsTestSuiteFactory( const createTestDefinitions = ( testCases: ResolveImportErrorsTestCase | ResolveImportErrorsTestCase[], forbidden: boolean, - overwrite: boolean, - options?: { + options: { + overwrite?: boolean; + createNewCopies?: boolean; spaceId?: string; singleRequest?: boolean; responseBodyOverride?: ExpectResponseBody; @@ -98,29 +190,40 @@ export function resolveImportErrorsTestSuiteFactory( ): ResolveImportErrorsTestDefinition[] => { const cases = Array.isArray(testCases) ? testCases : [testCases]; const responseStatusCode = forbidden ? 403 : 200; - if (!options?.singleRequest) { + const { + overwrite = false, + createNewCopies = 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, + createNewCopies, })); } // 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, + createNewCopies, }, ]; }; @@ -139,17 +242,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.createNewCopies ? '?createNewCopies=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..0b531a3dccc1a 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 newCopy = () => ({ successParam: 'createNewCopy' }); +const ambiguousConflict = (suffix: string) => ({ + failure: 409 as 409, + fail409Param: `ambiguous_conflict_${suffix}`, +}); -const createTestCases = (spaceId: string) => { +const createNewCopiesTestCases = () => { + // for each outcome, if failure !== undefined then we expect to receive + // an error; otherwise, we expect to receive a success result + const cases = Object.entries(CASES).filter(([key]) => key !== 'HIDDEN'); + const importable = cases.map(([, val]) => ({ ...val, successParam: 'createNewCopies' })); + const nonImportable = [{ ...CASES.HIDDEN, ...fail400() }]; + const 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, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID + { ...CASES.CONFLICT_1B_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID + { ...CASES.CONFLICT_3A_OBJ, ...fail409(!overwrite), ...destinationId() }, // "inexact match" conflict + { ...CASES.CONFLICT_4_OBJ, ...fail409(!overwrite), ...destinationId() }, // "inexact match" conflict + ]; + 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, ...newCopy() }, // "ambiguous source and destination" conflict which results in a new destination ID and empty origin ID + { ...CASES.CONFLICT_2D_OBJ, ...newCopy() }, // "ambiguous source and destination" conflict which results in a new destination ID and empty origin ID ]; - 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, createNewCopies: boolean, spaceId: string) => { + const singleRequest = true; + + if (createNewCopies) { + const { importable, nonImportable, all } = createNewCopiesTestCases(); + return { + unauthorized: [ + createTestDefinitions(importable, true, { createNewCopies, spaceId }), + createTestDefinitions(nonImportable, false, { createNewCopies, spaceId, singleRequest }), + createTestDefinitions(all, true, { + createNewCopies, + spaceId, + singleRequest, + responseBodyOverride: expectForbidden([ + 'dashboard', + 'globaltype', + 'isolatedtype', + 'sharedtype', + ]), + }), + ].flat(), + authorized: createTestDefinitions(all, false, { createNewCopies, 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, + singleRequest, responseBodyOverride: expectForbidden(['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, createNewCopies] = modifier!; + const suffix = ` within the ${spaceId} space${ + overwrite + ? ' with overwrite enabled' + : createNewCopies + ? ' with createNewCopies enabled' + : '' + }`; + const { unauthorized, authorized } = createTests(overwrite, createNewCopies, 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..792fe63e5932d 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 newCopy = () => ({ successParam: 'createNewCopy' }); + +const createNewCopiesTestCases = () => { + // for each outcome, if failure !== undefined then we expect to receive + // an error; otherwise, we expect to receive a success result + const cases = Object.entries(CASES).filter(([key]) => key !== 'HIDDEN'); + const importable = cases.map(([, val]) => ({ + ...val, + successParam: 'createNewCopies', + 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, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID + { ...CASES.CONFLICT_1B_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID + // all of the cases below represent imports that had an inexact match conflict or an ambiguous conflict + // if we call _resolve_import_errors and don't specify overwrite, each of these will result in a conflict because an object with that + // `expectedDestinationId` already exists + { ...CASES.CONFLICT_2C_OBJ, ...fail409(!overwrite), ...destinationId() }, // "ambiguous destination" conflict; if overwrite=true, will overwrite 'conflict_2a' + { ...CASES.CONFLICT_3A_OBJ, ...fail409(!overwrite), ...destinationId() }, // "inexact match" conflict; if overwrite=true, will overwrite 'conflict_3' + { ...CASES.CONFLICT_4_OBJ, ...fail409(!overwrite), ...destinationId() }, // "inexact match" conflict; if overwrite=true, will overwrite 'conflict_4a' ]; - const allTypes = importableTypes.concat(nonImportableTypes); - return { importableTypes, nonImportableTypes, allTypes }; + return { group1Importable, group1NonImportable, group1All, group2 }; }; export default function ({ getService }: FtrProviderContext) { @@ -56,47 +92,82 @@ 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, createNewCopies: boolean, spaceId: string) => { // use singleRequest to reduce execution time and/or test combined cases + const singleRequest = true; + + if (createNewCopies) { + const { importable, nonImportable, all } = createNewCopiesTestCases(); + return { + unauthorized: [ + createTestDefinitions(importable, true, { createNewCopies, spaceId }), + createTestDefinitions(nonImportable, false, { createNewCopies, spaceId, singleRequest }), + createTestDefinitions(all, true, { + createNewCopies, + spaceId, + singleRequest, + responseBodyOverride: expectForbidden(['globaltype', 'isolatedtype', 'sharedtype']), + }), + ].flat(), + authorized: createTestDefinitions(all, false, { createNewCopies, 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, createNewCopies] = modifier!; + const suffix = ` within the ${spaceId} space${ + overwrite + ? ' with overwrite enabled' + : createNewCopies + ? ' with createNewCopies enabled' + : '' + }`; + const { unauthorized, authorized } = createTests(overwrite, createNewCopies, spaceId); + const _addTests = (user: TestUser, tests: ResolveImportErrorsTestDefinition[]) => { + addTests(`${user.description}${suffix}`, { user, spaceId, tests }); + }; - [ - users.noAccess, - users.legacyAll, - users.dualRead, - users.readGlobally, - users.readAtSpace, - users.allAtOtherSpace, - ].forEach((user) => { - _addTests(user, unauthorized); - }); - [users.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..34be3b7408432 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 newCopy = () => ({ successParam: 'createNewCopy' }); +const ambiguousConflict = (suffix: string) => ({ + failure: 409 as 409, + fail409Param: `ambiguous_conflict_${suffix}`, +}); -const createTestCases = () => { +const createNewCopiesTestCases = () => { + // for each outcome, if failure !== undefined then we expect to receive + // an error; otherwise, we expect to receive a success result + const cases = Object.entries(CASES).filter(([key]) => key !== 'HIDDEN'); + const importable = cases.map(([, val]) => ({ ...val, successParam: 'createNewCopies' })); + const nonImportable = [{ ...CASES.HIDDEN, ...fail400() }]; + const 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, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID + { ...CASES.CONFLICT_1B_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID + { ...CASES.CONFLICT_3A_OBJ, ...fail409(!overwrite), ...destinationId() }, // "inexact match" conflict + { ...CASES.CONFLICT_4_OBJ, ...fail409(!overwrite), ...destinationId() }, // "inexact match" conflict + ]; + 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, ...newCopy() }, // "ambiguous source and destination" conflict which results in a new destination ID and empty origin ID + { ...CASES.CONFLICT_2D_OBJ, ...newCopy() }, // "ambiguous source and destination" conflict which results in a new destination ID and empty origin ID ]; - const allTypes = importableTypes.concat(nonImportableTypes); - return { importableTypes, nonImportableTypes, allTypes }; + return { group1Importable, group1NonImportable, group1All, group2, group3, group4 }; }; export default function ({ getService }: FtrProviderContext) { @@ -47,27 +83,76 @@ export default function ({ getService }: FtrProviderContext) { esArchiver, supertest ); - const createTests = () => { - const { importableTypes, nonImportableTypes, allTypes } = createTestCases(); + const createTests = (overwrite: boolean, createNewCopies: boolean) => { // use singleRequest to reduce execution time and/or test combined cases + const singleRequest = true; + + if (createNewCopies) { + const { importable, nonImportable, all } = createNewCopiesTestCases(); + return { + unauthorized: [ + createTestDefinitions(importable, true, { createNewCopies }), + createTestDefinitions(nonImportable, false, { createNewCopies, singleRequest }), + createTestDefinitions(all, true, { + createNewCopies, + singleRequest, + responseBodyOverride: expectForbidden([ + 'dashboard', + 'globaltype', + 'isolatedtype', + 'sharedtype', + ]), + }), + ].flat(), + authorized: createTestDefinitions(all, false, { createNewCopies, 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, + createTestDefinitions(group1Importable, true, { overwrite }), + createTestDefinitions(group1NonImportable, false, { overwrite, singleRequest }), + createTestDefinitions(group1All, true, { + overwrite, + singleRequest, responseBodyOverride: expectForbidden(['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, createNewCopies] = modifier!; + const suffix = overwrite + ? ' with overwrite enabled' + : createNewCopies + ? ' with createNewCopies enabled' + : ''; + const { unauthorized, authorized } = createTests(overwrite, createNewCopies); 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..91134dd14bd8a 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 newCopy = () => ({ successParam: 'createNewCopy' }); + +const createNewCopiesTestCases = () => { + // for each outcome, if failure !== undefined then we expect to receive + // an error; otherwise, we expect to receive a success result + const cases = Object.entries(CASES).filter(([key]) => key !== 'HIDDEN'); + const importable = cases.map(([, val]) => ({ + ...val, + successParam: 'createNewCopies', + 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, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID + { ...CASES.CONFLICT_1B_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID + // all of the cases below represent imports that had an inexact match conflict or an ambiguous conflict + // if we call _resolve_import_errors and don't specify overwrite, each of these will result in a conflict because an object with that + // `expectedDestinationId` already exists + { ...CASES.CONFLICT_2C_OBJ, ...fail409(!overwrite), ...destinationId() }, // "ambiguous destination" conflict; if overwrite=true, will overwrite 'conflict_2a' + { ...CASES.CONFLICT_3A_OBJ, ...fail409(!overwrite), ...destinationId() }, // "inexact match" conflict; if overwrite=true, will overwrite 'conflict_3' + { ...CASES.CONFLICT_4_OBJ, ...fail409(!overwrite), ...destinationId() }, // "inexact match" conflict; if overwrite=true, will overwrite 'conflict_4a' ]; - 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, createNewCopies: boolean) => { // use singleRequest to reduce execution time and/or test combined cases + const singleRequest = true; + + if (createNewCopies) { + const { importable, nonImportable, all } = createNewCopiesTestCases(); + return { + unauthorized: [ + createTestDefinitions(importable, true, { createNewCopies }), + createTestDefinitions(nonImportable, false, { createNewCopies, singleRequest }), + createTestDefinitions(all, true, { + createNewCopies, + singleRequest, + responseBodyOverride: expectForbidden(['globaltype', 'isolatedtype', 'sharedtype']), + }), + ].flat(), + authorized: createTestDefinitions(all, false, { createNewCopies, 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, createNewCopies] = modifier!; + const suffix = overwrite + ? ' with overwrite enabled' + : createNewCopies + ? ' with createNewCopies enabled' + : ''; + const { unauthorized, authorized } = createTests(overwrite, createNewCopies); 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..a36249528540b 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 newCopy = () => ({ successParam: 'createNewCopy' }); +const ambiguousConflict = (suffix: string) => ({ + failure: 409 as 409, + fail409Param: `ambiguous_conflict_${suffix}`, +}); -const createTestCases = (spaceId: string) => [ +const createNewCopiesTestCases = () => { // 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: 'createNewCopies' })), + { ...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, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID + { ...CASES.CONFLICT_1B_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID + { ...CASES.CONFLICT_3A_OBJ, ...fail409(!overwrite), ...destinationId() }, // "inexact match" conflict + { ...CASES.CONFLICT_4_OBJ, ...fail409(!overwrite), ...destinationId() }, // "inexact match" conflict + CASES.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, ...newCopy() }, // "ambiguous source and destination" conflict which results in a new destination ID and empty origin ID + { ...CASES.CONFLICT_2D_OBJ, ...newCopy() }, // "ambiguous source and destination" conflict which results in a new destination ID and empty origin ID + ]; + return { group1, group2, group3 }; +}; 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, createNewCopies: boolean, spaceId: string) => { + const singleRequest = true; + if (createNewCopies) { + const cases = createNewCopiesTestCases(); + return createTestDefinitions(cases, false, { createNewCopies, 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, createNewCopies] = modifier!; + const suffix = overwrite + ? ' with overwrite enabled' + : createNewCopies + ? ' with createNewCopies enabled' + : ''; + const tests = createTests(overwrite, createNewCopies, 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..1431a61b1cbe0 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,62 @@ const { SPACE_2: { spaceId: SPACE_2_ID }, } = SPACES; const { fail400, fail409 } = testCaseFailures; +const destinationId = (condition?: boolean) => + condition !== false ? { successParam: 'destinationId' } : {}; +const newCopy = () => ({ successParam: 'createNewCopy' }); -const createTestCases = (overwrite: boolean, spaceId: string) => [ +const createNewCopiesTestCases = () => { // 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: 'createNewCopies', + 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, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID + { ...CASES.CONFLICT_1B_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID + // all of the cases below represent imports that had an inexact match conflict or an ambiguous conflict + // if we call _resolve_import_errors and don't specify overwrite, each of these will result in a conflict because an object with that + // `expectedDestinationId` already exists + { ...CASES.CONFLICT_2C_OBJ, ...fail409(!overwrite), ...destinationId() }, // "ambiguous destination" conflict; if overwrite=true, will overwrite 'conflict_2a' + { ...CASES.CONFLICT_3A_OBJ, ...fail409(!overwrite), ...destinationId() }, // "inexact match" conflict; if overwrite=true, will overwrite 'conflict_3' + { ...CASES.CONFLICT_4_OBJ, ...fail409(!overwrite), ...destinationId() }, // "inexact match" conflict; if overwrite=true, will overwrite 'conflict_4a' + ]; +}; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -48,15 +86,32 @@ export default function ({ getService }: FtrProviderContext) { esArchiver, supertest ); - const createTests = (overwrite: boolean, spaceId: string) => { + const createTests = (overwrite: boolean, createNewCopies: boolean, spaceId: string) => { + const singleRequest = true; + if (createNewCopies) { + const cases = createNewCopiesTestCases(); + // The resolveImportErrors API doesn't actually have a flag for "createNewCopies" mode; rather, we create test cases as if we are resolving + // errors from a call to the import API that had createNewCopies mode enabled. + return createTestDefinitions(cases, false, { createNewCopies, 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, createNewCopies] = modifier!; + const suffix = overwrite + ? ' with overwrite enabled' + : createNewCopies + ? ' with createNewCopies enabled' + : ''; + const tests = createTests(overwrite, createNewCopies, 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 2dd4484ffcde8..2af2020794db4 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 6d80688b7a703..12fc3c371d208 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] },