diff --git a/docs/api/spaces-management.asciidoc b/docs/api/spaces-management.asciidoc index 9d34bbd56fe67..8e8d61924314a 100644 --- a/docs/api/spaces-management.asciidoc +++ b/docs/api/spaces-management.asciidoc @@ -12,8 +12,12 @@ NOTE: You cannot access these endpoints via the Console in Kibana. * <> * <> * <> +* <> +* <> include::spaces-management/post.asciidoc[] include::spaces-management/put.asciidoc[] include::spaces-management/get.asciidoc[] include::spaces-management/delete.asciidoc[] +include::spaces-management/copy_saved_objects.asciidoc[] +include::spaces-management/resolve_copy_saved_objects_conflicts.asciidoc[] diff --git a/docs/api/spaces-management/copy_saved_objects.asciidoc b/docs/api/spaces-management/copy_saved_objects.asciidoc new file mode 100644 index 0000000000000..6b51fb3fd6e71 --- /dev/null +++ b/docs/api/spaces-management/copy_saved_objects.asciidoc @@ -0,0 +1,288 @@ +[role="xpack"] +[[spaces-api-copy-saved-objects]] +=== Copy Saved Objects to Space +++++ +Copy Saved Objects to Space +++++ + +experimental[This functionality is *experimental* and may be changed or removed completely in a future release.] + +Copies saved objects from one space to other spaces. + +//// +Use the appropriate heading levels for your book. +Add anchors for each section. +FYI: The section titles use attributes in case those terms change. +//// + +[[spaces-api-copy-saved-objects-request]] +==== {api-request-title} +//// +This section show the basic endpoint, without the body or optional parameters. +Variables should use <...> syntax. +If an API supports both PUT and POST, include both here. +//// + +`POST /api/spaces/_copy_saved_objects` + +`POST /s//api/spaces/_copy_saved_objects` + + +//// +[[spaces-api-copy-saved-objects-prereqs]] +==== {api-prereq-title} +//// +//// +Optional list of prerequisites. + +For example: + +* A snapshot of an index created in 5.x can be restored to 6.x. You must... +* If the {es} {security-features} are enabled, you must have `write`, `monitor`, +and `manage_follow_index` index privileges... +//// + + +[[spaces-api-copy-saved-objects-desc]] +==== {api-description-title} + +Copy saved objects between spaces. + +It also allows you to automatically copy related objects, so when you copy a `dashboard`, this can automatically copy over the +associated visualizations, index patterns, and saved searches, as required. + +You can request to overwrite any objects that already exist in the target space if they share an ID, or you can use the +<> to do this on a per-object basis. + +//// +Add a more detailed description the context. +Link to related APIs if appropriate. + +Guidelines for parameter documentation +*************************************** +* Use a definition list. +* End each definition with a period. +* Include whether the parameter is Optional or Required and the data type. +* Include default values as the last sentence of the first paragraph. +* Include a range of valid values, if applicable. +* If the parameter requires a specific delimiter for multiple values, say so. +* If the parameter supports wildcards, ditto. +* For large or nested objects, consider linking to a separate definition list. +*************************************** +//// + + +[[spaces-api-copy-saved-objects-path-params]] +==== {api-path-parms-title} +//// +A list of all the parameters within the path of the endpoint (before the query string (?)). + +For example: +``:: +(Required, string) Name of the follower index +//// +`space_id`:: +(Optional, string) Identifies the source space from which saved objects will be copied. If `space_id` is not specified in the URL, the default space is used. + +//// +[[spaces-api-copy-saved-objects-params]] +==== {api-query-parms-title} +//// +//// +A list of the parameters in the query string of the endpoint (after the ?). + +For example: +`wait_for_active_shards`:: +(Optional, integer) Specifies the number of shards to wait on being active before +responding. A shard must be restored from the leader index being active. +Restoring a follower shard requires transferring all the remote Lucene segment +files to the follower index. The default is `0`, which means waiting on none of +the shards to be active. +//// + +[[spaces-api-copy-saved-objects-request-body]] +==== {api-request-body-title} +//// +A list of the properties you can specify in the body of the request. + +For example: +`remote_cluster`:: +(Required, string) The <> that contains +the leader index. + +`leader_index`:: +(Required, string) The name of the index in the leader cluster to follow. +//// +`spaces` :: + (Required, string array) The ids of the spaces the specified object(s) will be copied into. + +`objects` :: + (Required, object array) The saved objects to copy. + `type` ::: + (Required, string) The saved object type. + `id` ::: + (Required, string) The saved object id. + +`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`. + +`overwrite` :: + (Optional, boolean) When set to `true`, all conflicts will be automatically overidden. If a saved object with a matching `type` and `id` exists in the target space, then that version will be replaced with the version from the source space. The default value is `false`. + + +[[spaces-api-copy-saved-objects-response-body]] +==== {api-response-body-title} +//// +Response body is only required for detailed responses. + +For example: +`auto_follow_stats`:: + (object) An object representing stats for the auto-follow coordinator. This + object consists of the following fields: + +`auto_follow_stats.number_of_successful_follow_indices`::: + (long) the number of indices that the auto-follow coordinator successfully + followed +... + +//// + +``:: + (object) Specifies the dynamic keys that are included in the response. An object describing the result of the copy operation for this particular space. + `success`::: + (boolean) Indicates if the copy operation was successful. Note that some objects may have been copied even if this is set to `false`. Consult the `successCount` and `errors` properties of the response for additional information. + `successCount`::: + (number) The number of objects that were successfully copied. + `errors`::: + (Optional, array) Collection of any errors that were encountered during the copy operation. If any errors are reported, then the `success` flag will be set to `false`. + `id`:::: + (string) The saved object id which failed to copy. + `type`:::: + (string) The type of saved object which failed to copy. + `error`:::: + (object) The error which caused the copy operation to fail. + `type`::::: + (string) Indicates the type of error. May be one of: `conflict`, `unsupported_type`, `missing_references`, `unknown`. Errors marked as `conflict` may be resolved by using the <>. + +//// +[[spaces-api-copy-saved-objects-response-codes]] +==== {api-response-codes-title} +//// +//// +Response codes are only required when needed to understand the response body. + +For example: +`200`:: +Indicates all listed indices or index aliases exist. + + `404`:: +Indicates one or more listed indices or index aliases **do not** exist. +//// + + +[[spaces-api-copy-saved-objects-example]] +==== {api-examples-title} +//// +Optional brief example. +Use an 'Examples' heading if you include multiple examples. + + +[source,js] +---- +PUT /follower_index/_ccr/follow?wait_for_active_shards=1 +{ + "remote_cluster" : "remote_cluster", + "leader_index" : "leader_index", + "max_read_request_operation_count" : 1024, + "max_outstanding_read_requests" : 16, + "max_read_request_size" : "1024k", + "max_write_request_operation_count" : 32768, + "max_write_request_size" : "16k", + "max_outstanding_write_requests" : 8, + "max_write_buffer_count" : 512, + "max_write_buffer_size" : "512k", + "max_retry_delay" : "10s", + "read_poll_timeout" : "30s" +} +---- +// CONSOLE +// TEST[setup:remote_cluster_and_leader_index] + +The API returns the following result: + +[source,js] +---- +{ + "follow_index_created" : true, + "follow_index_shards_acked" : true, + "index_following_started" : true +} +---- +// TESTRESPONSE +//// + +The following example attempts to copy a dashboard with id `my-dashboard`, including all references from the `default` space to the `marketing` and `sales` spaces. The `marketing` space succeeds, while the `sales` space fails due to a conflict on the underlying index pattern: + +[source,js] +---- +POST /api/spaces/_copy_saved_objects +{ + "objects": [{ + "type": "dashboard", + "id": "my-dashboard" + }], + "spaces": ["marketing", "sales"], + "includeReferences": true +} +---- +// KIBANA + +The API returns the following result: + +[source,js] +---- +{ + "marketing": { + "success": true, + "successCount": 5 + }, + "sales": { + "success": false, + "successCount": 4, + "errors": [{ + "id": "my-index-pattern", + "type": "index-pattern", + "error": { + "type": "conflict" + } + }] + } +} +---- + +The following example successfully copies a visualization with id `my-viz` from the `marketing` space to the `default` space: + +[source,js] +---- +POST /s/marketing/api/spaces/_copy_saved_objects +{ + "objects": [{ + "type": "visualization", + "id": "my-viz" + }], + "spaces": ["default"] +} +---- +// KIBANA + +The API returns the following result: + +[source,js] +---- +{ + "default": { + "success": true, + "successCount": 1 + } +} +---- diff --git a/docs/api/spaces-management/get.asciidoc b/docs/api/spaces-management/get.asciidoc index a31aad4c1d6f9..cb38f5f113778 100644 --- a/docs/api/spaces-management/get.asciidoc +++ b/docs/api/spaces-management/get.asciidoc @@ -17,14 +17,23 @@ To retrieve all spaces, issue a GET request to the [source,js] -------------------------------------------------- GET /api/spaces/space +GET /api/spaces/space?purpose= copySavedObjectsIntoSpace -------------------------------------------------- // KIBANA +===== Request Query Parameters +purpose (optional) :: Retrieve the available spaces for a specific purpose. This parameter only has an effect when security is enabled. +`any` (default) ::: Retrieves all spaces the user is authorized to access. +`copySavedObjectsIntoSpace` ::: Retrieves all spaces the user is authorized to copy saved objects into via Saved Objects Management. + + ===== Response A successful call returns a response code of `200` and a response body containing a JSON representation of the spaces. +If you are not authorized for any spaces for the provided `purpose`, then a response code of `403` will be returned. + [source,js] -------------------------------------------------- [ diff --git a/docs/api/spaces-management/resolve_copy_saved_objects_conflicts.asciidoc b/docs/api/spaces-management/resolve_copy_saved_objects_conflicts.asciidoc new file mode 100644 index 0000000000000..0914d530f8873 --- /dev/null +++ b/docs/api/spaces-management/resolve_copy_saved_objects_conflicts.asciidoc @@ -0,0 +1,265 @@ +[role="xpack"] +[[spaces-api-resolve-copy-saved-objects-conflicts]] +=== Resolve Copy Saved Objects to Space Conflicts +++++ +Resolve copy to space conflicts +++++ + +experimental[This functionality is *experimental* and may be changed or removed completely in a future release.] + +Overwrites specific saved objects that were returned as errors from the <>. + +//// +Use the appropriate heading levels for your book. +Add anchors for each section. +FYI: The section titles use attributes in case those terms change. +//// + +[[spaces-api-resolve-copy-saved-objects-conflicts-request]] +==== {api-request-title} +//// +This section show the basic endpoint, without the body or optional parameters. +Variables should use <...> syntax. +If an API supports both PUT and POST, include both here. +//// + +`POST /api/spaces/_resolve_copy_saved_objects_errors` + +`POST /s//api/spaces/_resolve_copy_saved_objects_errors` + + + +[[spaces-api-resolve-copy-saved-objects-conflicts-prereqs]] +==== {api-prereq-title} +//// +Optional list of prerequisites. + +For example: + +* A snapshot of an index created in 5.x can be restored to 6.x. You must... +* If the {es} {security-features} are enabled, you must have `write`, `monitor`, +and `manage_follow_index` index privileges... +//// +* Executed the <>, which returned one or more `conflict` errors that you wish to resolve. + +//// +[[spaces-api-resolve-copy-saved-objects-conflicts-desc]] +==== {api-description-title} + +Allows saved objects to be selectively overridden in the target spaces. +//// + +//// +Add a more detailed description the context. +Link to related APIs if appropriate. + +Guidelines for parameter documentation +*************************************** +* Use a definition list. +* End each definition with a period. +* Include whether the parameter is Optional or Required and the data type. +* Include default values as the last sentence of the first paragraph. +* Include a range of valid values, if applicable. +* If the parameter requires a specific delimiter for multiple values, say so. +* If the parameter supports wildcards, ditto. +* For large or nested objects, consider linking to a separate definition list. +*************************************** +//// + + +[[spaces-api-resolve-copy-saved-objects-conflicts-path-params]] +==== {api-path-parms-title} +//// +A list of all the parameters within the path of the endpoint (before the query string (?)). + +For example: +``:: +(Required, string) Name of the follower index +//// +`space_id`:: +(Optional, string) Identifies the source space from which saved objects will be copied. If `space_id` is not specified in the URL, the default space is used. Must be the same value that was used during the failed <> operation. + +//// +[[spaces-api-resolve-copy-saved-objects-conflicts-request-params]] +==== {api-query-parms-title} +//// +//// +A list of the parameters in the query string of the endpoint (after the ?). + +For example: +`wait_for_active_shards`:: +(Optional, integer) Specifies the number of shards to wait on being active before +responding. A shard must be restored from the leader index being active. +Restoring a follower shard requires transferring all the remote Lucene segment +files to the follower index. The default is `0`, which means waiting on none of +the shards to be active. +//// + +[[spaces-api-resolve-copy-saved-objects-conflicts-request-body]] +==== {api-request-body-title} +//// +A list of the properties you can specify in the body of the request. + +For example: +`remote_cluster`:: +(Required, string) The <> that contains +the leader index. + +`leader_index`:: +(Required, string) The name of the index in the leader cluster to follow. +//// +`objects` :: + (Required, object array) The saved objects to copy. Must be the same value that was used during the failed <> operation. + `type` ::: + (Required, string) The saved object type. + `id` ::: + (Required, string) The saved object id. + +`includeReferences` :: + (Optional, boolean) When set to `true`, all saved objects related to the specified saved objects will also be copied into the target spaces. You must set this to the same value that you used when executing the <>. The default value is `false`. + +`retries`:: + (Required, object) The retry operations to attempt. Object keys represent the target space ids. + `` ::: + (Required, array) The the conflicts to resolve for the indicated ``. + `type` :::: + (Required, string) The saved object type. + `id` :::: + (Required, string) The saved object id. + `overwrite` :::: + (Required, boolean) when set to `true`, the saved object from the source space (desigated by the <>) will overwrite the the conflicting object in the destination space. When `false`, this does nothing. + + +[[spaces-api-resolve-copy-saved-objects-conflicts-response-body]] +==== {api-response-body-title} +//// +Response body is only required for detailed responses. + +For example: +`auto_follow_stats`:: + (object) An object representing stats for the auto-follow coordinator. This + object consists of the following fields: + +`auto_follow_stats.number_of_successful_follow_indices`::: + (long) the number of indices that the auto-follow coordinator successfully + followed +... + +//// + +``:: + (object) Specifies the dynamic keys that are included in the response. An object describing the result of the copy operation for this particular space. + `success`::: + (boolean) Indicates if the copy operation was successful. Note that some objects may have been copied even if this is set to `false`. Consult the `successCount` and `errors` properties of the response for additional information. + `successCount`::: + (number) The number of objects that were successfully copied. + `errors`::: + (Optional, array) Collection of any errors that were encountered during the copy operation. If any errors are reported, then the `success` flag will be set to `false`. + `id`:::: + (string) The saved object id which failed to copy. + `type`:::: + (string) The type of saved object which failed to copy. + `error`:::: + (object) The error which caused the copy operation to fail. + `type`::::: + (string) Indicates the type of error. May be one of: `unsupported_type`, `missing_references`, `unknown`. + +//// +[[spaces-api-resolve-copy-saved-objects-conflicts-response-codes]] +==== {api-response-codes-title} +//// +//// +Response codes are only required when needed to understand the response body. + +For example: +`200`:: +Indicates all listed indices or index aliases exist. + + `404`:: +Indicates one or more listed indices or index aliases **do not** exist. +//// + + +[[spaces-api-resolve-copy-saved-objects-conflicts-example]] +==== {api-examples-title} +//// +Optional brief example. +Use an 'Examples' heading if you include multiple examples. + + +[source,js] +---- +PUT /follower_index/_ccr/follow?wait_for_active_shards=1 +{ + "remote_cluster" : "remote_cluster", + "leader_index" : "leader_index", + "max_read_request_operation_count" : 1024, + "max_outstanding_read_requests" : 16, + "max_read_request_size" : "1024k", + "max_write_request_operation_count" : 32768, + "max_write_request_size" : "16k", + "max_outstanding_write_requests" : 8, + "max_write_buffer_count" : 512, + "max_write_buffer_size" : "512k", + "max_retry_delay" : "10s", + "read_poll_timeout" : "30s" +} +---- +// CONSOLE +// TEST[setup:remote_cluster_and_leader_index] + +The API returns the following result: + +[source,js] +---- +{ + "follow_index_created" : true, + "follow_index_shards_acked" : true, + "index_following_started" : true +} +---- +// TESTRESPONSE +//// + +The following example overwrites an index pattern in the marketing space, and a visualization in the sales space. + +[source,js] +---- +POST api/spaces/_resolve_copy_saved_objects_errors +{ + "objects": [{ + "type": "dashboard", + "id": "my-dashboard" + }], + "includeReferences": true, + "retries": { + "marketing": [{ + "type": "index-pattern", + "id": "my-pattern", + "overwrite": true + }], + "sales": [{ + "type": "visualization", + "id": "my-viz", + "overwrite": true + }] + } +} +---- +// KIBANA + +The API returns the following result: + +[source,js] +---- +{ + "marketing": { + "success": true, + "successCount": 1 + }, + "sales": { + "success": true, + "successCount": 1 + } +} +---- diff --git a/x-pack/dev-tools/jest/create_jest_config.js b/x-pack/dev-tools/jest/create_jest_config.js index e2cb873f54fd0..21ef201a4d3a8 100644 --- a/x-pack/dev-tools/jest/create_jest_config.js +++ b/x-pack/dev-tools/jest/create_jest_config.js @@ -19,6 +19,7 @@ export function createJestConfig({ kibanaDirectory, xPackKibanaDirectory }) { '^ui/(.*)': `${kibanaDirectory}/src/legacy/ui/public/$1`, 'uiExports/(.*)': fileMockPath, '^src/core/(.*)': `${kibanaDirectory}/src/core/$1`, + '^src/legacy/(.*)': `${kibanaDirectory}/src/legacy/$1`, '^plugins/watcher/models/(.*)': `${xPackKibanaDirectory}/legacy/plugins/watcher/public/models/$1`, '^plugins/([^/.]*)(.*)': `${kibanaDirectory}/src/legacy/core_plugins/$1/public$2`, '^legacy/plugins/xpack_main/(.*);': `${xPackKibanaDirectory}/legacy/plugins/xpack_main/public/$1`, diff --git a/x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.test.ts b/x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.test.ts new file mode 100644 index 0000000000000..3128165d78c7e --- /dev/null +++ b/x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.test.ts @@ -0,0 +1,401 @@ +/* + * 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 { + SavedObjectsSchema, + SavedObjectsService, + SavedObjectsClientContract, + SavedObjectsImportResponse, + SavedObjectsImportOptions, + SavedObjectsExportOptions, +} from 'src/core/server'; +import { copySavedObjectsToSpacesFactory } from './copy_to_spaces'; +import { Readable } from 'stream'; + +interface SetupOpts { + objects: Array<{ type: string; id: string; attributes: Record }>; + getSortedObjectsForExportImpl?: (opts: SavedObjectsExportOptions) => Promise; + importSavedObjectsImpl?: (opts: SavedObjectsImportOptions) => Promise; +} + +const expectStreamToContainObjects = async ( + stream: Readable, + expectedObjects: SetupOpts['objects'] +) => { + const objectsToResolve: unknown[] = await new Promise((resolve, reject) => { + const objects: SetupOpts['objects'] = []; + stream.on('data', chunk => { + objects.push(chunk); + }); + stream.on('end', () => resolve(objects)); + stream.on('error', err => reject(err)); + }); + + // Ensure the Readable stream passed to `resolveImportErrors` contains all of the expected objects. + // Verifies functionality for `readStreamToCompletion` and `createReadableStreamFromArray` + expect(objectsToResolve).toEqual(expectedObjects); +}; + +describe('copySavedObjectsToSpaces', () => { + const setup = (setupOpts: SetupOpts) => { + const savedObjectsClient = (null as unknown) as SavedObjectsClientContract; + + const savedObjectsService: SavedObjectsService = ({ + importExport: { + objectLimit: 1000, + getSortedObjectsForExport: + setupOpts.getSortedObjectsForExportImpl || + jest.fn().mockResolvedValue( + new Readable({ + objectMode: true, + read() { + setupOpts.objects.forEach(o => this.push(o)); + + this.push(null); + }, + }) + ), + importSavedObjects: + setupOpts.importSavedObjectsImpl || + jest.fn().mockImplementation(async (importOpts: SavedObjectsImportOptions) => { + await expectStreamToContainObjects(importOpts.readStream, setupOpts.objects); + const response: SavedObjectsImportResponse = { + success: true, + successCount: setupOpts.objects.length, + }; + + return Promise.resolve(response); + }), + }, + types: ['dashboard', 'visualization', 'globalType'], + schema: new SavedObjectsSchema({ + globalType: { isNamespaceAgnostic: true }, + }), + } as unknown) as SavedObjectsService; + + return { + savedObjectsClient, + savedObjectsService, + }; + }; + + it('uses the Saved Objects Service to perform an export followed by a series of imports', async () => { + const { savedObjectsClient, savedObjectsService } = setup({ + objects: [ + { + type: 'dashboard', + id: 'my-dashboard', + attributes: {}, + }, + { + type: 'visualization', + id: 'my-viz', + attributes: {}, + }, + { + type: 'index-pattern', + id: 'my-index-pattern', + attributes: {}, + }, + ], + }); + + const copySavedObjectsToSpaces = copySavedObjectsToSpacesFactory( + savedObjectsClient, + savedObjectsService + ); + + const result = await copySavedObjectsToSpaces('sourceSpace', ['destination1', 'destination2'], { + includeReferences: true, + overwrite: true, + objects: [ + { + type: 'dashboard', + id: 'my-dashboard', + }, + ], + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "destination1": Object { + "errors": undefined, + "success": true, + "successCount": 3, + }, + "destination2": Object { + "errors": undefined, + "success": true, + "successCount": 3, + }, + } + `); + + expect((savedObjectsService.importExport.getSortedObjectsForExport as jest.Mock).mock.calls) + .toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "exportSizeLimit": 1000, + "includeReferencesDeep": true, + "namespace": "sourceSpace", + "objects": Array [ + Object { + "id": "my-dashboard", + "type": "dashboard", + }, + ], + "savedObjectsClient": null, + "types": Array [ + "dashboard", + "visualization", + ], + }, + ], + ] + `); + + expect((savedObjectsService.importExport.importSavedObjects 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 { + "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": null, + "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 { + "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": null, + "supportedTypes": Array [ + "dashboard", + "visualization", + ], + }, + ], + ] + `); + }); + + 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 { savedObjectsClient, savedObjectsService } = setup({ + objects, + importSavedObjectsImpl: async opts => { + if (opts.namespace === 'failure-space') { + throw new Error(`Some error occurred!`); + } + await expectStreamToContainObjects(opts.readStream, objects); + return Promise.resolve({ + success: true, + successCount: 3, + }); + }, + }); + + const copySavedObjectsToSpaces = copySavedObjectsToSpacesFactory( + savedObjectsClient, + savedObjectsService + ); + + const result = await copySavedObjectsToSpaces( + 'sourceSpace', + ['failure-space', 'non-existent-space', 'marketing'], + { + includeReferences: true, + overwrite: true, + objects: [ + { + type: 'dashboard', + id: 'my-dashboard', + }, + ], + } + ); + + 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, + }, + } + `); + }); + + it(`handles stream read errors`, async () => { + const { savedObjectsClient, savedObjectsService } = setup({ + objects: [ + { + type: 'dashboard', + id: 'my-dashboard', + attributes: {}, + }, + { + type: 'visualization', + id: 'my-viz', + attributes: {}, + }, + { + type: 'index-pattern', + id: 'my-index-pattern', + attributes: {}, + }, + ], + getSortedObjectsForExportImpl: opts => { + return Promise.resolve( + new Readable({ + objectMode: true, + read() { + this.emit('error', new Error('Something went wrong while reading this stream')); + }, + }) + ); + }, + }); + + const copySavedObjectsToSpaces = copySavedObjectsToSpacesFactory( + savedObjectsClient, + savedObjectsService + ); + + await expect( + copySavedObjectsToSpaces( + 'sourceSpace', + ['failure-space', 'non-existent-space', 'marketing'], + { + includeReferences: true, + overwrite: true, + objects: [ + { + type: 'dashboard', + id: 'my-dashboard', + }, + ], + } + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Something went wrong while reading this stream"` + ); + }); +}); diff --git a/x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.ts b/x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.ts new file mode 100644 index 0000000000000..40acf8fc32cba --- /dev/null +++ b/x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.ts @@ -0,0 +1,90 @@ +/* + * 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 { SavedObjectsClientContract, SavedObjectsService, SavedObject } from 'src/core/server'; +import { Readable } from 'stream'; +import { SavedObjectsClientProviderOptions } 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'; + +export const COPY_TO_SPACES_SAVED_OBJECTS_CLIENT_OPTS: SavedObjectsClientProviderOptions = { + excludedWrappers: ['spaces'], +}; + +export function copySavedObjectsToSpacesFactory( + savedObjectsClient: SavedObjectsClientContract, + savedObjectsService: SavedObjectsService +) { + const { importExport, types, schema } = savedObjectsService; + const eligibleTypes = getEligibleTypes({ types, schema }); + + const exportRequestedObjects = async ( + sourceSpaceId: string, + options: Pick + ) => { + const objectStream = await importExport.getSortedObjectsForExport({ + namespace: spaceIdToNamespace(sourceSpaceId), + includeReferencesDeep: options.includeReferences, + objects: options.objects, + savedObjectsClient, + types: eligibleTypes, + exportSizeLimit: importExport.objectLimit, + }); + + return readStreamToCompletion(objectStream); + }; + + const importObjectsToSpace = async ( + spaceId: string, + objectsStream: Readable, + options: CopyOptions + ) => { + try { + const importResponse = await importExport.importSavedObjects({ + namespace: spaceIdToNamespace(spaceId), + objectLimit: importExport.objectLimit, + overwrite: options.overwrite, + savedObjectsClient, + supportedTypes: eligibleTypes, + readStream: objectsStream, + }); + + return { + success: importResponse.success, + successCount: importResponse.successCount, + errors: importResponse.errors, + }; + } catch (error) { + return createEmptyFailureResponse([error]); + } + }; + + const copySavedObjectsToSpaces = async ( + sourceSpaceId: string, + destinationSpaceIds: string[], + options: CopyOptions + ): Promise => { + const response: CopyResponse = {}; + + const exportedSavedObjects = await exportRequestedObjects(sourceSpaceId, options); + + for (const spaceId of destinationSpaceIds) { + response[spaceId] = await importObjectsToSpace( + spaceId, + createReadableStreamFromArray(exportedSavedObjects), + options + ); + } + + return response; + }; + + return copySavedObjectsToSpaces; +} diff --git a/x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/index.ts b/x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/index.ts new file mode 100644 index 0000000000000..7545d2e16dfbb --- /dev/null +++ b/x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/index.ts @@ -0,0 +1,9 @@ +/* + * 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. + */ + +export { copySavedObjectsToSpacesFactory } from './copy_to_spaces'; +export { resolveCopySavedObjectsToSpacesConflictsFactory } from './resolve_copy_conflicts'; +export { CopyResponse } from './types'; diff --git a/x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/lib/create_empty_failure_response.ts b/x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/lib/create_empty_failure_response.ts new file mode 100644 index 0000000000000..99156c58082c3 --- /dev/null +++ b/x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/lib/create_empty_failure_response.ts @@ -0,0 +1,22 @@ +/* + * 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 Boom, { Payload } from 'boom'; +import { SavedObjectsImportError } from 'src/core/server'; + +export const createEmptyFailureResponse = (errors?: Array) => { + const errorMessages: Array = (errors || []).map(error => { + if (Boom.isBoom(error as any)) { + return (error as Boom).output.payload as Payload; + } + return error as SavedObjectsImportError; + }); + + return { + success: false, + successCount: 0, + errors: errorMessages, + }; +}; diff --git a/x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/lib/get_eligible_types.ts b/x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/lib/get_eligible_types.ts new file mode 100644 index 0000000000000..203f41860ba3e --- /dev/null +++ b/x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/lib/get_eligible_types.ts @@ -0,0 +1,11 @@ +/* + * 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 { SavedObjectsService } from 'src/core/server'; + +export function getEligibleTypes({ types, schema }: Pick) { + return types.filter(type => !schema.isNamespaceAgnostic(type)); +} diff --git a/x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/lib/read_stream_to_completion.ts b/x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/lib/read_stream_to_completion.ts new file mode 100644 index 0000000000000..bfa8acf08cae2 --- /dev/null +++ b/x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/lib/read_stream_to_completion.ts @@ -0,0 +1,30 @@ +/* + * 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 { Readable, pipeline, Writable } from 'stream'; + +export const readStreamToCompletion = (stream: Readable) => { + return new Promise((resolve, reject) => { + const chunks: T[] = []; + pipeline( + stream, + new Writable({ + objectMode: true, + write(chunk, enc, done) { + chunks.push(chunk); + done(); + }, + }), + err => { + if (err) { + reject(err); + } else { + resolve(chunks); + } + } + ); + }); +}; diff --git a/x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/lib/readable_stream_from_array.ts b/x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/lib/readable_stream_from_array.ts new file mode 100644 index 0000000000000..e1dd0a4e1cc1e --- /dev/null +++ b/x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/lib/readable_stream_from_array.ts @@ -0,0 +1,18 @@ +/* + * 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 { Readable } from 'stream'; + +// TODO: Remove in favor of Readable.from once we upgrade to Node 12.x +export const createReadableStreamFromArray = (array: unknown[]) => { + return new Readable({ + objectMode: true, + read() { + array.forEach(entry => this.push(entry)); + this.push(null); + }, + }); +}; diff --git a/x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.test.ts b/x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.test.ts new file mode 100644 index 0000000000000..8fa0d03283f9c --- /dev/null +++ b/x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.test.ts @@ -0,0 +1,429 @@ +/* + * 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 { + SavedObjectsSchema, + SavedObjectsService, + SavedObjectsClientContract, + SavedObjectsImportResponse, + SavedObjectsResolveImportErrorsOptions, + SavedObjectsExportOptions, +} from 'src/core/server'; +import { Readable } from 'stream'; +import { resolveCopySavedObjectsToSpacesConflictsFactory } from './resolve_copy_conflicts'; + +interface SetupOpts { + objects: Array<{ type: string; id: string; attributes: Record }>; + getSortedObjectsForExportImpl?: (opts: SavedObjectsExportOptions) => Promise; + resolveImportErrorsImpl?: ( + opts: SavedObjectsResolveImportErrorsOptions + ) => Promise; +} + +const expectStreamToContainObjects = async ( + stream: Readable, + expectedObjects: SetupOpts['objects'] +) => { + const objectsToResolve: unknown[] = await new Promise((resolve, reject) => { + const objects: SetupOpts['objects'] = []; + stream.on('data', chunk => { + objects.push(chunk); + }); + stream.on('end', () => resolve(objects)); + stream.on('error', err => reject(err)); + }); + + // Ensure the Readable stream passed to `resolveImportErrors` contains all of the expected objects. + // Verifies functionality for `readStreamToCompletion` and `createReadableStreamFromArray` + expect(objectsToResolve).toEqual(expectedObjects); +}; + +describe('resolveCopySavedObjectsToSpacesConflicts', () => { + const setup = (setupOpts: SetupOpts) => { + const savedObjectsService: SavedObjectsService = ({ + importExport: { + objectLimit: 1000, + getSortedObjectsForExport: + setupOpts.getSortedObjectsForExportImpl || + jest.fn().mockResolvedValue( + new Readable({ + objectMode: true, + read() { + setupOpts.objects.forEach(o => this.push(o)); + + this.push(null); + }, + }) + ), + resolveImportErrors: + setupOpts.resolveImportErrorsImpl || + jest + .fn() + .mockImplementation(async (resolveOpts: SavedObjectsResolveImportErrorsOptions) => { + await expectStreamToContainObjects(resolveOpts.readStream, setupOpts.objects); + + const response: SavedObjectsImportResponse = { + success: true, + successCount: setupOpts.objects.length, + }; + + return response; + }), + }, + types: ['dashboard', 'visualization', 'globalType'], + schema: new SavedObjectsSchema({ + globalType: { isNamespaceAgnostic: true }, + }), + } as unknown) as SavedObjectsService; + + const savedObjectsClient = (null as unknown) as SavedObjectsClientContract; + + return { + savedObjectsClient, + savedObjectsService, + }; + }; + + it('uses the Saved Objects Service to perform an export followed by a series of conflict resolution calls', async () => { + const { savedObjectsClient, savedObjectsService } = setup({ + objects: [ + { + type: 'dashboard', + id: 'my-dashboard', + attributes: {}, + }, + { + type: 'visualization', + id: 'my-viz', + attributes: {}, + }, + { + type: 'index-pattern', + id: 'my-index-pattern', + attributes: {}, + }, + ], + }); + + const resolveCopySavedObjectsToSpacesConflicts = resolveCopySavedObjectsToSpacesConflictsFactory( + savedObjectsClient, + savedObjectsService + ); + + const result = await resolveCopySavedObjectsToSpacesConflicts('sourceSpace', { + 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, + }, + ], + }, + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "destination1": Object { + "errors": undefined, + "success": true, + "successCount": 3, + }, + "destination2": Object { + "errors": undefined, + "success": true, + "successCount": 3, + }, + } + `); + + expect((savedObjectsService.importExport.getSortedObjectsForExport as jest.Mock).mock.calls) + .toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "exportSizeLimit": 1000, + "includeReferencesDeep": true, + "namespace": "sourceSpace", + "objects": Array [ + Object { + "id": "my-dashboard", + "type": "dashboard", + }, + ], + "savedObjectsClient": null, + "types": Array [ + "dashboard", + "visualization", + ], + }, + ], + ] + `); + + expect((savedObjectsService.importExport.resolveImportErrors 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 { + "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": null, + "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 { + "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": null, + "supportedTypes": Array [ + "dashboard", + "visualization", + ], + }, + ], + ] + `); + }); + + 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 { savedObjectsClient, savedObjectsService } = setup({ + objects, + resolveImportErrorsImpl: async opts => { + if (opts.namespace === 'failure-space') { + throw new Error(`Some error occurred!`); + } + await expectStreamToContainObjects(opts.readStream, objects); + return Promise.resolve({ + success: true, + successCount: 3, + }); + }, + }); + + const resolveCopySavedObjectsToSpacesConflicts = resolveCopySavedObjectsToSpacesConflictsFactory( + savedObjectsClient, + savedObjectsService + ); + + const result = await resolveCopySavedObjectsToSpacesConflicts('sourceSpace', { + includeReferences: true, + objects: [ + { + type: 'dashboard', + id: 'my-dashboard', + }, + ], + retries: { + ['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, + }, + ], + }, + }); + + 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, + }, + } + `); + }); + + it(`handles stream read errors`, async () => { + const { savedObjectsClient, savedObjectsService } = setup({ + objects: [], + getSortedObjectsForExportImpl: opts => { + return Promise.resolve( + new Readable({ + objectMode: true, + read() { + this.emit('error', new Error('Something went wrong while reading this stream')); + }, + }) + ); + }, + }); + + const resolveCopySavedObjectsToSpacesConflicts = resolveCopySavedObjectsToSpacesConflictsFactory( + savedObjectsClient, + savedObjectsService + ); + + await expect( + resolveCopySavedObjectsToSpacesConflicts('sourceSpace', { + includeReferences: true, + objects: [], + retries: {}, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Something went wrong while reading this stream"` + ); + }); +}); diff --git a/x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.ts b/x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.ts new file mode 100644 index 0000000000000..38c7b068d5729 --- /dev/null +++ b/x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.ts @@ -0,0 +1,95 @@ +/* + * 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 { SavedObjectsClientContract, SavedObjectsService, SavedObject } from 'src/core/server'; +import { Readable } from 'stream'; +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'; + +export function resolveCopySavedObjectsToSpacesConflictsFactory( + savedObjectsClient: SavedObjectsClientContract, + savedObjectsService: SavedObjectsService +) { + const { importExport, types, schema } = savedObjectsService; + const eligibleTypes = getEligibleTypes({ types, schema }); + + const exportRequestedObjects = async ( + sourceSpaceId: string, + options: Pick + ) => { + const objectStream = await importExport.getSortedObjectsForExport({ + namespace: spaceIdToNamespace(sourceSpaceId), + includeReferencesDeep: options.includeReferences, + objects: options.objects, + savedObjectsClient, + types: eligibleTypes, + exportSizeLimit: importExport.objectLimit, + }); + return readStreamToCompletion(objectStream); + }; + + const resolveConflictsForSpace = async ( + spaceId: string, + objectsStream: Readable, + retries: Array<{ + type: string; + id: string; + overwrite: boolean; + replaceReferences: Array<{ type: string; from: string; to: string }>; + }> + ) => { + try { + const importResponse = await importExport.resolveImportErrors({ + namespace: spaceIdToNamespace(spaceId), + objectLimit: importExport.objectLimit, + savedObjectsClient, + supportedTypes: eligibleTypes, + readStream: objectsStream, + retries, + }); + + return { + success: importResponse.success, + successCount: importResponse.successCount, + errors: importResponse.errors, + }; + } catch (error) { + return createEmptyFailureResponse([error]); + } + }; + + const resolveCopySavedObjectsToSpacesConflicts = async ( + sourceSpaceId: string, + options: ResolveConflictsOptions + ): Promise => { + const response: CopyResponse = {}; + + const exportedSavedObjects = await exportRequestedObjects(sourceSpaceId, { + includeReferences: options.includeReferences, + objects: options.objects, + }); + + for (const entry of Object.entries(options.retries)) { + const [spaceId, entryRetries] = entry; + + const retries = entryRetries.map(retry => ({ ...retry, replaceReferences: [] })); + + response[spaceId] = await resolveConflictsForSpace( + spaceId, + createReadableStreamFromArray(exportedSavedObjects), + retries + ); + } + + return response; + }; + + return resolveCopySavedObjectsToSpacesConflicts; +} diff --git a/x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/types.ts b/x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/types.ts new file mode 100644 index 0000000000000..1bbe5aa6625b0 --- /dev/null +++ b/x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/types.ts @@ -0,0 +1,30 @@ +/* + * 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 { Payload } from 'boom'; +import { SavedObjectsImportError } from 'src/core/server'; + +export interface CopyOptions { + objects: Array<{ type: string; id: string }>; + overwrite: boolean; + includeReferences: boolean; +} + +export interface ResolveConflictsOptions { + objects: Array<{ type: string; id: string }>; + includeReferences: boolean; + retries: { + [spaceId: string]: Array<{ type: string; id: string; overwrite: boolean }>; + }; +} + +export interface CopyResponse { + [spaceId: string]: { + success: boolean; + successCount: number; + errors?: Array; + }; +} diff --git a/x-pack/legacy/plugins/spaces/server/lib/space_schema.ts b/x-pack/legacy/plugins/spaces/server/lib/space_schema.ts index 824c5f27d5fda..c298e62b9bf0c 100644 --- a/x-pack/legacy/plugins/spaces/server/lib/space_schema.ts +++ b/x-pack/legacy/plugins/spaces/server/lib/space_schema.ts @@ -7,8 +7,10 @@ import Joi from 'joi'; import { MAX_SPACE_INITIALS } from '../../common/constants'; +export const SPACE_ID_REGEX = /^[a-z0-9_\-]+$/; + export const spaceSchema = Joi.object({ - id: Joi.string().regex(/^[a-z0-9_\-]+$/, `lower case, a-z, 0-9, "_", and "-" are allowed`), + id: Joi.string().regex(SPACE_ID_REGEX, `lower case, a-z, 0-9, "_", and "-" are allowed`), name: Joi.string().required(), description: Joi.string().allow(''), initials: Joi.string().max(MAX_SPACE_INITIALS), diff --git a/x-pack/legacy/plugins/spaces/server/lib/spaces_client/__snapshots__/spaces_client.test.ts.snap b/x-pack/legacy/plugins/spaces/server/lib/spaces_client/__snapshots__/spaces_client.test.ts.snap index 3d3049a2c290e..a0fa3a2c75eab 100644 --- a/x-pack/legacy/plugins/spaces/server/lib/spaces_client/__snapshots__/spaces_client.test.ts.snap +++ b/x-pack/legacy/plugins/spaces/server/lib/spaces_client/__snapshots__/spaces_client.test.ts.snap @@ -18,6 +18,14 @@ exports[`#delete authorization.mode.useRbacForRequest returns true throws bad re exports[`#get useRbacForRequest is true throws Boom.forbidden if the user isn't authorized at space 1`] = `"Unauthorized to get foo-space space"`; -exports[`#getAll useRbacForRequest is true throws Boom.forbidden when user isn't authorized for any spaces 1`] = `"Forbidden"`; +exports[`#getAll authorization.mode.useRbacForRequest returns false throws Boom.badRequest when an invalid purpose is provided' 1`] = `"unsupported space purpose: invalid_purpose"`; + +exports[`#getAll useRbacForRequest is true throws Boom.badRequest when an invalid purpose is provided 1`] = `"unsupported space purpose: invalid_purpose"`; + +exports[`#getAll useRbacForRequest is true with purpose='any' throws Boom.forbidden when user isn't authorized for any spaces 1`] = `"Forbidden"`; + +exports[`#getAll useRbacForRequest is true with purpose='copySavedObjectsIntoSpace' throws Boom.forbidden when user isn't authorized for any spaces 1`] = `"Forbidden"`; + +exports[`#getAll useRbacForRequest is true with purpose='undefined' throws Boom.forbidden when user isn't authorized for any spaces 1`] = `"Forbidden"`; exports[`#update useRbacForRequest is true throws Boom.forbidden when user isn't authorized at space 1`] = `"Unauthorized to update spaces"`; diff --git a/x-pack/legacy/plugins/spaces/server/lib/spaces_client/index.ts b/x-pack/legacy/plugins/spaces/server/lib/spaces_client/index.ts index 54c778ae3839e..de0039e6e39c2 100644 --- a/x-pack/legacy/plugins/spaces/server/lib/spaces_client/index.ts +++ b/x-pack/legacy/plugins/spaces/server/lib/spaces_client/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { SpacesClient } from './spaces_client'; +export { SpacesClient, GetSpacePurpose } from './spaces_client'; diff --git a/x-pack/legacy/plugins/spaces/server/lib/spaces_client/spaces_client.mock.ts b/x-pack/legacy/plugins/spaces/server/lib/spaces_client/spaces_client.mock.ts index 49cafd7692eb3..d773cd86ef688 100644 --- a/x-pack/legacy/plugins/spaces/server/lib/spaces_client/spaces_client.mock.ts +++ b/x-pack/legacy/plugins/spaces/server/lib/spaces_client/spaces_client.mock.ts @@ -6,33 +6,30 @@ import { DEFAULT_SPACE_ID } from '../../../common/constants'; import { Space } from '../../../common/model/space'; - -const createSpacesClientMock = () => ({ - canEnumerateSpaces: jest.fn().mockResolvedValue(true), - - getAll: jest.fn().mockResolvedValue([ - { - id: DEFAULT_SPACE_ID, - name: 'mock default space', - disabledFeatures: [], - _reserved: true, - }, - ]), - - get: jest.fn().mockImplementation((spaceId: string) => { - return Promise.resolve({ - id: spaceId, - name: `mock space for ${spaceId}`, - disabledFeatures: [], - }); - }), - - create: jest.fn().mockImplementation((space: Space) => Promise.resolve(space)), - - update: jest.fn().mockImplementation((space: Space) => Promise.resolve(space)), - - delete: jest.fn(), -}); +import { SpacesClient } from './spaces_client'; + +const createSpacesClientMock = () => + (({ + canEnumerateSpaces: jest.fn().mockResolvedValue(true), + getAll: jest.fn().mockResolvedValue([ + { + id: DEFAULT_SPACE_ID, + name: 'mock default space', + disabledFeatures: [], + _reserved: true, + }, + ]), + get: jest.fn().mockImplementation((spaceId: string) => { + return Promise.resolve({ + id: spaceId, + name: `mock space for ${spaceId}`, + disabledFeatures: [], + }); + }), + create: jest.fn().mockImplementation((space: Space) => Promise.resolve(space)), + update: jest.fn().mockImplementation((space: Space) => Promise.resolve(space)), + delete: jest.fn(), + } as unknown) as SpacesClient); export const spacesClientMock = { create: createSpacesClientMock, diff --git a/x-pack/legacy/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts b/x-pack/legacy/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts index 8228121423232..5e82b75ce6014 100644 --- a/x-pack/legacy/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts +++ b/x-pack/legacy/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SpacesClient } from './spaces_client'; +import { SpacesClient, GetSpacePurpose } from './spaces_client'; import { AuthorizationService } from '../../../../security/server/lib/authorization/service'; import { actionsFactory } from '../../../../security/server/lib/authorization/actions'; import { SpacesConfigType, config } from '../../new_platform/config'; @@ -186,120 +186,209 @@ describe('#getAll', () => { expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledTimes(0); expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledTimes(0); }); - }); - describe('useRbacForRequest is true', () => { - test(`throws Boom.forbidden when user isn't authorized for any spaces`, async () => { - const username = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const mockDebugLogger = createMockDebugLogger(); - const { mockAuthorization, mockCheckPrivilegesAtSpaces } = createMockAuthorization(); - mockAuthorization.mode.useRbacForRequest.mockReturnValue(true); - mockCheckPrivilegesAtSpaces.mockReturnValue({ - username, - spacePrivileges: { - [savedObjects[0].id]: { - [mockAuthorization.actions.login]: false, - }, - [savedObjects[1].id]: { - [mockAuthorization.actions.login]: false, - }, - }, - }); - const maxSpaces = 1234; - const mockConfig = createMockConfig({ - maxSpaces: 1234, - }); - const mockInternalRepository = { - find: jest.fn().mockReturnValue({ - saved_objects: savedObjects, - }), - }; + test(`throws Boom.badRequest when an invalid purpose is provided'`, async () => { + const { mockAuthorization } = createMockAuthorization(); + mockAuthorization.mode.useRbacForRequest.mockReturnValue(false); + const request = Symbol() as any; const client = new SpacesClient( - mockAuditLogger as any, - mockDebugLogger, + null as any, + null as any, mockAuthorization, + null as any, + null as any, null, - mockConfig, - mockInternalRepository, request ); - await expect(client.getAll()).rejects.toThrowErrorMatchingSnapshot(); + await expect( + client.getAll('invalid_purpose' as GetSpacePurpose) + ).rejects.toThrowErrorMatchingSnapshot(); - expect(mockInternalRepository.find).toHaveBeenCalledWith({ - type: 'space', - page: 1, - perPage: maxSpaces, - sortField: 'name.keyword', - }); - expect(mockAuthorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); - expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); - expect(mockCheckPrivilegesAtSpaces).toHaveBeenCalledWith( - savedObjects.map(savedObject => savedObject.id), - mockAuthorization.actions.login - ); - expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledWith(username, 'getAll'); - expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledTimes(0); + expect(mockAuthorization.mode.useRbacForRequest).toHaveBeenCalledTimes(0); }); + }); - test(`returns spaces that the user is authorized for`, async () => { - const username = Symbol(); + describe('useRbacForRequest is true', () => { + it('throws Boom.badRequest when an invalid purpose is provided', async () => { const mockAuditLogger = createMockAuditLogger(); - const mockDebugLogger = createMockDebugLogger(); const { mockAuthorization, mockCheckPrivilegesAtSpaces } = createMockAuthorization(); + mockAuthorization.mode.useRbacForRequest.mockReturnValue(true); - mockCheckPrivilegesAtSpaces.mockReturnValue({ - username, - spacePrivileges: { - [savedObjects[0].id]: { - [mockAuthorization.actions.login]: true, - }, - [savedObjects[1].id]: { - [mockAuthorization.actions.login]: false, - }, - }, - }); + const mockInternalRepository = { find: jest.fn().mockReturnValue({ saved_objects: savedObjects, }), }; - const maxSpaces = 1234; - const mockConfig = createMockConfig({ - maxSpaces: 1234, - }); const request = Symbol() as any; const client = new SpacesClient( mockAuditLogger as any, - mockDebugLogger, + null as any, mockAuthorization, null, - mockConfig, + null as any, mockInternalRepository, request ); - const actualSpaces = await client.getAll(); + await expect( + client.getAll('invalid_purpose' as GetSpacePurpose) + ).rejects.toThrowErrorMatchingSnapshot(); + + expect(mockInternalRepository.find).not.toHaveBeenCalled(); + expect(mockAuthorization.mode.useRbacForRequest).not.toHaveBeenCalled(); + expect(mockAuthorization.checkPrivilegesWithRequest).not.toHaveBeenCalled(); + expect(mockCheckPrivilegesAtSpaces).not.toHaveBeenCalled(); + expect(mockAuditLogger.spacesAuthorizationFailure).not.toHaveBeenCalled(); + expect(mockAuditLogger.spacesAuthorizationSuccess).not.toHaveBeenCalled(); + }); - expect(actualSpaces).toEqual([expectedSpaces[0]]); - expect(mockInternalRepository.find).toHaveBeenCalledWith({ - type: 'space', - page: 1, - perPage: maxSpaces, - sortField: 'name.keyword', + [ + { + purpose: undefined, + expectedPrivilege: (mockAuthorization: MockedAuthorization) => + mockAuthorization.actions.login, + }, + { + purpose: 'any', + expectedPrivilege: (mockAuthorization: MockedAuthorization) => + mockAuthorization.actions.login, + }, + { + purpose: 'copySavedObjectsIntoSpace', + expectedPrivilege: (mockAuthorization: MockedAuthorization) => + mockAuthorization.actions.ui.get('savedObjectsManagement', 'copyIntoSpace'), + }, + ].forEach(scenario => { + describe(`with purpose='${scenario.purpose}'`, () => { + test(`throws Boom.forbidden when user isn't authorized for any spaces`, async () => { + const username = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const mockDebugLogger = createMockDebugLogger(); + const { mockAuthorization, mockCheckPrivilegesAtSpaces } = createMockAuthorization(); + + const privilege = scenario.expectedPrivilege(mockAuthorization); + + mockAuthorization.mode.useRbacForRequest.mockReturnValue(true); + mockCheckPrivilegesAtSpaces.mockReturnValue({ + username, + spacePrivileges: { + [savedObjects[0].id]: { + [privilege]: false, + }, + [savedObjects[1].id]: { + [privilege]: false, + }, + }, + }); + const maxSpaces = 1234; + const mockConfig = createMockConfig({ + maxSpaces: 1234, + }); + const mockInternalRepository = { + find: jest.fn().mockReturnValue({ + saved_objects: savedObjects, + }), + }; + const request = Symbol() as any; + + const client = new SpacesClient( + mockAuditLogger as any, + mockDebugLogger, + mockAuthorization, + null, + mockConfig, + mockInternalRepository, + request + ); + await expect( + client.getAll(scenario.purpose as GetSpacePurpose) + ).rejects.toThrowErrorMatchingSnapshot(); + + expect(mockInternalRepository.find).toHaveBeenCalledWith({ + type: 'space', + page: 1, + perPage: maxSpaces, + sortField: 'name.keyword', + }); + expect(mockAuthorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); + expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); + expect(mockCheckPrivilegesAtSpaces).toHaveBeenCalledWith( + savedObjects.map(savedObject => savedObject.id), + privilege + ); + expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledWith( + username, + 'getAll' + ); + expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledTimes(0); + }); + + test(`returns spaces that the user is authorized for`, async () => { + const username = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const mockDebugLogger = createMockDebugLogger(); + const { mockAuthorization, mockCheckPrivilegesAtSpaces } = createMockAuthorization(); + + const privilege = scenario.expectedPrivilege(mockAuthorization); + + mockAuthorization.mode.useRbacForRequest.mockReturnValue(true); + mockCheckPrivilegesAtSpaces.mockReturnValue({ + username, + spacePrivileges: { + [savedObjects[0].id]: { + [privilege]: true, + }, + [savedObjects[1].id]: { + [privilege]: false, + }, + }, + }); + const mockInternalRepository = { + find: jest.fn().mockReturnValue({ + saved_objects: savedObjects, + }), + }; + const maxSpaces = 1234; + const mockConfig = createMockConfig({ + maxSpaces: 1234, + }); + const request = Symbol() as any; + + const client = new SpacesClient( + mockAuditLogger as any, + mockDebugLogger, + mockAuthorization, + null, + mockConfig, + mockInternalRepository, + request + ); + const actualSpaces = await client.getAll(scenario.purpose as GetSpacePurpose); + + expect(actualSpaces).toEqual([expectedSpaces[0]]); + expect(mockInternalRepository.find).toHaveBeenCalledWith({ + type: 'space', + page: 1, + perPage: maxSpaces, + sortField: 'name.keyword', + }); + expect(mockAuthorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); + expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); + expect(mockCheckPrivilegesAtSpaces).toHaveBeenCalledWith( + savedObjects.map(savedObject => savedObject.id), + privilege + ); + expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledTimes(0); + expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledWith( + username, + 'getAll', + [savedObjects[0].id] + ); + }); }); - expect(mockAuthorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); - expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); - expect(mockCheckPrivilegesAtSpaces).toHaveBeenCalledWith( - savedObjects.map(savedObject => savedObject.id), - mockAuthorization.actions.login - ); - expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledTimes(0); - expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledWith(username, 'getAll', [ - savedObjects[0].id, - ]); }); }); }); diff --git a/x-pack/legacy/plugins/spaces/server/lib/spaces_client/spaces_client.ts b/x-pack/legacy/plugins/spaces/server/lib/spaces_client/spaces_client.ts index c9ed21c38aaef..04bb30b1a84e1 100644 --- a/x-pack/legacy/plugins/spaces/server/lib/spaces_client/spaces_client.ts +++ b/x-pack/legacy/plugins/spaces/server/lib/spaces_client/spaces_client.ts @@ -14,6 +14,19 @@ import { SpacesAuditLogger } from '../audit_logger'; import { SpacesConfigType } from '../../new_platform/config'; type SpacesClientRequestFacade = Legacy.Request | KibanaRequest; + +export type GetSpacePurpose = 'any' | 'copySavedObjectsIntoSpace'; +const SUPPORTED_GET_SPACE_PURPOSES: GetSpacePurpose[] = ['any', 'copySavedObjectsIntoSpace']; + +const PURPOSE_PRIVILEGE_MAP: Record< + GetSpacePurpose, + (authorization: AuthorizationService) => string +> = { + any: authorization => authorization.actions.login, + copySavedObjectsIntoSpace: authorization => + authorization.actions.ui.get('savedObjectsManagement', 'copyIntoSpace'), +}; + export class SpacesClient { constructor( private readonly auditLogger: SpacesAuditLogger, @@ -40,8 +53,14 @@ export class SpacesClient { return true; } - public async getAll(): Promise { + public async getAll(purpose: GetSpacePurpose = 'any'): Promise { + if (!SUPPORTED_GET_SPACE_PURPOSES.includes(purpose)) { + throw Boom.badRequest(`unsupported space purpose: ${purpose}`); + } + if (this.useRbac()) { + const privilegeFactory = PURPOSE_PRIVILEGE_MAP[purpose]; + const { saved_objects } = await this.internalSavedObjectRepository.find({ type: 'space', page: 1, @@ -55,13 +74,13 @@ export class SpacesClient { const spaceIds = spaces.map((space: Space) => space.id); const checkPrivileges = this.authorization!.checkPrivilegesWithRequest(this.request); - const { username, spacePrivileges } = await checkPrivileges.atSpaces( - spaceIds, - this.authorization!.actions.login - ); + + const privilege = privilegeFactory(this.authorization!); + + const { username, spacePrivileges } = await checkPrivileges.atSpaces(spaceIds, privilege); const authorized = Object.keys(spacePrivileges).filter(spaceId => { - return spacePrivileges[spaceId][this.authorization!.actions.login]; + return spacePrivileges[spaceId][privilege]; }); this.debugLogger( diff --git a/x-pack/legacy/plugins/spaces/server/routes/api/__fixtures__/create_test_handler.ts b/x-pack/legacy/plugins/spaces/server/routes/api/__fixtures__/create_test_handler.ts index 0e90e2c47118c..6305839f64cf6 100644 --- a/x-pack/legacy/plugins/spaces/server/routes/api/__fixtures__/create_test_handler.ts +++ b/x-pack/legacy/plugins/spaces/server/routes/api/__fixtures__/create_test_handler.ts @@ -10,6 +10,9 @@ import { Legacy } from 'kibana'; import { KibanaConfig } from 'src/legacy/server/kbn_server'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { httpServiceMock, elasticsearchServiceMock } from 'src/core/server/mocks'; +import { SavedObjectsSchema, SavedObjectsService } from 'src/core/server'; +import { Readable } from 'stream'; +import { createPromiseFromStreams, createConcatStream } from 'src/legacy/utils/streams'; import { createOptionalPlugin } from '../../../../../../server/lib/optional_plugin'; import { SpacesClient } from '../../../lib/spaces_client'; import { createSpaces } from './create_spaces'; @@ -33,6 +36,7 @@ export interface TestOptions { payload?: any; preCheckLicenseImpl?: (req: any, h: any) => any; expectSpacesClientCall?: boolean; + expectPreCheckLicenseCall?: boolean; } export type TeardownFn = () => void; @@ -40,6 +44,17 @@ export type TeardownFn = () => void; export interface RequestRunnerResult { server: any; mockSavedObjectsRepository: any; + mockSavedObjectsService: { + getScopedSavedObjectsClient: jest.Mock; + importExport: { + getSortedObjectsForExport: jest.Mock< + SavedObjectsService['importExport']['getSortedObjectsForExport'] + >; + importSavedObjects: jest.Mock; + resolveImportErrors: jest.Mock; + }; + }; + headers: Record; response: any; } @@ -55,6 +70,10 @@ const baseConfig: TestConfig = { 'server.basePath': '', }; +async function readStreamToCompletion(stream: Readable) { + return (createPromiseFromStreams([stream, createConcatStream([])]) as unknown) as any[]; +} + export function createTestHandler( initApiFn: (deps: ExternalRouteDeps & InternalRouteDeps) => void ) { @@ -74,6 +93,7 @@ export function createTestHandler( testConfig = {}, payload, preCheckLicenseImpl = defaultPreCheckLicenseImpl, + expectPreCheckLicenseCall = true, expectSpacesClientCall = true, } = options; @@ -97,7 +117,7 @@ export function createTestHandler( server.decorate('server', 'config', jest.fn(() => mockConfig)); - const mockSavedObjectsRepository = { + const mockSavedObjectsClientContract = { get: jest.fn((type, id) => { const result = spaces.filter(s => s.id === id); if (!result.length) { @@ -130,6 +150,44 @@ export function createTestHandler( }; server.savedObjects = { + types: ['visualization', 'dashboard', 'index-pattern', 'globalType'], + schema: new SavedObjectsSchema({ + space: { + isNamespaceAgnostic: true, + hidden: true, + }, + globalType: { + isNamespaceAgnostic: true, + }, + }), + getScopedSavedObjectsClient: jest.fn().mockResolvedValue(mockSavedObjectsClientContract), + importExport: { + getSortedObjectsForExport: jest.fn().mockResolvedValue( + new Readable({ + objectMode: true, + read() { + if (Array.isArray(payload.objects)) { + payload.objects.forEach((o: any) => this.push(o)); + } + this.push(null); + }, + }) + ), + importSavedObjects: jest.fn().mockImplementation(async (opts: Record) => { + const objectsToImport: any[] = await readStreamToCompletion(opts.readStream); + return { + success: true, + successCount: objectsToImport.length, + }; + }), + resolveImportErrors: jest.fn().mockImplementation(async (opts: Record) => { + const objectsToImport: any[] = await readStreamToCompletion(opts.readStream); + return { + success: true, + successCount: objectsToImport.length, + }; + }), + }, SavedObjectsClient: { errors: { isNotFoundError: jest.fn((e: any) => e.message.startsWith('not found:')), @@ -173,9 +231,9 @@ export function createTestHandler( null as any, () => null, null, - mockSavedObjectsRepository, + mockSavedObjectsClientContract, { maxSpaces: 1000 }, - mockSavedObjectsRepository, + mockSavedObjectsClientContract, req ) ); @@ -207,7 +265,7 @@ export function createTestHandler( payload, }); - if (preCheckLicenseImpl) { + if (preCheckLicenseImpl && expectPreCheckLicenseCall) { expect(pre).toHaveBeenCalled(); } else { expect(pre).not.toHaveBeenCalled(); @@ -231,7 +289,8 @@ export function createTestHandler( return { server, headers, - mockSavedObjectsRepository, + mockSavedObjectsRepository: mockSavedObjectsClientContract, + mockSavedObjectsService: server.savedObjects, response: await testRun(), }; }; diff --git a/x-pack/legacy/plugins/spaces/server/routes/api/external/copy_to_space.test.ts b/x-pack/legacy/plugins/spaces/server/routes/api/external/copy_to_space.test.ts new file mode 100644 index 0000000000000..292fc21a2dd79 --- /dev/null +++ b/x-pack/legacy/plugins/spaces/server/routes/api/external/copy_to_space.test.ts @@ -0,0 +1,443 @@ +/* + * 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. + */ + +jest.mock('../../../lib/route_pre_check_license', () => { + return { + routePreCheckLicense: () => (request: any, h: any) => h.continue, + }; +}); + +jest.mock('../../../../../../server/lib/get_client_shield', () => { + return { + getClient: () => { + return { + callWithInternalUser: jest.fn(() => { + return; + }), + }; + }, + }; +}); + +import Boom from 'boom'; +import { createTestHandler, RequestRunner, TeardownFn } from '../__fixtures__'; +import { initCopyToSpacesApi } from './copy_to_space'; + +describe('POST /api/spaces/_copy_saved_objects', () => { + let request: RequestRunner; + let teardowns: TeardownFn[]; + + beforeEach(() => { + const setup = createTestHandler(initCopyToSpacesApi); + + request = setup.request; + teardowns = setup.teardowns; + }); + + afterEach(async () => { + await Promise.all(teardowns.splice(0).map(fn => fn())); + }); + + test(`returns result of routePreCheckLicense`, async () => { + const payload = { + spaces: ['a-space'], + objects: [], + }; + + const { response } = await request('POST', '/api/spaces/_copy_saved_objects', { + preCheckLicenseImpl: () => Boom.forbidden('test forbidden message'), + expectSpacesClientCall: false, + payload, + }); + + const { statusCode, payload: responsePayload } = response; + + expect(statusCode).toEqual(403); + expect(JSON.parse(responsePayload)).toMatchObject({ + message: 'test forbidden message', + }); + }); + + test(`uses a Saved Objects Client instance without the spaces wrapper`, async () => { + const payload = { + spaces: ['a-space'], + objects: [], + }; + + const { mockSavedObjectsService } = await request('POST', '/api/spaces/_copy_saved_objects', { + expectSpacesClientCall: false, + payload, + }); + + expect(mockSavedObjectsService.getScopedSavedObjectsClient).toHaveBeenCalledWith( + expect.any(Object), + { + excludedWrappers: ['spaces'], + } + ); + }); + + test(`requires space IDs to be unique`, async () => { + const payload = { + spaces: ['a-space', 'a-space'], + objects: [], + }; + + const { response } = await request('POST', '/api/spaces/_copy_saved_objects', { + expectSpacesClientCall: false, + expectPreCheckLicenseCall: false, + payload, + }); + + const { statusCode, payload: responsePayload } = response; + + expect(statusCode).toEqual(400); + expect(JSON.parse(responsePayload)).toMatchInlineSnapshot(` + Object { + "error": "Bad Request", + "message": "Invalid request payload input", + "statusCode": 400, + } + `); + }); + + test(`requires well-formed space IDS`, async () => { + const payload = { + spaces: ['a-space', 'a-space-invalid-!@#$%^&*()'], + objects: [], + }; + + const { response } = await request('POST', '/api/spaces/_copy_saved_objects', { + expectSpacesClientCall: false, + expectPreCheckLicenseCall: false, + payload, + }); + + const { statusCode, payload: responsePayload } = response; + + expect(statusCode).toEqual(400); + expect(JSON.parse(responsePayload)).toMatchInlineSnapshot(` + Object { + "error": "Bad Request", + "message": "Invalid request payload input", + "statusCode": 400, + } + `); + }); + + test(`requires objects to be unique`, async () => { + const payload = { + spaces: ['a-space'], + objects: [{ type: 'foo', id: 'bar' }, { type: 'foo', id: 'bar' }], + }; + + const { response } = await request('POST', '/api/spaces/_copy_saved_objects', { + expectSpacesClientCall: false, + expectPreCheckLicenseCall: false, + payload, + }); + + const { statusCode, payload: responsePayload } = response; + + expect(statusCode).toEqual(400); + expect(JSON.parse(responsePayload)).toMatchInlineSnapshot(` + Object { + "error": "Bad Request", + "message": "Invalid request payload input", + "statusCode": 400, + } + `); + }); + + test('does not allow namespace agnostic types to be copied (via "supportedTypes" property)', async () => { + const payload = { + spaces: ['a-space'], + objects: [{ type: 'globalType', id: 'bar' }, { type: 'visualization', id: 'bar' }], + }; + + const { response, mockSavedObjectsService } = await request( + 'POST', + '/api/spaces/_copy_saved_objects', + { + expectSpacesClientCall: false, + payload, + } + ); + + const { statusCode } = response; + + expect(statusCode).toEqual(200); + expect(mockSavedObjectsService.importExport.importSavedObjects).toHaveBeenCalledTimes(1); + const [ + importCallOptions, + ] = mockSavedObjectsService.importExport.importSavedObjects.mock.calls[0]; + + expect(importCallOptions).toMatchObject({ + namespace: 'a-space', + supportedTypes: ['visualization', 'dashboard', 'index-pattern'], + }); + }); + + test('copies to multiple spaces', async () => { + const payload = { + spaces: ['a-space', 'b-space'], + objects: [{ type: 'visualization', id: 'bar' }], + }; + + const { response, mockSavedObjectsService } = await request( + 'POST', + '/api/spaces/_copy_saved_objects', + { + expectSpacesClientCall: false, + payload, + } + ); + + const { statusCode } = response; + + expect(statusCode).toEqual(200); + expect(mockSavedObjectsService.importExport.importSavedObjects).toHaveBeenCalledTimes(2); + const [ + firstImportCallOptions, + ] = mockSavedObjectsService.importExport.importSavedObjects.mock.calls[0]; + + expect(firstImportCallOptions).toMatchObject({ + namespace: 'a-space', + }); + + const [ + secondImportCallOptions, + ] = mockSavedObjectsService.importExport.importSavedObjects.mock.calls[1]; + + expect(secondImportCallOptions).toMatchObject({ + namespace: 'b-space', + }); + }); +}); + +describe('POST /api/spaces/_resolve_copy_saved_objects_errors', () => { + let request: RequestRunner; + let teardowns: TeardownFn[]; + + beforeEach(() => { + const setup = createTestHandler(initCopyToSpacesApi); + + request = setup.request; + teardowns = setup.teardowns; + }); + + afterEach(async () => { + await Promise.all(teardowns.splice(0).map(fn => fn())); + }); + + test(`returns result of routePreCheckLicense`, async () => { + const payload = { + retries: {}, + objects: [], + }; + + const { response } = await request('POST', '/api/spaces/_resolve_copy_saved_objects_errors', { + preCheckLicenseImpl: () => Boom.forbidden('test forbidden message'), + expectSpacesClientCall: false, + payload, + }); + + const { statusCode, payload: responsePayload } = response; + + expect(statusCode).toEqual(403); + expect(JSON.parse(responsePayload)).toMatchObject({ + message: 'test forbidden message', + }); + }); + + test(`uses a Saved Objects Client instance without the spaces wrapper`, async () => { + const payload = { + retries: { + ['a-space']: [ + { + type: 'visualization', + id: 'bar', + overwrite: true, + }, + ], + }, + objects: [{ type: 'visualization', id: 'bar' }], + }; + + const { mockSavedObjectsService } = await request( + 'POST', + '/api/spaces/_resolve_copy_saved_objects_errors', + { + expectSpacesClientCall: false, + payload, + } + ); + + expect(mockSavedObjectsService.getScopedSavedObjectsClient).toHaveBeenCalledWith( + expect.any(Object), + { + excludedWrappers: ['spaces'], + } + ); + }); + + test(`requires objects to be unique`, async () => { + const payload = { + retries: {}, + objects: [{ type: 'foo', id: 'bar' }, { type: 'foo', id: 'bar' }], + }; + + const { response } = await request('POST', '/api/spaces/_resolve_copy_saved_objects_errors', { + expectSpacesClientCall: false, + expectPreCheckLicenseCall: false, + payload, + }); + + const { statusCode, payload: responsePayload } = response; + + expect(statusCode).toEqual(400); + expect(JSON.parse(responsePayload)).toMatchInlineSnapshot(` + Object { + "error": "Bad Request", + "message": "Invalid request payload input", + "statusCode": 400, + } + `); + }); + + test(`requires well-formed space ids`, async () => { + const payload = { + retries: { + ['invalid-space-id!@#$%^&*()']: [ + { + type: 'foo', + id: 'bar', + overwrite: true, + }, + ], + }, + objects: [{ type: 'foo', id: 'bar' }], + }; + + const { response } = await request('POST', '/api/spaces/_resolve_copy_saved_objects_errors', { + expectSpacesClientCall: false, + expectPreCheckLicenseCall: false, + payload, + }); + + const { statusCode, payload: responsePayload } = response; + + expect(statusCode).toEqual(400); + expect(JSON.parse(responsePayload)).toMatchInlineSnapshot(` + Object { + "error": "Bad Request", + "message": "Invalid request payload input", + "statusCode": 400, + } + `); + }); + + test('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 { response, mockSavedObjectsService } = await request( + 'POST', + '/api/spaces/_resolve_copy_saved_objects_errors', + { + expectSpacesClientCall: false, + payload, + } + ); + + const { statusCode } = response; + + expect(statusCode).toEqual(200); + expect(mockSavedObjectsService.importExport.resolveImportErrors).toHaveBeenCalledTimes(1); + const [ + resolveImportErrorsCallOptions, + ] = mockSavedObjectsService.importExport.resolveImportErrors.mock.calls[0]; + + expect(resolveImportErrorsCallOptions).toMatchObject({ + namespace: 'a-space', + supportedTypes: ['visualization', 'dashboard', 'index-pattern'], + }); + }); + + test('resolves conflicts for multiple spaces', async () => { + const payload = { + objects: [{ type: 'visualization', id: 'bar' }], + retries: { + ['a-space']: [ + { + type: 'visualization', + id: 'bar', + overwrite: true, + }, + ], + ['b-space']: [ + { + type: 'globalType', + id: 'bar', + overwrite: true, + }, + ], + }, + }; + + const { response, mockSavedObjectsService } = await request( + 'POST', + '/api/spaces/_resolve_copy_saved_objects_errors', + { + expectSpacesClientCall: false, + payload, + } + ); + + const { statusCode } = response; + + expect(statusCode).toEqual(200); + expect(mockSavedObjectsService.importExport.resolveImportErrors).toHaveBeenCalledTimes(2); + const [ + resolveImportErrorsFirstCallOptions, + ] = mockSavedObjectsService.importExport.resolveImportErrors.mock.calls[0]; + + expect(resolveImportErrorsFirstCallOptions).toMatchObject({ + namespace: 'a-space', + supportedTypes: ['visualization', 'dashboard', 'index-pattern'], + }); + + const [ + resolveImportErrorsSecondCallOptions, + ] = mockSavedObjectsService.importExport.resolveImportErrors.mock.calls[1]; + + expect(resolveImportErrorsSecondCallOptions).toMatchObject({ + namespace: 'b-space', + supportedTypes: ['visualization', 'dashboard', 'index-pattern'], + }); + }); +}); diff --git a/x-pack/legacy/plugins/spaces/server/routes/api/external/copy_to_space.ts b/x-pack/legacy/plugins/spaces/server/routes/api/external/copy_to_space.ts new file mode 100644 index 0000000000000..d149d6042932d --- /dev/null +++ b/x-pack/legacy/plugins/spaces/server/routes/api/external/copy_to_space.ts @@ -0,0 +1,145 @@ +/* + * 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 Joi from 'joi'; +import { Legacy } from 'kibana'; +import { + copySavedObjectsToSpacesFactory, + resolveCopySavedObjectsToSpacesConflictsFactory, +} from '../../../lib/copy_to_spaces'; +import { ExternalRouteDeps } from '.'; +import { COPY_TO_SPACES_SAVED_OBJECTS_CLIENT_OPTS } from '../../../lib/copy_to_spaces/copy_to_spaces'; +import { SPACE_ID_REGEX } from '../../../lib/space_schema'; + +interface CopyPayload { + spaces: string[]; + objects: Array<{ type: string; id: string }>; + includeReferences: boolean; + overwrite: boolean; +} + +interface ResolveConflictsPayload { + objects: Array<{ type: string; id: string }>; + includeReferences: boolean; + retries: { + [spaceId: string]: Array<{ + type: string; + id: string; + overwrite: boolean; + }>; + }; +} + +export function initCopyToSpacesApi(deps: ExternalRouteDeps) { + const { http, spacesService, savedObjects, routePreCheckLicenseFn } = deps; + + http.route({ + method: 'POST', + path: '/api/spaces/_copy_saved_objects', + async handler(request: Legacy.Request, h: Legacy.ResponseToolkit) { + const savedObjectsClient = savedObjects.getScopedSavedObjectsClient( + request, + COPY_TO_SPACES_SAVED_OBJECTS_CLIENT_OPTS + ); + + const copySavedObjectsToSpaces = copySavedObjectsToSpacesFactory( + savedObjectsClient, + savedObjects + ); + + const { + spaces: destinationSpaceIds, + objects, + includeReferences, + overwrite, + } = request.payload as CopyPayload; + + const sourceSpaceId = spacesService.getSpaceId(request); + + const copyResponse = await copySavedObjectsToSpaces(sourceSpaceId, destinationSpaceIds, { + objects, + includeReferences, + overwrite, + }); + + return h.response(copyResponse); + }, + options: { + tags: ['access:copySavedObjectsToSpaces'], + validate: { + payload: { + spaces: Joi.array() + .items( + Joi.string().regex(SPACE_ID_REGEX, `lower case, a-z, 0-9, "_", and "-" are allowed`) + ) + .unique(), + objects: Joi.array() + .items(Joi.object({ type: Joi.string(), id: Joi.string() })) + .unique(), + includeReferences: Joi.bool().default(false), + overwrite: Joi.bool().default(false), + }, + }, + pre: [routePreCheckLicenseFn], + }, + }); + + http.route({ + method: 'POST', + path: '/api/spaces/_resolve_copy_saved_objects_errors', + async handler(request: Legacy.Request, h: Legacy.ResponseToolkit) { + const savedObjectsClient = savedObjects.getScopedSavedObjectsClient( + request, + COPY_TO_SPACES_SAVED_OBJECTS_CLIENT_OPTS + ); + + const resolveCopySavedObjectsToSpacesConflicts = resolveCopySavedObjectsToSpacesConflictsFactory( + savedObjectsClient, + savedObjects + ); + + const { objects, includeReferences, retries } = request.payload as ResolveConflictsPayload; + + const sourceSpaceId = spacesService.getSpaceId(request); + + const resolveConflictsResponse = await resolveCopySavedObjectsToSpacesConflicts( + sourceSpaceId, + { + objects, + includeReferences, + retries, + } + ); + + return h.response(resolveConflictsResponse); + }, + options: { + tags: ['access:copySavedObjectsToSpaces'], + validate: { + payload: Joi.object({ + objects: Joi.array() + .items(Joi.object({ type: Joi.string(), id: Joi.string() })) + .required() + .unique(), + includeReferences: Joi.bool().default(false), + retries: Joi.object() + .pattern( + SPACE_ID_REGEX, + Joi.array().items( + Joi.object({ + type: Joi.string().required(), + id: Joi.string().required(), + overwrite: Joi.boolean().default(false), + }) + ) + ) + .required(), + }).default(), + }, + pre: [routePreCheckLicenseFn], + }, + }); +} diff --git a/x-pack/legacy/plugins/spaces/server/routes/api/external/get.test.ts b/x-pack/legacy/plugins/spaces/server/routes/api/external/get.test.ts index daef9c54633c5..5357c38e0e9ae 100644 --- a/x-pack/legacy/plugins/spaces/server/routes/api/external/get.test.ts +++ b/x-pack/legacy/plugins/spaces/server/routes/api/external/get.test.ts @@ -52,6 +52,29 @@ describe('GET spaces', () => { expect(resultSpaces.map(s => s.id)).toEqual(spaces.map(s => s.id)); }); + test(`'GET spaces' returns all available spaces with the 'any' purpose`, async () => { + const { response } = await request('GET', '/api/spaces/space?purpose=any'); + + const { statusCode, payload } = response; + + expect(statusCode).toEqual(200); + const resultSpaces: Space[] = JSON.parse(payload); + expect(resultSpaces.map(s => s.id)).toEqual(spaces.map(s => s.id)); + }); + + test(`'GET spaces' returns all available spaces with the 'copySavedObjectsIntoSpace' purpose`, async () => { + const { response } = await request( + 'GET', + '/api/spaces/space?purpose=copySavedObjectsIntoSpace' + ); + + const { statusCode, payload } = response; + + expect(statusCode).toEqual(200); + const resultSpaces: Space[] = JSON.parse(payload); + expect(resultSpaces.map(s => s.id)).toEqual(spaces.map(s => s.id)); + }); + test(`returns result of routePreCheckLicense`, async () => { const { response } = await request('GET', '/api/spaces/space', { preCheckLicenseImpl: () => Boom.forbidden('test forbidden message'), diff --git a/x-pack/legacy/plugins/spaces/server/routes/api/external/get.ts b/x-pack/legacy/plugins/spaces/server/routes/api/external/get.ts index 8da265f2c1939..ec1a4b5d0baf0 100644 --- a/x-pack/legacy/plugins/spaces/server/routes/api/external/get.ts +++ b/x-pack/legacy/plugins/spaces/server/routes/api/external/get.ts @@ -5,9 +5,11 @@ */ import Boom from 'boom'; +import Joi from 'joi'; +import { RequestQuery } from 'hapi'; import { Space } from '../../../../common/model/space'; import { wrapError } from '../../../lib/errors'; -import { SpacesClient } from '../../../lib/spaces_client'; +import { SpacesClient, GetSpacePurpose } from '../../../lib/spaces_client'; import { ExternalRouteDeps, ExternalRouteRequestFacade } from '.'; export function initGetSpacesApi(deps: ExternalRouteDeps) { @@ -19,16 +21,18 @@ export function initGetSpacesApi(deps: ExternalRouteDeps) { async handler(request: ExternalRouteRequestFacade) { log.debug(`Inside GET /api/spaces/space`); + const purpose: GetSpacePurpose = (request.query as RequestQuery).purpose as GetSpacePurpose; + const spacesClient: SpacesClient = await spacesService.scopedClient(request); let spaces: Space[]; try { - log.debug(`Attempting to retrieve all spaces`); - spaces = await spacesClient.getAll(); - log.debug(`Retrieved ${spaces.length} spaces`); + log.debug(`Attempting to retrieve all spaces for ${purpose} purpose`); + spaces = await spacesClient.getAll(purpose); + log.debug(`Retrieved ${spaces.length} spaces for ${purpose} purpose`); } catch (error) { - log.debug(`Error retrieving spaces: ${error}`); + log.debug(`Error retrieving spaces for ${purpose} purpose: ${error}`); return wrapError(error); } @@ -36,6 +40,13 @@ export function initGetSpacesApi(deps: ExternalRouteDeps) { }, options: { pre: [routePreCheckLicenseFn], + validate: { + query: Joi.object().keys({ + purpose: Joi.string() + .valid('any', 'copySavedObjectsIntoSpace') + .default('any'), + }), + }, }, }); diff --git a/x-pack/legacy/plugins/spaces/server/routes/api/external/index.ts b/x-pack/legacy/plugins/spaces/server/routes/api/external/index.ts index 141b65062e3f9..5559392dc1fda 100644 --- a/x-pack/legacy/plugins/spaces/server/routes/api/external/index.ts +++ b/x-pack/legacy/plugins/spaces/server/routes/api/external/index.ts @@ -14,6 +14,7 @@ import { initPostSpacesApi } from './post'; import { initPutSpacesApi } from './put'; import { SpacesServiceSetup } from '../../../new_platform/spaces_service/spaces_service'; import { SpacesHttpServiceSetup } from '../../../new_platform/plugin'; +import { initCopyToSpacesApi } from './copy_to_space'; type Omit = Pick>; @@ -43,4 +44,5 @@ export function initExternalSpacesApi({ xpackMain, ...rest }: RouteDeps) { initGetSpacesApi(deps); initPostSpacesApi(deps); initPutSpacesApi(deps); + initCopyToSpacesApi(deps); } diff --git a/x-pack/legacy/plugins/xpack_main/server/lib/register_oss_features.ts b/x-pack/legacy/plugins/xpack_main/server/lib/register_oss_features.ts index daeda066ac789..cc15d425964a2 100644 --- a/x-pack/legacy/plugins/xpack_main/server/lib/register_oss_features.ts +++ b/x-pack/legacy/plugins/xpack_main/server/lib/register_oss_features.ts @@ -202,13 +202,15 @@ const buildKibanaFeatures = (savedObjectTypes: string[]) => { }, privileges: { all: { + api: ['copySavedObjectsToSpaces'], savedObject: { all: [...savedObjectTypes], read: [], }, - ui: ['read', 'edit', 'delete'], + ui: ['read', 'edit', 'delete', 'copyIntoSpace'], }, read: { + api: ['copySavedObjectsToSpaces'], savedObject: { all: [], read: [...savedObjectTypes], 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 a85303857affd..64c1be0b90071 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 @@ -105,3 +105,274 @@ "type": "_doc" } } + +{ + "type": "_doc", + "value": { + "id": "dashboard:cts_dashboard", + "index": ".kibana", + "source": { + "dashboard": { + "description": "Copy to Space Dashboard from the default space", + "title": "This is the default test space CTS dashboard" + }, + "references": [{ + "type": "visualization", + "id": "cts_vis_1_default", + "name": "CTS Vis 1" + }, { + "type": "visualization", + "id": "cts_vis_2_default", + "name": "CTS Vis 2" + }, { + "type": "visualization", + "id": "cts_vis_3", + "name": "CTS Vis 3" + }], + "type": "dashboard", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "_doc" + } +} + +{ + "type": "_doc", + "value": { + "id": "visualization:cts_vis_1_default", + "index": ".kibana", + "source": { + "visualization": { + "title": "CTS vis 1 from default space", + "description": "AreaChart", + "kibanaSavedObjectMeta": {"searchSourceJSON": "{}"}, + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"New Visualization\",\"type\":\"area\",\"params\":{\"shareYAxis\":true,\"addTooltip\":true,\"addLegend\":true,\"smoothLines\":false,\"scale\":\"linear\",\"interpolate\":\"linear\",\"mode\":\"stacked\",\"times\":[],\"addTimeMarker\":false,\"defaultYExtents\":false,\"setYExtents\":false,\"yAxis\":{}},\"aggs\":[{\"id\":\"1\",\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"type\":\"date_histogram\",\"schema\":\"segment\",\"params\":{\"field\":\"@timestamp\",\"interval\":\"auto\",\"customInterval\":\"2h\",\"min_doc_count\":1,\"extended_bounds\":{}}}],\"listeners\":{}}" + }, + "references": [ + { + "type": "index-pattern", + "id": "cts_ip_1", + "name": "CTS IP 1" + } + ], + "type": "visualization", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "_doc" + } +} + +{ + "type": "_doc", + "value": { + "id": "visualization:cts_vis_2_default", + "index": ".kibana", + "source": { + "visualization": { + "title": "CTS vis 2 from default space", + "description": "AreaChart", + "kibanaSavedObjectMeta": {"searchSourceJSON": "{}"}, + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"New Visualization\",\"type\":\"area\",\"params\":{\"shareYAxis\":true,\"addTooltip\":true,\"addLegend\":true,\"smoothLines\":false,\"scale\":\"linear\",\"interpolate\":\"linear\",\"mode\":\"stacked\",\"times\":[],\"addTimeMarker\":false,\"defaultYExtents\":false,\"setYExtents\":false,\"yAxis\":{}},\"aggs\":[{\"id\":\"1\",\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"type\":\"date_histogram\",\"schema\":\"segment\",\"params\":{\"field\":\"@timestamp\",\"interval\":\"auto\",\"customInterval\":\"2h\",\"min_doc_count\":1,\"extended_bounds\":{}}}],\"listeners\":{}}" + }, + "references": [ + { + "type": "index-pattern", + "id": "cts_ip_1", + "name": "CTS IP 1" + } + ], + "type": "visualization", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "_doc" + } +} + +{ + "type": "_doc", + "value": { + "id": "visualization:cts_vis_3", + "index": ".kibana", + "source": { + "visualization": { + "title": "CTS vis 3 from default space", + "description": "AreaChart", + "kibanaSavedObjectMeta": {"searchSourceJSON": "{}"}, + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"New Visualization\",\"type\":\"area\",\"params\":{\"shareYAxis\":true,\"addTooltip\":true,\"addLegend\":true,\"smoothLines\":false,\"scale\":\"linear\",\"interpolate\":\"linear\",\"mode\":\"stacked\",\"times\":[],\"addTimeMarker\":false,\"defaultYExtents\":false,\"setYExtents\":false,\"yAxis\":{}},\"aggs\":[{\"id\":\"1\",\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"type\":\"date_histogram\",\"schema\":\"segment\",\"params\":{\"field\":\"@timestamp\",\"interval\":\"auto\",\"customInterval\":\"2h\",\"min_doc_count\":1,\"extended_bounds\":{}}}],\"listeners\":{}}" + }, + "references": [ + { + "type": "index-pattern", + "id": "cts_ip_1", + "name": "CTS IP 1" + } + ], + "type": "visualization", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "_doc" + } +} + +{ + "type": "_doc", + "value": { + "id": "space_1:dashboard:cts_dashboard", + "index": ".kibana", + "source": { + "dashboard": { + "description": "Copy to Space Dashboard from space_1 space", + "title": "This is the space_1 test space CTS dashboard" + }, + "references": [ + { + "type": "visualization", + "id": "cts_vis_1_space_1", + "name": "CTS Vis 1" + }, + { + "type": "visualization", + "id": "cts_vis_2_space_1", + "name": "CTS Vis 2" + }, + { + "type": "visualization", + "id": "cts_vis_3", + "name": "CTS Vis 3" + } + ], + "type": "dashboard", + "updated_at": "2017-09-21T18:49:16.270Z", + "namespace": "space_1" + }, + "type": "_doc" + } +} + +{ + "type": "_doc", + "value": { + "id": "space_1:visualization:cts_vis_1_space_1", + "index": ".kibana", + "source": { + "visualization": { + "title": "CTS vis 1 from space_1 space", + "description": "AreaChart", + "kibanaSavedObjectMeta": {"searchSourceJSON": "{}"}, + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"New Visualization\",\"type\":\"area\",\"params\":{\"shareYAxis\":true,\"addTooltip\":true,\"addLegend\":true,\"smoothLines\":false,\"scale\":\"linear\",\"interpolate\":\"linear\",\"mode\":\"stacked\",\"times\":[],\"addTimeMarker\":false,\"defaultYExtents\":false,\"setYExtents\":false,\"yAxis\":{}},\"aggs\":[{\"id\":\"1\",\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"type\":\"date_histogram\",\"schema\":\"segment\",\"params\":{\"field\":\"@timestamp\",\"interval\":\"auto\",\"customInterval\":\"2h\",\"min_doc_count\":1,\"extended_bounds\":{}}}],\"listeners\":{}}" + }, + "references": [ + { + "type": "index-pattern", + "id": "cts_ip_1", + "name": "CTS IP 1" + } + ], + "type": "visualization", + "updated_at": "2017-09-21T18:49:16.270Z", + "namespace": "space_1" + }, + "type": "_doc" + } +} + +{ + "type": "_doc", + "value": { + "id": "space_1:visualization:cts_vis_2_space_1", + "index": ".kibana", + "source": { + "visualization": { + "title": "CTS vis 2 from space_1 space", + "description": "AreaChart", + "kibanaSavedObjectMeta": {"searchSourceJSON": "{}"}, + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"New Visualization\",\"type\":\"area\",\"params\":{\"shareYAxis\":true,\"addTooltip\":true,\"addLegend\":true,\"smoothLines\":false,\"scale\":\"linear\",\"interpolate\":\"linear\",\"mode\":\"stacked\",\"times\":[],\"addTimeMarker\":false,\"defaultYExtents\":false,\"setYExtents\":false,\"yAxis\":{}},\"aggs\":[{\"id\":\"1\",\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"type\":\"date_histogram\",\"schema\":\"segment\",\"params\":{\"field\":\"@timestamp\",\"interval\":\"auto\",\"customInterval\":\"2h\",\"min_doc_count\":1,\"extended_bounds\":{}}}],\"listeners\":{}}" + }, + "references": [ + { + "type": "index-pattern", + "id": "cts_ip_1", + "name": "CTS IP 1" + } + ], + "type": "visualization", + "updated_at": "2017-09-21T18:49:16.270Z", + "namespace": "space_1" + }, + "type": "_doc" + } +} + +{ + "type": "_doc", + "value": { + "id": "space_1:visualization:cts_vis_3", + "index": ".kibana", + "source": { + "visualization": { + "title": "CTS vis 3 from space_1 space", + "description": "AreaChart", + "kibanaSavedObjectMeta": {"searchSourceJSON": "{}"}, + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"New Visualization\",\"type\":\"area\",\"params\":{\"shareYAxis\":true,\"addTooltip\":true,\"addLegend\":true,\"smoothLines\":false,\"scale\":\"linear\",\"interpolate\":\"linear\",\"mode\":\"stacked\",\"times\":[],\"addTimeMarker\":false,\"defaultYExtents\":false,\"setYExtents\":false,\"yAxis\":{}},\"aggs\":[{\"id\":\"1\",\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"type\":\"date_histogram\",\"schema\":\"segment\",\"params\":{\"field\":\"@timestamp\",\"interval\":\"auto\",\"customInterval\":\"2h\",\"min_doc_count\":1,\"extended_bounds\":{}}}],\"listeners\":{}}" + }, + "references": [ + { + "type": "index-pattern", + "id": "cts_ip_1", + "name": "CTS IP 1" + } + ], + "type": "visualization", + "updated_at": "2017-09-21T18:49:16.270Z", + "namespace": "space_1" + }, + "type": "_doc" + } +} + +{ + "type": "_doc", + "value": { + "id": "index-pattern:cts_ip_1", + "index": ".kibana", + "source": { + "index-pattern": { + "title": "Copy to Space index pattern 1 from default space" + }, + "references": [], + "type": "index-pattern", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "_doc" + } +} + +{ + "type": "_doc", + "value": { + "id": "space_1:index-pattern:cts_ip_1", + "index": ".kibana", + "source": { + "index-pattern": { + "title": "Copy to Space index pattern 1 from space_1 space" + }, + "references": [], + "type": "index-pattern", + "updated_at": "2017-09-21T18:49:16.270Z", + "namespace": "space_1" + }, + "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 90594d9d32cce..1440585b625b9 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 @@ -24,6 +24,20 @@ } } }, + "references": { + "type": "nested", + "properties": { + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "id": { + "type": "keyword" + } + } + }, "dashboard": { "properties": { "description": { diff --git a/x-pack/test/spaces_api_integration/common/lib/authentication.ts b/x-pack/test/spaces_api_integration/common/lib/authentication.ts index b901a7ed9516c..e9f2e38eceb03 100644 --- a/x-pack/test/spaces_api_integration/common/lib/authentication.ts +++ b/x-pack/test/spaces_api_integration/common/lib/authentication.ts @@ -65,6 +65,22 @@ export const AUTHENTICATION = { username: 'a_kibana_rbac_space_1_2_read_user', password: 'password', }, + KIBANA_RBAC_DEFAULT_SPACE_SAVED_OBJECTS_ALL_USER: { + username: 'a_kibana_rbac_default_space_saved_objects_all_user', + password: 'password', + }, + KIBANA_RBAC_DEFAULT_SPACE_SAVED_OBJECTS_READ_USER: { + username: 'a_kibana_rbac_default_space_saved_objects_read_user', + password: 'password', + }, + KIBANA_RBAC_SPACE_1_SAVED_OBJECTS_ALL_USER: { + username: 'a_kibana_rbac_space_1_saved_objects_all_user', + password: 'password', + }, + KIBANA_RBAC_SPACE_1_SAVED_OBJECTS_READ_USER: { + username: 'a_kibana_rbac_space_1_saved_objects_read_user', + password: 'password', + }, APM_USER: { username: 'a_apm_user', password: 'password', diff --git a/x-pack/test/spaces_api_integration/common/lib/create_users_and_roles.ts b/x-pack/test/spaces_api_integration/common/lib/create_users_and_roles.ts index e480548c9493f..494c8d9c9e449 100644 --- a/x-pack/test/spaces_api_integration/common/lib/create_users_and_roles.ts +++ b/x-pack/test/spaces_api_integration/common/lib/create_users_and_roles.ts @@ -181,6 +181,66 @@ export const createUsersAndRoles = async (es: any, supertest: SuperTest) => }) .expect(204); + await supertest + .put('/api/security/role/kibana_rbac_default_space_saved_objects_all_user') + .send({ + kibana: [ + { + base: [], + feature: { + savedObjectsManagement: ['all'], + }, + spaces: ['default'], + }, + ], + }) + .expect(204); + + await supertest + .put('/api/security/role/kibana_rbac_default_space_saved_objects_read_user') + .send({ + kibana: [ + { + base: [], + feature: { + savedObjectsManagement: ['read'], + }, + spaces: ['default'], + }, + ], + }) + .expect(204); + + await supertest + .put('/api/security/role/kibana_rbac_space_1_saved_objects_all_user') + .send({ + kibana: [ + { + base: [], + feature: { + savedObjectsManagement: ['all'], + }, + spaces: ['space_1'], + }, + ], + }) + .expect(204); + + await supertest + .put('/api/security/role/kibana_rbac_space_1_saved_objects_read_user') + .send({ + kibana: [ + { + base: [], + feature: { + savedObjectsManagement: ['read'], + }, + spaces: ['space_1'], + }, + ], + }) + .expect(204); + await es.shield.putUser({ username: AUTHENTICATION.NOT_A_KIBANA_USER.username, body: { @@ -321,6 +381,46 @@ export const createUsersAndRoles = async (es: any, supertest: SuperTest) => }, }); + await es.shield.putUser({ + username: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_SAVED_OBJECTS_ALL_USER.username, + body: { + password: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_SAVED_OBJECTS_ALL_USER.password, + roles: ['kibana_rbac_default_space_saved_objects_all_user'], + full_name: 'a kibana rbac default space saved objects management all user', + email: 'a_kibana_rbac_default_space_saved_objects_all_user@elastic.co', + }, + }); + + await es.shield.putUser({ + username: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_SAVED_OBJECTS_READ_USER.username, + body: { + password: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_SAVED_OBJECTS_READ_USER.password, + roles: ['kibana_rbac_default_space_saved_objects_read_user'], + full_name: 'a kibana rbac default space saved objects management read user', + email: 'a_kibana_rbac_default_space_saved_objects_read_user@elastic.co', + }, + }); + + await es.shield.putUser({ + username: AUTHENTICATION.KIBANA_RBAC_SPACE_1_SAVED_OBJECTS_ALL_USER.username, + body: { + password: AUTHENTICATION.KIBANA_RBAC_SPACE_1_SAVED_OBJECTS_ALL_USER.password, + roles: ['kibana_rbac_space_1_saved_objects_all_user'], + full_name: 'a kibana rbac space 1 saved objects management all user', + email: 'a_kibana_rbac_space_1_saved_objects_all_user@elastic.co', + }, + }); + + await es.shield.putUser({ + username: AUTHENTICATION.KIBANA_RBAC_SPACE_1_SAVED_OBJECTS_READ_USER.username, + body: { + password: AUTHENTICATION.KIBANA_RBAC_SPACE_1_SAVED_OBJECTS_READ_USER.password, + roles: ['kibana_rbac_space_1_saved_objects_read_user'], + full_name: 'a kibana rbac space 1 saved objects management read user', + email: 'a_kibana_rbac_space_1_saved_objects_read_user@elastic.co', + }, + }); + await es.shield.putUser({ username: AUTHENTICATION.APM_USER.username, body: { 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 new file mode 100644 index 0000000000000..61f1ab2190a09 --- /dev/null +++ b/x-pack/test/spaces_api_integration/common/suites/copy_to_space.ts @@ -0,0 +1,552 @@ +/* + * 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 expect from '@kbn/expect'; +import { SuperTest } from 'supertest'; +import { EsArchiver } from 'src/es_archiver'; +import { DEFAULT_SPACE_ID } from '../../../../legacy/plugins/spaces/common/constants'; +import { CopyResponse } from '../../../../legacy/plugins/spaces/server/lib/copy_to_spaces'; +import { getUrlPrefix } from '../lib/space_test_utils'; +import { DescribeFn, TestDefinitionAuthentication } from '../lib/types'; + +type TestResponse = Record; + +interface CopyToSpaceTest { + statusCode: number; + response: (resp: TestResponse) => Promise; +} + +interface CopyToSpaceTests { + noConflictsWithoutReferences: CopyToSpaceTest; + noConflictsWithReferences: CopyToSpaceTest; + withConflictsOverwriting: CopyToSpaceTest; + withConflictsWithoutOverwriting: CopyToSpaceTest; + nonExistentSpace: CopyToSpaceTest; + multipleSpaces: { + statusCode: number; + withConflictsResponse: (resp: TestResponse) => Promise; + noConflictsResponse: (resp: TestResponse) => Promise; + }; +} + +interface CopyToSpaceTestDefinition { + user?: TestDefinitionAuthentication; + spaceId?: string; + tests: CopyToSpaceTests; +} + +interface CountByTypeBucket { + key: string; + doc_count: number; +} +interface SpaceBucket { + doc_count: number; + key: string; + countByType: { + doc_count_error_upper_bound: number; + sum_other_doc_count: number; + buckets: CountByTypeBucket[]; + }; +} + +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, + }, +}; + +const getDestinationWithoutConflicts = () => 'space_2'; +const getDestinationWithConflicts = (originSpaceId?: string) => { + if (!originSpaceId || originSpaceId === DEFAULT_SPACE_ID) { + return 'space_1'; + } + return DEFAULT_SPACE_ID; +}; + +export function copyToSpaceTestSuiteFactory( + es: any, + esArchiver: EsArchiver, + supertest: SuperTest +) { + const collectSpaceContents = async () => { + const response = await es.search({ + index: '.kibana', + body: { + size: 0, + query: { + bool: { + must_not: { + term: { + // exclude spaces from the result set. + // we don't assert on these. + type: 'space', + }, + }, + }, + }, + aggs: { + count: { + terms: { + field: 'namespace', + missing: DEFAULT_SPACE_ID, + size: 10, + }, + aggs: { + countByType: { + terms: { + field: 'type', + missing: 'UNKNOWN', + size: 10, + }, + }, + }, + }, + }, + }, + }); + + return { + buckets: response.aggregations.count.buckets as SpaceBucket[], + }; + }; + + const assertSpaceCounts = async ( + spaceId: string, + expectedCounts: Record = {} + ) => { + const bucketSorter = (b1: CountByTypeBucket, b2: CountByTypeBucket) => + b1.key < b2.key ? -1 : 1; + const { buckets } = await collectSpaceContents(); + + const spaceBucket = buckets.find(b => b.key === spaceId); + + if (!spaceBucket) { + expect(Object.keys(expectedCounts).length).to.eql(0); + return; + } + + const { countByType } = spaceBucket; + const expectedBuckets = Object.entries(expectedCounts).reduce( + (acc, entry) => { + const [type, count] = entry; + return [ + ...acc, + { + key: type, + doc_count: count, + }, + ]; + }, + [] as CountByTypeBucket[] + ); + + expectedBuckets.sort(bucketSorter); + countByType.buckets.sort(bucketSorter); + + expect(countByType).to.eql({ + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: expectedBuckets, + }); + }; + + 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, + error: 'Not Found', + message: 'Not Found', + }); + }; + + const createExpectNoConflictsWithoutReferencesForSpace = ( + spaceId: string, + expectedDashboardCount: number + ) => async (resp: TestResponse) => { + const result = resp.body as CopyResponse; + expect(result).to.eql({ + [spaceId]: { + success: true, + successCount: 1, + }, + } as CopyResponse); + + // Query ES to ensure that we copied everything we expected + await assertSpaceCounts(spaceId, { + dashboard: expectedDashboardCount, + }); + }; + + const expectNoConflictsWithoutReferencesResult = createExpectNoConflictsWithoutReferencesForSpace( + getDestinationWithoutConflicts(), + 2 + ); + + const expectNoConflictsForNonExistentSpaceResult = createExpectNoConflictsWithoutReferencesForSpace( + 'non_existent_space', + 1 + ); + + const expectNoConflictsWithReferencesResult = async (resp: TestResponse) => { + const destination = getDestinationWithoutConflicts(); + const result = resp.body as CopyResponse; + expect(result).to.eql({ + [destination]: { + success: true, + successCount: 5, + }, + } as CopyResponse); + + // Query ES to ensure that we copied everything we expected + await assertSpaceCounts(destination, { + dashboard: 2, + visualization: 3, + 'index-pattern': 1, + }); + }; + + const getDestinationSpace = ( + sourceSpaceId: string, + type: 'with-conflicts' | 'without-conflicts' | 'non-existent' + ) => { + if (type === 'non-existent') { + return 'non_existent_space'; + } + + return type === 'with-conflicts' + ? getDestinationWithConflicts(sourceSpaceId) + : getDestinationWithoutConflicts(); + }; + + const createExpectUnauthorizedAtSpaceWithReferencesResult = ( + spaceId: string = DEFAULT_SPACE_ID, + type: 'with-conflicts' | 'without-conflicts' + ) => async (resp: TestResponse) => { + const destination = getDestinationSpace(spaceId, type); + + const result = resp.body as CopyResponse; + expect(result).to.eql({ + [destination]: { + success: false, + successCount: 0, + errors: [ + { + statusCode: 403, + error: 'Forbidden', + message: 'Unable to bulk_create dashboard,index-pattern,visualization', + }, + ], + }, + } as CopyResponse); + + // Query ES to ensure that nothing was copied + await assertSpaceCounts(destination, INITIAL_COUNTS[destination]); + }; + + const createExpectUnauthorizedAtSpaceWithoutReferencesResult = ( + spaceId: string = DEFAULT_SPACE_ID, + type: 'with-conflicts' | 'without-conflicts' | 'non-existent' + ) => async (resp: TestResponse) => { + const destination = getDestinationSpace(spaceId, type); + + const result = resp.body as CopyResponse; + expect(result).to.eql({ + [destination]: { + success: false, + successCount: 0, + errors: [ + { + statusCode: 403, + error: 'Forbidden', + message: 'Unable to bulk_create dashboard', + }, + ], + }, + } as CopyResponse); + + // Query ES to ensure that nothing was copied + await assertSpaceCounts(destination, INITIAL_COUNTS[destination]); + }; + + const createExpectWithConflictsOverwritingResult = (spaceId?: string) => async (resp: { + [key: string]: any; + }) => { + const destination = getDestinationWithConflicts(spaceId); + const result = resp.body as CopyResponse; + expect(result).to.eql({ + [destination]: { + success: true, + successCount: 5, + }, + } as CopyResponse); + + // Query ES to ensure that we copied everything we expected + await assertSpaceCounts(destination, { + dashboard: 2, + visualization: 5, + 'index-pattern': 1, + }); + }; + + const createExpectWithConflictsWithoutOverwritingResult = (spaceId?: string) => async (resp: { + [key: string]: any; + }) => { + const errorSorter = (e1: any, e2: any) => (e1.id < e2.id ? -1 : 1); + + const destination = getDestinationWithConflicts(spaceId); + + const result = resp.body as CopyResponse; + result[destination].errors!.sort(errorSorter); + + const expectedErrors = [ + { + error: { + type: 'conflict', + }, + id: 'cts_dashboard', + title: `This is the ${spaceId} test space CTS dashboard`, + type: 'dashboard', + }, + { + error: { + type: 'conflict', + }, + id: 'cts_ip_1', + title: `Copy to Space index pattern 1 from ${spaceId} space`, + type: 'index-pattern', + }, + { + error: { + type: 'conflict', + }, + id: 'cts_vis_3', + title: `CTS vis 3 from ${spaceId} space`, + type: 'visualization', + }, + ]; + expectedErrors.sort(errorSorter); + + expect(result).to.eql({ + [destination]: { + success: false, + successCount: 2, + errors: expectedErrors, + }, + } as CopyResponse); + + // Query ES to ensure that we copied everything we expected + await assertSpaceCounts(destination, { + dashboard: 2, + visualization: 5, + 'index-pattern': 1, + }); + }; + + const makeCopyToSpaceTest = (describeFn: DescribeFn) => ( + description: string, + { user = {}, spaceId = DEFAULT_SPACE_ID, tests }: CopyToSpaceTestDefinition + ) => { + describeFn(description, () => { + before(() => { + // test data only allows for the following spaces as the copy origin + 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); + 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(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: [ + { + type: 'dashboard', + id: 'cts_dashboard', + }, + ], + spaces: ['non_existent_space'], + includeReferences: false, + overwrite: true, + }) + .expect(tests.nonExistentSpace.statusCode) + .then(tests.nonExistentSpace.response); + }); + }); + }; + + const copyToSpaceTest = makeCopyToSpaceTest(describe); + // @ts-ignore + copyToSpaceTest.only = makeCopyToSpaceTest(describe.only); + + return { + copyToSpaceTest, + expectNoConflictsWithoutReferencesResult, + expectNoConflictsWithReferencesResult, + expectNoConflictsForNonExistentSpaceResult, + createExpectWithConflictsOverwritingResult, + createExpectWithConflictsWithoutOverwritingResult, + expectRbacForbiddenResponse, + expectNotFoundResponse, + createExpectUnauthorizedAtSpaceWithReferencesResult, + createExpectUnauthorizedAtSpaceWithoutReferencesResult, + 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 9ec0198001511..e3994634be1d9 100644 --- a/x-pack/test/spaces_api_integration/common/suites/delete.ts +++ b/x-pack/test/spaces_api_integration/common/suites/delete.ts @@ -66,11 +66,19 @@ export function deleteTestSuiteFactory(es: any, esArchiver: any, supertest: Supe const expectedBuckets = [ { key: 'default', - doc_count: 4, + doc_count: 9, countByType: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [ + { + key: 'visualization', + doc_count: 3, + }, + { + key: 'dashboard', + doc_count: 2, + }, { key: 'space', doc_count: 2, @@ -80,25 +88,33 @@ export function deleteTestSuiteFactory(es: any, esArchiver: any, supertest: Supe doc_count: 1, }, { - key: 'dashboard', + key: 'index-pattern', doc_count: 1, }, ], }, }, { - doc_count: 2, + doc_count: 7, key: 'space_1', countByType: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [ + { + key: 'visualization', + doc_count: 3, + }, + { + key: 'dashboard', + doc_count: 2, + }, { key: 'config', doc_count: 1, }, { - key: 'dashboard', + key: 'index-pattern', doc_count: 1, }, ], diff --git a/x-pack/test/spaces_api_integration/common/suites/get_all.ts b/x-pack/test/spaces_api_integration/common/suites/get_all.ts index ea5d391cfa0ca..b760bcfeca40f 100644 --- a/x-pack/test/spaces_api_integration/common/suites/get_all.ts +++ b/x-pack/test/spaces_api_integration/common/suites/get_all.ts @@ -15,6 +15,7 @@ interface GetAllTest { interface GetAllTests { exists: GetAllTest; + copySavedObjectsPurpose: GetAllTest; } interface GetAllTestDefinition { @@ -76,6 +77,17 @@ export function getAllTestSuiteFactory(esArchiver: any, supertest: SuperTest { + it(`should return ${tests.copySavedObjectsPurpose.statusCode}`, async () => { + return supertest + .get(`${getUrlPrefix(spaceId)}/api/spaces/space`) + .query({ purpose: 'copySavedObjectsIntoSpace' }) + .auth(user.username, user.password) + .expect(tests.copySavedObjectsPurpose.statusCode) + .then(tests.copySavedObjectsPurpose.response); + }); + }); }); }; 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 new file mode 100644 index 0000000000000..5d32d9f8adcf2 --- /dev/null +++ b/x-pack/test/spaces_api_integration/common/suites/resolve_copy_to_space_conflicts.ts @@ -0,0 +1,437 @@ +/* + * 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 expect from '@kbn/expect'; +import { SuperTest } from 'supertest'; +import { EsArchiver } from 'src/es_archiver'; +import { SavedObject } from 'src/core/server'; +import { DEFAULT_SPACE_ID } from '../../../../legacy/plugins/spaces/common/constants'; +import { CopyResponse } from '../../../../legacy/plugins/spaces/server/lib/copy_to_spaces'; +import { getUrlPrefix } from '../lib/space_test_utils'; +import { DescribeFn, TestDefinitionAuthentication } from '../lib/types'; + +type TestResponse = Record; + +interface ResolveCopyToSpaceTest { + statusCode: number; + response: (resp: TestResponse) => Promise; +} + +interface ResolveCopyToSpaceTests { + withReferencesNotOverwriting: ResolveCopyToSpaceTest; + withReferencesOverwriting: ResolveCopyToSpaceTest; + withoutReferencesOverwriting: ResolveCopyToSpaceTest; + withoutReferencesNotOverwriting: ResolveCopyToSpaceTest; + nonExistentSpace: ResolveCopyToSpaceTest; +} + +interface ResolveCopyToSpaceTestDefinition { + user?: TestDefinitionAuthentication; + spaceId?: string; + tests: ResolveCopyToSpaceTests; +} + +const NON_EXISTENT_SPACE_ID = 'non_existent_space'; + +const getDestinationSpace = (originSpaceId?: string) => { + if (!originSpaceId || originSpaceId === DEFAULT_SPACE_ID) { + return 'space_1'; + } + return DEFAULT_SPACE_ID; +}; + +export function resolveCopyToSpaceConflictsSuite( + esArchiver: EsArchiver, + supertestWithAuth: SuperTest, + supertestWithoutAuth: SuperTest +) { + const getVisualizationAtSpace = async (spaceId: string): Promise> => { + return supertestWithAuth + .get(`${getUrlPrefix(spaceId)}/api/saved_objects/visualization/cts_vis_3`) + .then((response: any) => response.body); + }; + const getDashboardAtSpace = async (spaceId: string): Promise> => { + return supertestWithAuth + .get(`${getUrlPrefix(spaceId)}/api/saved_objects/dashboard/cts_dashboard`) + .then((response: any) => response.body); + }; + + const getObjectsAtSpace = async (spaceId: string): Promise<[SavedObject, SavedObject]> => { + const dashboard = await getDashboardAtSpace(spaceId); + const visualization = await getVisualizationAtSpace(spaceId); + return [dashboard, visualization]; + }; + + const createExpectOverriddenResponseWithReferences = (sourceSpaceId: string) => async ( + response: TestResponse + ) => { + const destination = getDestinationSpace(sourceSpaceId); + const result = response.body; + expect(result).to.eql({ + [destination]: { + success: true, + successCount: 1, + }, + }); + const [dashboard, visualization] = await getObjectsAtSpace(destination); + expect(dashboard.attributes.title).to.eql( + `This is the ${destination} test space CTS dashboard` + ); + expect(visualization.attributes.title).to.eql(`CTS vis 3 from ${sourceSpaceId} space`); + }; + + const createExpectOverriddenResponseWithoutReferences = ( + sourceSpaceId: string, + destinationSpaceId: string = getDestinationSpace(sourceSpaceId) + ) => async (response: TestResponse) => { + const result = response.body; + expect(result).to.eql({ + [destinationSpaceId]: { + success: true, + successCount: 1, + }, + }); + const [dashboard, visualization] = await getObjectsAtSpace(destinationSpaceId); + expect(dashboard.attributes.title).to.eql( + `This is the ${sourceSpaceId} test space CTS dashboard` + ); + if (destinationSpaceId === NON_EXISTENT_SPACE_ID) { + expect((visualization as any).statusCode).to.eql(404); + } else { + expect(visualization.attributes.title).to.eql(`CTS vis 3 from ${destinationSpaceId} space`); + } + }; + + const createExpectNonOverriddenResponseWithReferences = (sourceSpaceId: string) => async ( + response: TestResponse + ) => { + const destination = getDestinationSpace(sourceSpaceId); + + const result = response.body; + expect(result).to.eql({ + [destination]: { + success: false, + successCount: 0, + errors: [ + { + error: { + type: 'conflict', + }, + id: 'cts_vis_3', + title: `CTS vis 3 from ${sourceSpaceId} space`, + type: 'visualization', + }, + ], + }, + }); + + const [dashboard, visualization] = await getObjectsAtSpace(destination); + expect(dashboard.attributes.title).to.eql( + `This is the ${destination} test space CTS dashboard` + ); + expect(visualization.attributes.title).to.eql(`CTS vis 3 from ${destination} space`); + }; + + const createExpectNonOverriddenResponseWithoutReferences = (sourceSpaceId: string) => async ( + response: TestResponse + ) => { + const destination = getDestinationSpace(sourceSpaceId); + + const result = response.body; + expect(result).to.eql({ + [destination]: { + success: false, + successCount: 0, + errors: [ + { + error: { + type: 'conflict', + }, + id: 'cts_dashboard', + title: `This is the ${sourceSpaceId} test space CTS dashboard`, + type: 'dashboard', + }, + ], + }, + }); + + const [dashboard, visualization] = await getObjectsAtSpace(destination); + expect(dashboard.attributes.title).to.eql( + `This is the ${destination} test space CTS dashboard` + ); + expect(visualization.attributes.title).to.eql(`CTS vis 3 from ${destination} space`); + }; + + const expectNotFoundResponse = async (resp: TestResponse) => { + expect(resp.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Not Found', + }); + }; + + const createExpectUnauthorizedAtSpaceWithReferencesResult = ( + spaceId: string = DEFAULT_SPACE_ID + ) => async (resp: TestResponse) => { + const destination = getDestinationSpace(spaceId); + + const result = resp.body as CopyResponse; + expect(result).to.eql({ + [destination]: { + success: false, + successCount: 0, + errors: [ + { + statusCode: 403, + error: 'Forbidden', + message: 'Unable to bulk_get index-pattern', + }, + ], + }, + } as CopyResponse); + + // Query ES to ensure that nothing was copied + const [dashboard, visualization] = await getObjectsAtSpace(destination); + expect(dashboard.attributes.title).to.eql( + `This is the ${destination} test space CTS dashboard` + ); + expect(visualization.attributes.title).to.eql(`CTS vis 3 from ${destination} space`); + }; + + const createExpectReadonlyAtSpaceWithReferencesResult = ( + spaceId: string = DEFAULT_SPACE_ID + ) => async (resp: TestResponse) => { + const destination = getDestinationSpace(spaceId); + + const result = resp.body as CopyResponse; + expect(result).to.eql({ + [destination]: { + success: false, + successCount: 0, + errors: [ + { + statusCode: 403, + error: 'Forbidden', + message: 'Unable to bulk_create visualization', + }, + ], + }, + } as CopyResponse); + + // Query ES to ensure that nothing was copied + const [dashboard, visualization] = await getObjectsAtSpace(destination); + expect(dashboard.attributes.title).to.eql( + `This is the ${destination} test space CTS dashboard` + ); + expect(visualization.attributes.title).to.eql(`CTS vis 3 from ${destination} space`); + }; + + const createExpectUnauthorizedAtSpaceWithoutReferencesResult = ( + sourceSpaceId: string = DEFAULT_SPACE_ID, + destinationSpaceId: string = getDestinationSpace(sourceSpaceId) + ) => async (resp: TestResponse) => { + const result = resp.body as CopyResponse; + expect(result).to.eql({ + [destinationSpaceId]: { + success: false, + successCount: 0, + errors: [ + { + statusCode: 403, + error: 'Forbidden', + message: 'Unable to bulk_create dashboard', + }, + ], + }, + } as CopyResponse); + + // Query ES to ensure that nothing was copied + const [dashboard, visualization] = await getObjectsAtSpace(destinationSpaceId); + + if (destinationSpaceId === NON_EXISTENT_SPACE_ID) { + expect((dashboard as any).statusCode).to.eql(404); + expect((visualization as any).statusCode).to.eql(404); + } else { + expect(dashboard.attributes.title).to.eql( + `This is the ${destinationSpaceId} test space CTS dashboard` + ); + expect(visualization.attributes.title).to.eql(`CTS vis 3 from ${destinationSpaceId} space`); + } + }; + + const makeResolveCopyToSpaceConflictsTest = (describeFn: DescribeFn) => ( + description: string, + { user = {}, spaceId = DEFAULT_SPACE_ID, tests }: ResolveCopyToSpaceTestDefinition + ) => { + describeFn(description, () => { + before(() => { + // test data only allows for the following spaces as the copy origin + 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); + }); + + 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 resolveCopyToSpaceConflictsTest = makeResolveCopyToSpaceConflictsTest(describe); + // @ts-ignore + resolveCopyToSpaceConflictsTest.only = makeResolveCopyToSpaceConflictsTest(describe.only); + + return { + resolveCopyToSpaceConflictsTest, + expectNotFoundResponse, + createExpectOverriddenResponseWithReferences, + createExpectOverriddenResponseWithoutReferences, + createExpectNonOverriddenResponseWithReferences, + createExpectNonOverriddenResponseWithoutReferences, + createExpectUnauthorizedAtSpaceWithReferencesResult, + createExpectReadonlyAtSpaceWithReferencesResult, + createExpectUnauthorizedAtSpaceWithoutReferencesResult, + 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 new file mode 100644 index 0000000000000..c6c41f7cd3a97 --- /dev/null +++ b/x-pack/test/spaces_api_integration/security_and_spaces/apis/copy_to_space.ts @@ -0,0 +1,379 @@ +/* + * 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 { AUTHENTICATION } from '../../common/lib/authentication'; +import { SPACES } from '../../common/lib/spaces'; +import { TestInvoker } from '../../common/lib/types'; +import { copyToSpaceTestSuiteFactory } from '../../common/suites/copy_to_space'; + +// eslint-disable-next-line import/no-default-export +export default function copyToSpaceSpacesAndSecuritySuite({ getService }: TestInvoker) { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + const es = getService('es'); + + const { + copyToSpaceTest, + expectNoConflictsWithoutReferencesResult, + expectNoConflictsWithReferencesResult, + expectNoConflictsForNonExistentSpaceResult, + createExpectWithConflictsOverwritingResult, + createExpectWithConflictsWithoutOverwritingResult, + createExpectUnauthorizedAtSpaceWithReferencesResult, + createExpectUnauthorizedAtSpaceWithoutReferencesResult, + expectNotFoundResponse, + } = copyToSpaceTestSuiteFactory(es, esArchiver, supertestWithoutAuth); + + describe('copy to spaces', () => { + [ + { + spaceId: SPACES.DEFAULT.spaceId, + users: { + noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, + superuser: AUTHENTICATION.SUPERUSER, + allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, + readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, + allAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, + legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, + dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, + dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, + }, + }, + { + spaceId: SPACES.SPACE_1.spaceId, + users: { + noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, + superuser: AUTHENTICATION.SUPERUSER, + allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, + readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, + allAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, + legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, + dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, + 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, + 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, + }, + }, + }); + + 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, + }, + }, + }); + + 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, + }, + }, + }); + + 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, + }, + }, + }); + + 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, + }, + }, + }); + + copyToSpaceTest(`rbac user with read globally from the ${scenario.spaceId} space`, { + spaceId: scenario.spaceId, + user: scenario.users.readGlobally, + 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' + ), + }, + }, + }); + + copyToSpaceTest(`dual-privileges readonly user from the ${scenario.spaceId} space`, { + spaceId: scenario.spaceId, + user: scenario.users.dualRead, + 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' + ), + }, + }, + }); + + copyToSpaceTest(`rbac user with all at space from the ${scenario.spaceId} space`, { + spaceId: scenario.spaceId, + user: scenario.users.allAtSpace, + 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' + ), + }, + }, + }); + }); + }); +} diff --git a/x-pack/test/spaces_api_integration/security_and_spaces/apis/get_all.ts b/x-pack/test/spaces_api_integration/security_and_spaces/apis/get_all.ts index 54557080867cc..cbac532900d0f 100644 --- a/x-pack/test/spaces_api_integration/security_and_spaces/apis/get_all.ts +++ b/x-pack/test/spaces_api_integration/security_and_spaces/apis/get_all.ts @@ -32,6 +32,12 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { readAtSpace_1: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, allAtDefaultSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, readAtDefaultSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, + readSavedObjectsAtDefaultSpace: + AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_SAVED_OBJECTS_READ_USER, + allSavedObjectsAtDefaultSpace: + AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_SAVED_OBJECTS_ALL_USER, + readSavedObjectsAtSpace_1: AUTHENTICATION.KIBANA_RBAC_SPACE_1_SAVED_OBJECTS_READ_USER, + allSavedObjectsAtSpace_1: AUTHENTICATION.KIBANA_RBAC_SPACE_1_SAVED_OBJECTS_ALL_USER, legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, @@ -52,6 +58,12 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { readAtSpace_1: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, allAtDefaultSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, readAtDefaultSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, + readSavedObjectsAtDefaultSpace: + AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_SAVED_OBJECTS_READ_USER, + allSavedObjectsAtDefaultSpace: + AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_SAVED_OBJECTS_ALL_USER, + readSavedObjectsAtSpace_1: AUTHENTICATION.KIBANA_RBAC_SPACE_1_SAVED_OBJECTS_READ_USER, + allSavedObjectsAtSpace_1: AUTHENTICATION.KIBANA_RBAC_SPACE_1_SAVED_OBJECTS_ALL_USER, legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, @@ -70,6 +82,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 403, response: expectRbacForbidden, }, + copySavedObjectsPurpose: { + statusCode: 403, + response: expectRbacForbidden, + }, }, }); @@ -81,6 +97,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 200, response: createExpectResults('default', 'space_1', 'space_2'), }, + copySavedObjectsPurpose: { + statusCode: 200, + response: createExpectResults('default', 'space_1', 'space_2'), + }, }, }); @@ -92,6 +112,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 200, response: createExpectResults('default', 'space_1', 'space_2'), }, + copySavedObjectsPurpose: { + statusCode: 200, + response: createExpectResults('default', 'space_1', 'space_2'), + }, }, }); @@ -103,6 +127,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 200, response: createExpectResults('default', 'space_1', 'space_2'), }, + copySavedObjectsPurpose: { + statusCode: 200, + response: createExpectResults('default', 'space_1', 'space_2'), + }, }, }); @@ -114,6 +142,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 403, response: expectRbacForbidden, }, + copySavedObjectsPurpose: { + statusCode: 403, + response: expectRbacForbidden, + }, }, }); @@ -125,6 +157,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 200, response: createExpectResults('default', 'space_1', 'space_2'), }, + copySavedObjectsPurpose: { + statusCode: 403, + response: expectRbacForbidden, + }, }, }); @@ -136,6 +172,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 200, response: createExpectResults('default', 'space_1', 'space_2'), }, + copySavedObjectsPurpose: { + statusCode: 403, + response: expectRbacForbidden, + }, }, }); @@ -147,6 +187,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 200, response: createExpectResults('space_1'), }, + copySavedObjectsPurpose: { + statusCode: 200, + response: createExpectResults('space_1'), + }, }, }); @@ -158,6 +202,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 200, response: createExpectResults('space_1'), }, + copySavedObjectsPurpose: { + statusCode: 403, + response: expectRbacForbidden, + }, }, }); @@ -171,6 +219,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 200, response: createExpectResults('default'), }, + copySavedObjectsPurpose: { + statusCode: 200, + response: createExpectResults('default'), + }, }, } ); @@ -185,6 +237,82 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 200, response: createExpectResults('default'), }, + copySavedObjectsPurpose: { + statusCode: 403, + response: expectRbacForbidden, + }, + }, + } + ); + + getAllTest( + `rbac user with saved objects management all at default space can access default from ${scenario.spaceId}`, + { + spaceId: scenario.spaceId, + user: scenario.users.allSavedObjectsAtDefaultSpace, + tests: { + exists: { + statusCode: 200, + response: createExpectResults('default'), + }, + copySavedObjectsPurpose: { + statusCode: 200, + response: createExpectResults('default'), + }, + }, + } + ); + + getAllTest( + `rbac user with saved objects management read at default space can access default from ${scenario.spaceId}`, + { + spaceId: scenario.spaceId, + user: scenario.users.readSavedObjectsAtDefaultSpace, + tests: { + exists: { + statusCode: 200, + response: createExpectResults('default'), + }, + copySavedObjectsPurpose: { + statusCode: 403, + response: expectRbacForbidden, + }, + }, + } + ); + + getAllTest( + `rbac user with saved objects management all at space_1 space can access space_1 from ${scenario.spaceId}`, + { + spaceId: scenario.spaceId, + user: scenario.users.allSavedObjectsAtSpace_1, + tests: { + exists: { + statusCode: 200, + response: createExpectResults('space_1'), + }, + copySavedObjectsPurpose: { + statusCode: 200, + response: createExpectResults('space_1'), + }, + }, + } + ); + + getAllTest( + `rbac user with saved objects management read at space_1 space can access space_1 from ${scenario.spaceId}`, + { + spaceId: scenario.spaceId, + user: scenario.users.readSavedObjectsAtSpace_1, + tests: { + exists: { + statusCode: 200, + response: createExpectResults('space_1'), + }, + copySavedObjectsPurpose: { + statusCode: 403, + response: expectRbacForbidden, + }, }, } ); @@ -197,6 +325,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 403, response: expectRbacForbidden, }, + copySavedObjectsPurpose: { + statusCode: 403, + response: expectRbacForbidden, + }, }, }); @@ -208,6 +340,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 403, response: expectRbacForbidden, }, + copySavedObjectsPurpose: { + statusCode: 403, + response: expectRbacForbidden, + }, }, }); @@ -219,6 +355,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 403, response: expectRbacForbidden, }, + copySavedObjectsPurpose: { + statusCode: 403, + response: expectRbacForbidden, + }, }, }); @@ -230,6 +370,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 403, response: expectRbacForbidden, }, + copySavedObjectsPurpose: { + statusCode: 403, + response: expectRbacForbidden, + }, }, }); }); diff --git a/x-pack/test/spaces_api_integration/security_and_spaces/apis/index.ts b/x-pack/test/spaces_api_integration/security_and_spaces/apis/index.ts index 0c58865acd939..4493a5332b62c 100644 --- a/x-pack/test/spaces_api_integration/security_and_spaces/apis/index.ts +++ b/x-pack/test/spaces_api_integration/security_and_spaces/apis/index.ts @@ -19,6 +19,8 @@ export default function({ loadTestFile, getService }: TestInvoker) { await createUsersAndRoles(es, supertest); }); + loadTestFile(require.resolve('./copy_to_space')); + loadTestFile(require.resolve('./resolve_copy_to_space_conflicts')); loadTestFile(require.resolve('./create')); loadTestFile(require.resolve('./delete')); loadTestFile(require.resolve('./get_all')); 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 new file mode 100644 index 0000000000000..b1023ebede3cc --- /dev/null +++ b/x-pack/test/spaces_api_integration/security_and_spaces/apis/resolve_copy_to_space_conflicts.ts @@ -0,0 +1,307 @@ +/* + * 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 { AUTHENTICATION } from '../../common/lib/authentication'; +import { SPACES } from '../../common/lib/spaces'; +import { TestInvoker } from '../../common/lib/types'; +import { resolveCopyToSpaceConflictsSuite } from '../../common/suites/resolve_copy_to_space_conflicts'; + +// eslint-disable-next-line import/no-default-export +export default function resolveCopyToSpaceConflictsTestSuite({ getService }: TestInvoker) { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const supertestWithAuth = getService('supertest'); + const esArchiver = getService('esArchiver'); + + const { + resolveCopyToSpaceConflictsTest, + createExpectNonOverriddenResponseWithReferences, + createExpectNonOverriddenResponseWithoutReferences, + createExpectOverriddenResponseWithReferences, + createExpectOverriddenResponseWithoutReferences, + expectNotFoundResponse, + createExpectUnauthorizedAtSpaceWithReferencesResult, + createExpectReadonlyAtSpaceWithReferencesResult, + createExpectUnauthorizedAtSpaceWithoutReferencesResult, + NON_EXISTENT_SPACE_ID, + } = resolveCopyToSpaceConflictsSuite(esArchiver, supertestWithAuth, supertestWithoutAuth); + + describe('resolve copy to spaces conflicts', () => { + [ + { + spaceId: SPACES.DEFAULT.spaceId, + users: { + noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, + superuser: AUTHENTICATION.SUPERUSER, + allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, + readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, + allAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, + legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, + dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, + dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, + }, + }, + { + spaceId: SPACES.SPACE_1.spaceId, + users: { + noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, + superuser: AUTHENTICATION.SUPERUSER, + allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, + readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, + allAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, + legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, + dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, + 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, + tests: { + withReferencesNotOverwriting: { + statusCode: 404, + response: expectNotFoundResponse, + }, + withReferencesOverwriting: { + statusCode: 404, + response: expectNotFoundResponse, + }, + withoutReferencesOverwriting: { + statusCode: 404, + response: expectNotFoundResponse, + }, + withoutReferencesNotOverwriting: { + statusCode: 404, + response: expectNotFoundResponse, + }, + nonExistentSpace: { + statusCode: 404, + response: expectNotFoundResponse, + }, + }, + }); + + resolveCopyToSpaceConflictsTest(`superuser from the ${scenario.spaceId} space`, { + spaceId: scenario.spaceId, + user: scenario.users.superuser, + 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( + `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, + 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(`legacy user from the ${scenario.spaceId} space`, { + spaceId: scenario.spaceId, + user: scenario.users.legacyAll, + tests: { + withReferencesNotOverwriting: { + statusCode: 404, + response: expectNotFoundResponse, + }, + withReferencesOverwriting: { + statusCode: 404, + response: expectNotFoundResponse, + }, + withoutReferencesOverwriting: { + statusCode: 404, + response: expectNotFoundResponse, + }, + withoutReferencesNotOverwriting: { + statusCode: 404, + response: expectNotFoundResponse, + }, + nonExistentSpace: { + statusCode: 404, + response: expectNotFoundResponse, + }, + }, + }); + + 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 + ), + }, + }, + } + ); + + 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 + ), + }, + }, + } + ); + + 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 + ), + }, + }, + } + ); + }); + }); +} 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 new file mode 100644 index 0000000000000..b1427a9b77be0 --- /dev/null +++ b/x-pack/test/spaces_api_integration/spaces_only/apis/copy_to_space.ts @@ -0,0 +1,60 @@ +/* + * 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 { FtrProviderContext } from '../../../api_integration/ftr_provider_context'; +import { copyToSpaceTestSuiteFactory } from '../../common/suites/copy_to_space'; + +// eslint-disable-next-line import/no-default-export +export default function copyToSpacesOnlySuite({ getService }: FtrProviderContext) { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + const es = getService('es'); + + const { + copyToSpaceTest, + expectNoConflictsWithoutReferencesResult, + expectNoConflictsWithReferencesResult, + expectNoConflictsForNonExistentSpaceResult, + createExpectWithConflictsOverwritingResult, + createExpectWithConflictsWithoutOverwritingResult, + originSpaces, + } = copyToSpaceTestSuiteFactory(es, esArchiver, supertestWithoutAuth); + + describe('copy to spaces', () => { + originSpaces.forEach(spaceId => { + copyToSpaceTest(`from the ${spaceId} space`, { + spaceId, + tests: { + noConflictsWithoutReferences: { + statusCode: 200, + response: expectNoConflictsWithoutReferencesResult, + }, + noConflictsWithReferences: { + statusCode: 200, + response: expectNoConflictsWithReferencesResult, + }, + withConflictsOverwriting: { + statusCode: 200, + response: createExpectWithConflictsOverwritingResult(spaceId), + }, + withConflictsWithoutOverwriting: { + statusCode: 200, + response: createExpectWithConflictsWithoutOverwritingResult(spaceId), + }, + multipleSpaces: { + statusCode: 200, + withConflictsResponse: createExpectWithConflictsOverwritingResult(spaceId), + noConflictsResponse: expectNoConflictsWithReferencesResult, + }, + nonExistentSpace: { + statusCode: 200, + response: expectNoConflictsForNonExistentSpaceResult, + }, + }, + }); + }); + }); +} diff --git a/x-pack/test/spaces_api_integration/spaces_only/apis/get_all.ts b/x-pack/test/spaces_api_integration/spaces_only/apis/get_all.ts index 13cb17a7efffd..e147e501cc684 100644 --- a/x-pack/test/spaces_api_integration/spaces_only/apis/get_all.ts +++ b/x-pack/test/spaces_api_integration/spaces_only/apis/get_all.ts @@ -34,6 +34,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 200, response: createExpectResults('default', 'space_1', 'space_2'), }, + copySavedObjectsPurpose: { + statusCode: 200, + response: createExpectResults('default', 'space_1', 'space_2'), + }, }, }); }); diff --git a/x-pack/test/spaces_api_integration/spaces_only/apis/index.ts b/x-pack/test/spaces_api_integration/spaces_only/apis/index.ts index 1a3b6ca457b39..764d1cfae22b6 100644 --- a/x-pack/test/spaces_api_integration/spaces_only/apis/index.ts +++ b/x-pack/test/spaces_api_integration/spaces_only/apis/index.ts @@ -11,6 +11,8 @@ export default function spacesOnlyTestSuite({ loadTestFile }: TestInvoker) { describe('spaces api without security', function() { this.tags('ciGroup5'); + loadTestFile(require.resolve('./copy_to_space')); + loadTestFile(require.resolve('./resolve_copy_to_space_conflicts')); loadTestFile(require.resolve('./create')); loadTestFile(require.resolve('./delete')); loadTestFile(require.resolve('./get_all')); 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 new file mode 100644 index 0000000000000..7fbec27ee568d --- /dev/null +++ b/x-pack/test/spaces_api_integration/spaces_only/apis/resolve_copy_to_space_conflicts.ts @@ -0,0 +1,58 @@ +/* + * 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 { FtrProviderContext } from '../../../api_integration/ftr_provider_context'; +import { resolveCopyToSpaceConflictsSuite } from '../../common/suites/resolve_copy_to_space_conflicts'; + +// eslint-disable-next-line import/no-default-export +export default function resolveCopyToSpaceConflictsTestSuite({ getService }: FtrProviderContext) { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const supertestWithAuth = getService('supertest'); + const esArchiver = getService('esArchiver'); + + const { + resolveCopyToSpaceConflictsTest, + createExpectNonOverriddenResponseWithReferences, + createExpectNonOverriddenResponseWithoutReferences, + createExpectOverriddenResponseWithReferences, + createExpectOverriddenResponseWithoutReferences, + NON_EXISTENT_SPACE_ID, + originSpaces, + } = resolveCopyToSpaceConflictsSuite(esArchiver, supertestWithAuth, supertestWithoutAuth); + + describe('resolve copy to spaces conflicts', () => { + originSpaces.forEach(spaceId => { + resolveCopyToSpaceConflictsTest(`from the ${spaceId} space`, { + spaceId, + tests: { + withReferencesNotOverwriting: { + statusCode: 200, + response: createExpectNonOverriddenResponseWithReferences(spaceId), + }, + withReferencesOverwriting: { + statusCode: 200, + response: createExpectOverriddenResponseWithReferences(spaceId), + }, + withoutReferencesOverwriting: { + statusCode: 200, + response: createExpectOverriddenResponseWithoutReferences(spaceId), + }, + withoutReferencesNotOverwriting: { + statusCode: 200, + response: createExpectNonOverriddenResponseWithoutReferences(spaceId), + }, + nonExistentSpace: { + statusCode: 200, + response: createExpectOverriddenResponseWithoutReferences( + spaceId, + NON_EXISTENT_SPACE_ID + ), + }, + }, + }); + }); + }); +}