From f2016f09aef72adabf4fbe86c395790177f1c434 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20C=C3=B4t=C3=A9?= Date: Fri, 8 Mar 2019 14:23:10 -0500 Subject: [PATCH] Basic server side import API for saved objects (#32158) (#32790) * Initial work * Add overwrite and skip support * Cleanup and add tests * Move code into separate files * Remove reduce * New API parameters * Add support to replace references * Add better error handling * Add spaces tests * Fix return type in collectSavedObjects * Apply PR feedback * Update jest tests due to jest version upgrade * Add docs * WIP * Split import routes pt1 * Add tests * Fix broken tests * Update docs and fix broken test * Add successCount to _import endpoint * Make skip by default in resolution API * Update tests for removal of skips * Add back support for skips * Add success count * Add back resolve import conflicts x-pack tests * Remove writev from filter stream * Delete _mock_server.d.ts file * Rename lib/import_saved_objects to lib/import * Filter records at stream level for conflict resolution * Update docs * Add tests to validate documentation * Return 200 instead of other code for errors, include errors array * Change [] to {} * Apply PR feedback * Fix import object limit to not return 500 * Change some wording in the docs * Fix status code * Apply PR feedback pt2 * Lower maxImportPayloadBytes to 10MB * Add unknown type tests for import * Add unknown type tests for resolve_import_conflicts * Fix tslint issues --- docs/api/saved-objects.asciidoc | 4 + docs/api/saved-objects/import.asciidoc | 96 +++ .../resolve_import_conflicts.asciidoc | 104 +++ src/legacy/server/config/schema.js | 1 + .../server/saved_objects/lib/import.test.ts | 815 ++++++++++++++++++ src/legacy/server/saved_objects/lib/import.ts | 218 +++++ .../_mock_server.d.ts => lib/index.ts} | 5 +- .../saved_objects/routes/_mock_server.ts | 1 + .../server/saved_objects/routes/export.ts | 2 +- .../saved_objects/routes/import.test.ts | 188 ++++ .../server/saved_objects/routes/import.ts | 82 ++ .../server/saved_objects/routes/index.ts | 2 + .../routes/resolve_import_conflicts.test.ts | 229 +++++ .../routes/resolve_import_conflicts.ts | 114 +++ .../saved_objects/saved_objects_mixin.js | 4 + .../saved_objects/saved_objects_mixin.test.js | 13 +- .../service/saved_objects_client.d.ts | 2 + .../service/saved_objects_client.js | 2 + .../utils/streams/filter_stream.test.ts | 77 ++ src/legacy/utils/streams/filter_stream.ts | 33 + src/legacy/utils/streams/index.d.ts | 31 + src/legacy/utils/streams/index.js | 1 + .../apis/saved_objects/import.js | 135 +++ .../apis/saved_objects/index.js | 2 + .../saved_objects/resolve_import_conflicts.js | 239 +++++ test/api_integration/fixtures/import.ndjson | 3 + .../common/suites/import.ts | 152 ++++ .../common/suites/resolve_import_conflicts.ts | 185 ++++ .../security_and_spaces/apis/import.ts | 211 +++++ .../security_and_spaces/apis/index.ts | 2 + .../apis/resolve_import_conflicts.ts | 229 +++++ .../security_only/apis/import.ts | 181 ++++ .../security_only/apis/index.ts | 2 + .../apis/resolve_import_conflicts.ts | 181 ++++ .../spaces_only/apis/import.ts | 52 ++ .../spaces_only/apis/index.ts | 2 + .../apis/resolve_import_conflicts.ts | 52 ++ 37 files changed, 3646 insertions(+), 6 deletions(-) create mode 100644 docs/api/saved-objects/import.asciidoc create mode 100644 docs/api/saved-objects/resolve_import_conflicts.asciidoc create mode 100644 src/legacy/server/saved_objects/lib/import.test.ts create mode 100644 src/legacy/server/saved_objects/lib/import.ts rename src/legacy/server/saved_objects/{routes/_mock_server.d.ts => lib/index.ts} (86%) create mode 100644 src/legacy/server/saved_objects/routes/import.test.ts create mode 100644 src/legacy/server/saved_objects/routes/import.ts create mode 100644 src/legacy/server/saved_objects/routes/resolve_import_conflicts.test.ts create mode 100644 src/legacy/server/saved_objects/routes/resolve_import_conflicts.ts create mode 100644 src/legacy/utils/streams/filter_stream.test.ts create mode 100644 src/legacy/utils/streams/filter_stream.ts create mode 100644 src/legacy/utils/streams/index.d.ts create mode 100644 test/api_integration/apis/saved_objects/import.js create mode 100644 test/api_integration/apis/saved_objects/resolve_import_conflicts.js create mode 100644 test/api_integration/fixtures/import.ndjson create mode 100644 x-pack/test/saved_object_api_integration/common/suites/import.ts create mode 100644 x-pack/test/saved_object_api_integration/common/suites/resolve_import_conflicts.ts create mode 100644 x-pack/test/saved_object_api_integration/security_and_spaces/apis/import.ts create mode 100644 x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve_import_conflicts.ts create mode 100644 x-pack/test/saved_object_api_integration/security_only/apis/import.ts create mode 100644 x-pack/test/saved_object_api_integration/security_only/apis/resolve_import_conflicts.ts create mode 100644 x-pack/test/saved_object_api_integration/spaces_only/apis/import.ts create mode 100644 x-pack/test/saved_object_api_integration/spaces_only/apis/resolve_import_conflicts.ts diff --git a/docs/api/saved-objects.asciidoc b/docs/api/saved-objects.asciidoc index d60c2983a0e7a..6f0a096d18e96 100644 --- a/docs/api/saved-objects.asciidoc +++ b/docs/api/saved-objects.asciidoc @@ -19,6 +19,8 @@ NOTE: You cannot access these endpoints via the Console in Kibana. * <> * <> * <> +* <> +* <> include::saved-objects/get.asciidoc[] include::saved-objects/bulk_get.asciidoc[] @@ -28,3 +30,5 @@ include::saved-objects/bulk_create.asciidoc[] include::saved-objects/update.asciidoc[] include::saved-objects/delete.asciidoc[] include::saved-objects/export.asciidoc[] +include::saved-objects/import.asciidoc[] +include::saved-objects/resolve_import_conflicts.asciidoc[] diff --git a/docs/api/saved-objects/import.asciidoc b/docs/api/saved-objects/import.asciidoc new file mode 100644 index 0000000000000..d1df7c3808124 --- /dev/null +++ b/docs/api/saved-objects/import.asciidoc @@ -0,0 +1,96 @@ +[[saved-objects-api-import]] +=== Import Objects + +experimental[This functionality is *experimental* and may be changed or removed completely in a future release.] + +The import saved objects API enables you to create a set of Kibana saved objects from a file created by the export API. + +Note: You cannot access this endpoint via the Console in Kibana. + +==== Request + +`POST /api/saved_objects/_import` + +==== Query Parameters + +`overwrite` (optional):: + (boolean) Overwrite saved objects if they exist already + +==== Request body + +The request body must be of type multipart/form-data. + +`file`:: + A file exported using the export API. + +==== Response body + +The response body will have a top level `success` property that indicates +if the import was successful or not as well as a `successCount` indicating how many records are successfully imported. +In the scenario the import wasn't successful a top level `errors` array will contain the objects that failed to import. + +==== Examples + +The following example imports an index pattern and dashboard. + +[source,js] +-------------------------------------------------- +POST api/saved_objects/_import +Content-Type: multipart/form-data; boundary=EXAMPLE +--EXAMPLE +Content-Disposition: form-data; name="file"; filename="export.ndjson" +Content-Type: application/ndjson + +{"type":"index-pattern","id":"my-pattern","attributes":{"title":"my-pattern-*"}} +{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"}} +--EXAMPLE-- +-------------------------------------------------- +// KIBANA + +A successful call returns a response code of `200` and a response body +containing a JSON structure similar to the following example: + +[source,js] +-------------------------------------------------- +{ + "success": true, + "successCount": 2 +} +-------------------------------------------------- + +The following example imports an index pattern and dashboard but has a conflict on the index pattern. + +[source,js] +-------------------------------------------------- +POST api/saved_objects/_import +Content-Type: multipart/form-data; boundary=EXAMPLE +--EXAMPLE +Content-Disposition: form-data; name="file"; filename="export.ndjson" +Content-Type: application/ndjson + +{"type":"index-pattern","id":"my-pattern","attributes":{"title":"my-pattern-*"}} +{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"}} +--EXAMPLE-- +-------------------------------------------------- +// KIBANA + +The call returns a response code of `200` and a response body +containing a JSON structure similar to the following example: + +[source,js] +-------------------------------------------------- +{ + "success": false, + "successCount": 1, + "errors": [ + { + "id": "my-pattern", + "type": "index-pattern", + "error": { + "statusCode": 409, + "message": "version conflict, document already exists", + }, + }, + ], +} +-------------------------------------------------- diff --git a/docs/api/saved-objects/resolve_import_conflicts.asciidoc b/docs/api/saved-objects/resolve_import_conflicts.asciidoc new file mode 100644 index 0000000000000..be022c0ed6833 --- /dev/null +++ b/docs/api/saved-objects/resolve_import_conflicts.asciidoc @@ -0,0 +1,104 @@ +[[saved-objects-api-resolve-import-conflicts]] +=== Resolve Import Conflicts + +experimental[This functionality is *experimental* and may be changed or removed completely in a future release.] + +The resolve import conflicts API enables you to resolve conflicts given by the import API by either overwriting specific saved objects or changing references to a newly created object. + +Note: You cannot access this endpoint via the Console in Kibana. + +==== Request + +`POST /api/saved_objects/_resolve_import_conflicts` + +==== Request body + +The request body must be of type multipart/form-data. + +`file`:: + (ndjson) The same new line delimited JSON objects given to the import API. + +`overwrites` (optional):: + (array) A list of `type` and `id` objects allowed to be overwritten on import. + +`replaceReferences` (optional):: + (array) A list of `type`, `from` and `to` used to change imported saved object references to. + +`skips` (optional):: + (array) A list of `type` and `id` objects to skip importing. + +==== Response body + +The response body will have a top level `success` property that indicates +if the import was successful or not as well as a `successCount` indicating how many records are successfully resolved. +In the scenario the import wasn't successful a top level `errors` array will contain the objects that failed to import. + +==== Examples + +The following example resolves conflicts for an index pattern and dashboard but indicates to skip the index pattern. +This will cause the index pattern to not be in the system and the dashboard to overwrite the existing saved object. + +[source,js] +-------------------------------------------------- +POST api/saved_objects/_resolve_import_conflicts +Content-Type: multipart/form-data; boundary=EXAMPLE +--EXAMPLE +Content-Disposition: form-data; name="file"; filename="export.ndjson" +Content-Type: application/ndjson + +{"type":"index-pattern","id":"my-pattern","attributes":{"title":"my-pattern-*"}} +{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"}} +--EXAMPLE +Content-Disposition: form-data; name="skips" + +[{"type":"index-pattern","id":"my-pattern"}] +--EXAMPLE +Content-Disposition: form-data; name="overwrites" + +[{"type":"dashboard","id":"my-dashboard"}] +--EXAMPLE-- +-------------------------------------------------- +// KIBANA + +A successful call returns a response code of `200` and a response body +containing a JSON structure similar to the following example: + +[source,js] +-------------------------------------------------- +{ + "success": true, + "successCount": 1 +} +-------------------------------------------------- + +The following example resolves conflicts for a visualization and dashboard but indicates +to replace the dashboard references to another visualization. + +[source,js] +-------------------------------------------------- +POST api/saved_objects/_resolve_import_conflicts +Content-Type: multipart/form-data; boundary=EXAMPLE +--EXAMPLE +Content-Disposition: form-data; name="file"; filename="export.ndjson" +Content-Type: application/ndjson + +{"type":"visualization","id":"my-vis","attributes":{"title":"Look at my visualization"}} +{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"},"references":[{"name":"panel_0","type":"visualization","id":"my-vis"}]} +--EXAMPLE +Content-Disposition: form-data; name="replaceReferences" + +[{"type":"visualization","from":"my-vis","to":"my-vis-2"}] +--EXAMPLE-- +-------------------------------------------------- +// KIBANA + +A successful call returns a response code of `200` and a response body +containing a JSON structure similar to the following example: + +[source,js] +-------------------------------------------------- +{ + "success": true, + "successCount": 1 +} +-------------------------------------------------- diff --git a/src/legacy/server/config/schema.js b/src/legacy/server/config/schema.js index d9f6d04bde724..97d7da4d8f192 100644 --- a/src/legacy/server/config/schema.js +++ b/src/legacy/server/config/schema.js @@ -252,6 +252,7 @@ export default () => Joi.object({ }).default(), savedObjects: Joi.object({ + maxImportPayloadBytes: Joi.number().default(10485760), maxImportExportSize: Joi.number().default(10000), }).default(), diff --git a/src/legacy/server/saved_objects/lib/import.test.ts b/src/legacy/server/saved_objects/lib/import.test.ts new file mode 100644 index 0000000000000..98fea6765bcb6 --- /dev/null +++ b/src/legacy/server/saved_objects/lib/import.test.ts @@ -0,0 +1,815 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Readable } from 'stream'; +import { + createConcatStream, + createListStream, + createPromiseFromStreams, +} from '../../../utils/streams'; +import { SavedObject } from '../service'; +import { + collectSavedObjects, + createLimitStream, + createObjectsFilter, + extractErrors, + importSavedObjects, + resolveImportConflicts, +} from './import'; + +describe('extractErrors()', () => { + test('returns empty array when no errors exist', () => { + const savedObjects: SavedObject[] = []; + const result = extractErrors(savedObjects); + expect(result).toMatchInlineSnapshot(`Array []`); + }); + + test('extracts errors from saved objects', () => { + const savedObjects: SavedObject[] = [ + { + id: '1', + type: 'dashboard', + attributes: {}, + references: [], + }, + { + id: '2', + type: 'dashboard', + attributes: {}, + references: [], + error: { + statusCode: 409, + message: 'Conflict', + }, + }, + ]; + const result = extractErrors(savedObjects); + expect(result).toMatchInlineSnapshot(` +Array [ + Object { + "error": Object { + "message": "Conflict", + "statusCode": 409, + }, + "id": "2", + "type": "dashboard", + }, +] +`); + }); +}); + +describe('createLimitStream()', () => { + test('limit of 5 allows 5 items through', async () => { + await createPromiseFromStreams([createListStream([1, 2, 3, 4, 5]), createLimitStream(5)]); + }); + + test('limit of 5 errors out when 6 items are through', async () => { + await expect( + createPromiseFromStreams([createListStream([1, 2, 3, 4, 5, 6]), createLimitStream(5)]) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"Can't import more than 5 objects"`); + }); + + test('send the values on the output stream', async () => { + const result = await createPromiseFromStreams([ + createListStream([1, 2, 3]), + createLimitStream(3), + createConcatStream([]), + ]); + + expect(result).toMatchInlineSnapshot(` +Array [ + 1, + 2, + 3, +] +`); + }); +}); + +describe('collectSavedObjects()', () => { + test('collects nothing when stream is empty', async () => { + const readStream = new Readable({ + read() { + this.push(null); + }, + }); + const objects = await collectSavedObjects(readStream, 10); + expect(objects).toMatchInlineSnapshot(`Array []`); + }); + + test('collects objects from stream', async () => { + const readStream = new Readable({ + read() { + this.push('{"foo":true}'); + this.push(null); + }, + }); + const objects = await collectSavedObjects(readStream, 1); + expect(objects).toMatchInlineSnapshot(` +Array [ + Object { + "foo": true, + }, +] +`); + }); + + test('filters out empty lines', async () => { + const readStream = new Readable({ + read() { + this.push('{"foo":true}\n\n'); + this.push(null); + }, + }); + const objects = await collectSavedObjects(readStream, 1); + expect(objects).toMatchInlineSnapshot(` +Array [ + Object { + "foo": true, + }, +] +`); + }); + + test('throws error when object limit is reached', async () => { + const readStream = new Readable({ + read() { + this.push('{"foo":true}\n'); + this.push('{"bar":true}\n'); + this.push(null); + }, + }); + await expect(collectSavedObjects(readStream, 1)).rejects.toThrowErrorMatchingInlineSnapshot( + `"Can't import more than 1 objects"` + ); + }); +}); + +describe('createObjectsFilter()', () => { + test('filters should return false when contains empty parameters', () => { + const fn = createObjectsFilter([], [], []); + expect(fn({ type: 'a', id: '1', attributes: {}, references: [] })).toEqual(false); + }); + + test('filters should exclude skips', () => { + const fn = createObjectsFilter( + [ + { + type: 'a', + id: '1', + }, + ], + [], + [ + { + type: 'b', + from: '1', + to: '2', + }, + ] + ); + expect( + fn({ + type: 'a', + id: '1', + attributes: {}, + references: [{ name: 'ref_0', type: 'b', id: '1' }], + }) + ).toEqual(false); + expect( + fn({ + type: 'a', + id: '2', + attributes: {}, + references: [{ name: 'ref_0', type: 'b', id: '1' }], + }) + ).toEqual(true); + }); + + test('filter should include references to replace', () => { + const fn = createObjectsFilter( + [], + [], + [ + { + type: 'b', + from: '1', + to: '2', + }, + ] + ); + expect( + fn({ + type: 'a', + id: '1', + attributes: {}, + references: [ + { + name: 'ref_0', + type: 'b', + id: '1', + }, + ], + }) + ).toEqual(true); + expect( + fn({ + type: 'a', + id: '1', + attributes: {}, + references: [ + { + name: 'ref_0', + type: 'b', + id: '2', + }, + ], + }) + ).toEqual(false); + }); + + test('filter should include objects to overwrite', () => { + const fn = createObjectsFilter( + [], + [ + { + type: 'a', + id: '1', + }, + ], + [] + ); + expect(fn({ type: 'a', id: '1', attributes: {}, references: [] })).toEqual(true); + expect(fn({ type: 'a', id: '2', attributes: {}, references: [] })).toEqual(false); + }); + + test('filter should work with skips, overwrites and replaceReferences', () => { + const fn = createObjectsFilter( + [ + { + type: 'a', + id: '1', + }, + ], + [ + { + type: 'a', + id: '2', + }, + ], + [ + { + type: 'b', + from: '1', + to: '2', + }, + ] + ); + expect( + fn({ + type: 'a', + id: '1', + attributes: {}, + references: [ + { + name: 'ref_0', + type: 'b', + id: '1', + }, + ], + }) + ).toEqual(false); + expect( + fn({ + type: 'a', + id: '2', + attributes: {}, + references: [ + { + name: 'ref_0', + type: 'b', + id: '2', + }, + ], + }) + ).toEqual(true); + expect( + fn({ + type: 'a', + id: '3', + attributes: {}, + references: [ + { + name: 'ref_0', + type: 'b', + id: '1', + }, + ], + }) + ).toEqual(true); + }); +}); + +describe('importSavedObjects()', () => { + const savedObjects: SavedObject[] = [ + { + id: '1', + type: 'index-pattern', + attributes: {}, + references: [], + }, + { + id: '2', + type: 'search', + attributes: {}, + references: [], + }, + { + id: '3', + type: 'visualization', + attributes: {}, + references: [], + }, + { + id: '4', + type: 'dashboard', + attributes: {}, + references: [], + }, + ]; + const savedObjectsClient = { + errors: {} as any, + bulkCreate: jest.fn(), + bulkGet: jest.fn(), + create: jest.fn(), + delete: jest.fn(), + find: jest.fn(), + get: jest.fn(), + update: jest.fn(), + }; + + beforeEach(() => { + savedObjectsClient.bulkCreate.mockReset(); + savedObjectsClient.bulkGet.mockReset(); + savedObjectsClient.create.mockReset(); + savedObjectsClient.delete.mockReset(); + savedObjectsClient.find.mockReset(); + savedObjectsClient.get.mockReset(); + savedObjectsClient.update.mockReset(); + }); + + test('calls bulkCreate without overwrite', async () => { + const readStream = new Readable({ + read() { + savedObjects.forEach(obj => this.push(JSON.stringify(obj) + '\n')); + this.push(null); + }, + }); + savedObjectsClient.bulkCreate.mockResolvedValue({ + saved_objects: savedObjects, + }); + const result = await importSavedObjects({ + readStream, + objectLimit: 4, + overwrite: false, + savedObjectsClient, + }); + expect(result).toMatchInlineSnapshot(` +Object { + "success": true, + "successCount": 4, +} +`); + expect(savedObjectsClient.bulkCreate).toMatchInlineSnapshot(` +[MockFunction] { + "calls": Array [ + Array [ + Array [ + Object { + "attributes": Object {}, + "id": "1", + "references": Array [], + "type": "index-pattern", + }, + Object { + "attributes": Object {}, + "id": "2", + "references": Array [], + "type": "search", + }, + Object { + "attributes": Object {}, + "id": "3", + "references": Array [], + "type": "visualization", + }, + Object { + "attributes": Object {}, + "id": "4", + "references": Array [], + "type": "dashboard", + }, + ], + Object { + "overwrite": false, + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], +} +`); + }); + + test('calls bulkCreate with overwrite', async () => { + const readStream = new Readable({ + read() { + savedObjects.forEach(obj => this.push(JSON.stringify(obj) + '\n')); + this.push(null); + }, + }); + savedObjectsClient.bulkCreate.mockResolvedValue({ + saved_objects: savedObjects, + }); + const result = await importSavedObjects({ + readStream, + objectLimit: 4, + overwrite: true, + savedObjectsClient, + }); + expect(result).toMatchInlineSnapshot(` +Object { + "success": true, + "successCount": 4, +} +`); + expect(savedObjectsClient.bulkCreate).toMatchInlineSnapshot(` +[MockFunction] { + "calls": Array [ + Array [ + Array [ + Object { + "attributes": Object {}, + "id": "1", + "references": Array [], + "type": "index-pattern", + }, + Object { + "attributes": Object {}, + "id": "2", + "references": Array [], + "type": "search", + }, + Object { + "attributes": Object {}, + "id": "3", + "references": Array [], + "type": "visualization", + }, + Object { + "attributes": Object {}, + "id": "4", + "references": Array [], + "type": "dashboard", + }, + ], + Object { + "overwrite": true, + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], +} +`); + }); + + test('extracts errors', async () => { + const readStream = new Readable({ + read() { + savedObjects.forEach(obj => this.push(JSON.stringify(obj) + '\n')); + this.push(null); + }, + }); + savedObjectsClient.bulkCreate.mockResolvedValue({ + saved_objects: savedObjects.map(savedObject => ({ + type: savedObject.type, + id: savedObject.id, + error: { + statusCode: 409, + message: 'conflict', + }, + })), + }); + const result = await importSavedObjects({ + readStream, + objectLimit: 4, + overwrite: false, + savedObjectsClient, + }); + expect(result).toMatchInlineSnapshot(` +Object { + "errors": Array [ + Object { + "error": Object { + "message": "conflict", + "statusCode": 409, + }, + "id": "1", + "type": "index-pattern", + }, + Object { + "error": Object { + "message": "conflict", + "statusCode": 409, + }, + "id": "2", + "type": "search", + }, + Object { + "error": Object { + "message": "conflict", + "statusCode": 409, + }, + "id": "3", + "type": "visualization", + }, + Object { + "error": Object { + "message": "conflict", + "statusCode": 409, + }, + "id": "4", + "type": "dashboard", + }, + ], + "success": false, + "successCount": 0, +} +`); + }); +}); + +describe('resolveImportConflicts()', () => { + const savedObjects: SavedObject[] = [ + { + id: '1', + type: 'index-pattern', + attributes: {}, + references: [], + }, + { + id: '2', + type: 'search', + attributes: {}, + references: [], + }, + { + id: '3', + type: 'visualization', + attributes: {}, + references: [], + }, + { + id: '4', + type: 'dashboard', + attributes: {}, + references: [ + { + name: 'panel_0', + type: 'visualization', + id: '3', + }, + ], + }, + ]; + const savedObjectsClient = { + errors: {} as any, + bulkCreate: jest.fn(), + bulkGet: jest.fn(), + create: jest.fn(), + delete: jest.fn(), + find: jest.fn(), + get: jest.fn(), + update: jest.fn(), + }; + + beforeEach(() => { + savedObjectsClient.bulkCreate.mockReset(); + savedObjectsClient.bulkGet.mockReset(); + savedObjectsClient.create.mockReset(); + savedObjectsClient.delete.mockReset(); + savedObjectsClient.find.mockReset(); + savedObjectsClient.get.mockReset(); + savedObjectsClient.update.mockReset(); + }); + + test('works with empty parameters', async () => { + const readStream = new Readable({ + read() { + savedObjects.forEach(obj => this.push(JSON.stringify(obj) + '\n')); + this.push(null); + }, + }); + savedObjectsClient.bulkCreate.mockResolvedValue({ + saved_objects: savedObjects, + }); + const result = await resolveImportConflicts({ + readStream, + objectLimit: 4, + skips: [], + overwrites: [], + savedObjectsClient, + replaceReferences: [], + }); + expect(result).toMatchInlineSnapshot(` +Object { + "success": true, + "successCount": 0, +} +`); + expect(savedObjectsClient.bulkCreate).toMatchInlineSnapshot(`[MockFunction]`); + }); + + test('works with skips', async () => { + const readStream = new Readable({ + read() { + savedObjects.forEach(obj => this.push(JSON.stringify(obj) + '\n')); + this.push(null); + }, + }); + savedObjectsClient.bulkCreate.mockResolvedValue({ + saved_objects: savedObjects, + }); + const result = await resolveImportConflicts({ + readStream, + objectLimit: 4, + skips: [ + { + type: 'dashboard', + id: '4', + }, + ], + overwrites: [], + savedObjectsClient, + replaceReferences: [ + { + type: 'visualization', + from: '3', + to: '30', + }, + ], + }); + expect(result).toMatchInlineSnapshot(` +Object { + "success": true, + "successCount": 0, +} +`); + expect(savedObjectsClient.bulkCreate).toMatchInlineSnapshot(`[MockFunction]`); + }); + + test('works with overwrites', async () => { + const readStream = new Readable({ + read() { + savedObjects.forEach(obj => this.push(JSON.stringify(obj) + '\n')); + this.push(null); + }, + }); + savedObjectsClient.bulkCreate.mockResolvedValue({ + saved_objects: savedObjects, + }); + const result = await resolveImportConflicts({ + readStream, + objectLimit: 4, + skips: [], + overwrites: [ + { + type: 'index-pattern', + id: '1', + }, + ], + savedObjectsClient, + replaceReferences: [], + }); + expect(result).toMatchInlineSnapshot(` +Object { + "success": true, + "successCount": 1, +} +`); + expect(savedObjectsClient.bulkCreate).toMatchInlineSnapshot(` +[MockFunction] { + "calls": Array [ + Array [ + Array [ + Object { + "attributes": Object {}, + "id": "1", + "references": Array [], + "type": "index-pattern", + }, + ], + Object { + "overwrite": true, + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], +} +`); + }); + + test('works wtih replaceReferences', async () => { + const readStream = new Readable({ + read() { + savedObjects.forEach(obj => this.push(JSON.stringify(obj) + '\n')); + this.push(null); + }, + }); + savedObjectsClient.bulkCreate.mockResolvedValue({ + saved_objects: savedObjects, + }); + const result = await resolveImportConflicts({ + readStream, + objectLimit: 4, + skips: [], + overwrites: [], + savedObjectsClient, + replaceReferences: [ + { + type: 'visualization', + from: '3', + to: '13', + }, + ], + }); + expect(result).toMatchInlineSnapshot(` +Object { + "success": true, + "successCount": 1, +} +`); + expect(savedObjectsClient.bulkCreate).toMatchInlineSnapshot(` +[MockFunction] { + "calls": Array [ + Array [ + Array [ + Object { + "attributes": Object {}, + "id": "4", + "references": Array [ + Object { + "id": "13", + "name": "panel_0", + "type": "visualization", + }, + ], + "type": "dashboard", + }, + ], + Object { + "overwrite": true, + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], +} +`); + }); +}); diff --git a/src/legacy/server/saved_objects/lib/import.ts b/src/legacy/server/saved_objects/lib/import.ts new file mode 100644 index 0000000000000..c185b0fc6a8c4 --- /dev/null +++ b/src/legacy/server/saved_objects/lib/import.ts @@ -0,0 +1,218 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import Boom from 'boom'; +import { Readable, Transform } from 'stream'; +import { + createConcatStream, + createFilterStream, + createMapStream, + createPromiseFromStreams, + createSplitStream, +} from '../../../utils/streams'; +import { SavedObject, SavedObjectsClient } from '../service'; + +interface CustomError { + id: string; + type: string; + error: { + message: string; + statusCode: number; + }; +} + +interface ImportResponse { + success: boolean; + successCount: number; + errors?: CustomError[]; +} + +interface ImportSavedObjectsOptions { + readStream: Readable; + objectLimit: number; + overwrite: boolean; + savedObjectsClient: SavedObjectsClient; +} + +interface ResolveImportConflictsOptions { + readStream: Readable; + objectLimit: number; + savedObjectsClient: SavedObjectsClient; + overwrites: Array<{ + type: string; + id: string; + }>; + replaceReferences: Array<{ + type: string; + from: string; + to: string; + }>; + skips: Array<{ + type: string; + id: string; + }>; +} + +export function extractErrors(savedObjects: SavedObject[]) { + const errors: CustomError[] = []; + for (const savedObject of savedObjects) { + if (savedObject.error) { + errors.push({ + id: savedObject.id, + type: savedObject.type, + error: savedObject.error, + }); + } + } + return errors; +} + +export function createLimitStream(limit: number) { + let counter = 0; + return new Transform({ + objectMode: true, + async transform(obj, enc, done) { + if (counter >= limit) { + return done(Boom.badRequest(`Can't import more than ${limit} objects`)); + } + counter++; + done(undefined, obj); + }, + }); +} + +export async function collectSavedObjects( + readStream: Readable, + objectLimit: number, + filter?: (obj: SavedObject) => boolean +): Promise { + return (await createPromiseFromStreams([ + readStream, + createSplitStream('\n'), + createMapStream((str: string) => { + if (str && str !== '') { + return JSON.parse(str); + } + }), + createFilterStream(obj => !!obj), + createLimitStream(objectLimit), + createFilterStream(obj => (filter ? filter(obj) : true)), + createConcatStream([]), + ])) as SavedObject[]; +} + +export function createObjectsFilter( + skips: Array<{ + type: string; + id: string; + }>, + overwrites: Array<{ + type: string; + id: string; + }>, + replaceReferences: Array<{ + type: string; + from: string; + to: string; + }> +) { + const refReplacements = replaceReferences.map(ref => `${ref.type}:${ref.from}`); + return (obj: SavedObject) => { + if (skips.some(skipObj => skipObj.type === obj.type && skipObj.id === obj.id)) { + return false; + } + if ( + overwrites.some(overwriteObj => overwriteObj.type === obj.type && overwriteObj.id === obj.id) + ) { + return true; + } + for (const reference of obj.references || []) { + if (refReplacements.includes(`${reference.type}:${reference.id}`)) { + return true; + } + } + return false; + }; +} + +export async function importSavedObjects({ + readStream, + objectLimit, + overwrite, + savedObjectsClient, +}: ImportSavedObjectsOptions): Promise { + const objectsToImport = await collectSavedObjects(readStream, objectLimit); + + if (objectsToImport.length === 0) { + return { + success: true, + successCount: 0, + }; + } + + const bulkCreateResult = await savedObjectsClient.bulkCreate(objectsToImport, { + overwrite, + }); + const errors = extractErrors(bulkCreateResult.saved_objects); + + return { + success: errors.length === 0, + successCount: objectsToImport.length - errors.length, + ...(errors.length ? { errors } : {}), + }; +} + +export async function resolveImportConflicts({ + readStream, + objectLimit, + skips, + overwrites, + savedObjectsClient, + replaceReferences, +}: ResolveImportConflictsOptions): Promise { + let errors: CustomError[] = []; + const filter = createObjectsFilter(skips, overwrites, replaceReferences); + const objectsToResolve = await collectSavedObjects(readStream, objectLimit, filter); + + // Replace references + const refReplacementsMap: Record = {}; + for (const { type, to, from } of replaceReferences) { + refReplacementsMap[`${type}:${from}`] = to; + } + for (const savedObject of objectsToResolve) { + for (const reference of savedObject.references || []) { + if (refReplacementsMap[`${reference.type}:${reference.id}`]) { + reference.id = refReplacementsMap[`${reference.type}:${reference.id}`]; + } + } + } + + if (objectsToResolve.length) { + const bulkCreateResult = await savedObjectsClient.bulkCreate(objectsToResolve, { + overwrite: true, + }); + errors = extractErrors(bulkCreateResult.saved_objects); + } + + return { + success: errors.length === 0, + successCount: objectsToResolve.length - errors.length, + ...(errors.length ? { errors } : {}), + }; +} diff --git a/src/legacy/server/saved_objects/routes/_mock_server.d.ts b/src/legacy/server/saved_objects/lib/index.ts similarity index 86% rename from src/legacy/server/saved_objects/routes/_mock_server.d.ts rename to src/legacy/server/saved_objects/lib/index.ts index a9fae1ba19283..c84b212a4ec0f 100644 --- a/src/legacy/server/saved_objects/routes/_mock_server.d.ts +++ b/src/legacy/server/saved_objects/lib/index.ts @@ -17,6 +17,5 @@ * under the License. */ -import Hapi from 'hapi'; - -export function MockServer(config?: { [key: string]: any }): Hapi.Server; +export { importSavedObjects, resolveImportConflicts } from './import'; +export { getSortedObjectsForExport } from './export'; diff --git a/src/legacy/server/saved_objects/routes/_mock_server.ts b/src/legacy/server/saved_objects/routes/_mock_server.ts index ffa04d86dfc9c..bf5579c99e5fa 100644 --- a/src/legacy/server/saved_objects/routes/_mock_server.ts +++ b/src/legacy/server/saved_objects/routes/_mock_server.ts @@ -23,6 +23,7 @@ import { defaultValidationErrorHandler } from '../../../../core/server/http/http const defaultConfig = { 'kibana.index': '.kibana', 'savedObjects.maxImportExportSize': 10000, + 'savedObjects.maxImportPayloadBytes': 52428800, }; export function createMockServer(config: { [key: string]: any } = defaultConfig) { diff --git a/src/legacy/server/saved_objects/routes/export.ts b/src/legacy/server/saved_objects/routes/export.ts index 95c2b6f51264c..2d96f699e56a6 100644 --- a/src/legacy/server/saved_objects/routes/export.ts +++ b/src/legacy/server/saved_objects/routes/export.ts @@ -21,7 +21,7 @@ import Hapi from 'hapi'; import Joi from 'joi'; import stringify from 'json-stable-stringify'; import { SavedObjectsClient } from '../'; -import { getSortedObjectsForExport } from '../lib/export'; +import { getSortedObjectsForExport } from '../lib'; import { Prerequisites } from './types'; const ALLOWED_TYPES = ['index-pattern', 'search', 'visualization', 'dashboard']; diff --git a/src/legacy/server/saved_objects/routes/import.test.ts b/src/legacy/server/saved_objects/routes/import.test.ts new file mode 100644 index 0000000000000..3e2b28e6232c7 --- /dev/null +++ b/src/legacy/server/saved_objects/routes/import.test.ts @@ -0,0 +1,188 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import Hapi from 'hapi'; +import { createMockServer } from './_mock_server'; +import { createImportRoute } from './import'; + +describe('POST /api/saved_objects/_import', () => { + let server: Hapi.Server; + const savedObjectsClient = { + errors: {} as any, + bulkCreate: jest.fn(), + bulkGet: jest.fn(), + create: jest.fn(), + delete: jest.fn(), + find: jest.fn(), + get: jest.fn(), + update: jest.fn(), + }; + + beforeEach(() => { + server = createMockServer(); + savedObjectsClient.bulkCreate.mockReset(); + savedObjectsClient.bulkGet.mockReset(); + savedObjectsClient.create.mockReset(); + savedObjectsClient.delete.mockReset(); + savedObjectsClient.find.mockReset(); + savedObjectsClient.get.mockReset(); + savedObjectsClient.update.mockReset(); + + const prereqs = { + getSavedObjectsClient: { + assign: 'savedObjectsClient', + method() { + return savedObjectsClient; + }, + }, + }; + + server.route(createImportRoute(prereqs, server)); + }); + + test('formats successful response', async () => { + const request = { + method: 'POST', + url: '/api/saved_objects/_import', + payload: [ + '--BOUNDARY', + 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', + 'Content-Type: application/ndjson', + '', + '', + '--BOUNDARY--', + ].join('\r\n'), + headers: { + 'content-Type': 'multipart/form-data; boundary=BOUNDARY', + }, + }; + const { payload, statusCode } = await server.inject(request); + const response = JSON.parse(payload); + expect(statusCode).toBe(200); + expect(response).toEqual({ + success: true, + successCount: 0, + }); + expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(0); + }); + + test('imports an index pattern and dashboard', async () => { + // NOTE: changes to this scenario should be reflected in the docs + const request = { + method: 'POST', + url: '/api/saved_objects/_import', + payload: [ + '--EXAMPLE', + 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', + 'Content-Type: application/ndjson', + '', + '{"type":"index-pattern","id":"my-pattern","attributes":{"title":"my-pattern-*"}}', + '{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"}}', + '--EXAMPLE--', + ].join('\r\n'), + headers: { + 'content-Type': 'multipart/form-data; boundary=EXAMPLE', + }, + }; + savedObjectsClient.bulkCreate.mockResolvedValueOnce({ + saved_objects: [ + { + type: 'index-pattern', + id: 'my-pattern', + attributes: { + title: 'my-pattern-*', + }, + }, + { + type: 'dashboard', + id: 'my-dashboard', + attributes: { + title: 'Look at my dashboard', + }, + }, + ], + }); + const { payload, statusCode } = await server.inject(request); + const response = JSON.parse(payload); + expect(statusCode).toBe(200); + expect(response).toEqual({ + success: true, + successCount: 2, + }); + }); + + test('imports an index pattern and dashboard but has a conflict on the index pattern', async () => { + // NOTE: changes to this scenario should be reflected in the docs + const request = { + method: 'POST', + url: '/api/saved_objects/_import', + payload: [ + '--EXAMPLE', + 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', + 'Content-Type: application/ndjson', + '', + '{"type":"index-pattern","id":"my-pattern","attributes":{"title":"my-pattern-*"}}', + '{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"}}', + '--EXAMPLE--', + ].join('\r\n'), + headers: { + 'content-Type': 'multipart/form-data; boundary=EXAMPLE', + }, + }; + savedObjectsClient.bulkCreate.mockResolvedValueOnce({ + saved_objects: [ + { + type: 'index-pattern', + id: 'my-pattern', + attributes: {}, + references: [], + error: { + statusCode: 409, + message: 'version conflict, document already exists', + }, + }, + { + type: 'dashboard', + id: 'my-dashboard', + attributes: { + title: 'Look at my dashboard', + }, + references: [], + }, + ], + }); + const { payload, statusCode } = await server.inject(request); + const response = JSON.parse(payload); + expect(statusCode).toBe(200); + expect(response).toEqual({ + success: false, + successCount: 1, + errors: [ + { + id: 'my-pattern', + type: 'index-pattern', + error: { + statusCode: 409, + message: 'version conflict, document already exists', + }, + }, + ], + }); + }); +}); diff --git a/src/legacy/server/saved_objects/routes/import.ts b/src/legacy/server/saved_objects/routes/import.ts new file mode 100644 index 0000000000000..47b61a7723865 --- /dev/null +++ b/src/legacy/server/saved_objects/routes/import.ts @@ -0,0 +1,82 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import Boom from 'boom'; +import Hapi from 'hapi'; +import Joi from 'joi'; +import { extname } from 'path'; +import { Readable } from 'stream'; +import { SavedObjectsClient } from '../'; +import { importSavedObjects } from '../lib'; +import { Prerequisites, WithoutQueryAndParams } from './types'; + +interface HapiReadableStream extends Readable { + hapi: { + filename: string; + }; +} + +interface ImportRequest extends WithoutQueryAndParams { + pre: { + savedObjectsClient: SavedObjectsClient; + }; + query: { + overwrite: boolean; + }; + payload: { + file: HapiReadableStream; + }; +} + +export const createImportRoute = (prereqs: Prerequisites, server: Hapi.Server) => ({ + path: '/api/saved_objects/_import', + method: 'POST', + config: { + pre: [prereqs.getSavedObjectsClient], + payload: { + maxBytes: server.config().get('savedObjects.maxImportPayloadBytes'), + output: 'stream', + allow: 'multipart/form-data', + }, + validate: { + query: Joi.object() + .keys({ + overwrite: Joi.boolean().default(false), + }) + .default(), + payload: Joi.object({ + file: Joi.object().required(), + }).default(), + }, + }, + async handler(request: ImportRequest, h: Hapi.ResponseToolkit) { + const { savedObjectsClient } = request.pre; + const { filename } = request.payload.file.hapi; + const fileExtension = extname(filename).toLowerCase(); + if (fileExtension !== '.ndjson') { + return Boom.badRequest(`Invalid file extension ${fileExtension}`); + } + return await importSavedObjects({ + savedObjectsClient, + readStream: request.payload.file, + objectLimit: request.server.config().get('savedObjects.maxImportExportSize'), + overwrite: request.query.overwrite, + }); + }, +}); diff --git a/src/legacy/server/saved_objects/routes/index.ts b/src/legacy/server/saved_objects/routes/index.ts index 64c6f270ffc8f..24abbc2abde0d 100644 --- a/src/legacy/server/saved_objects/routes/index.ts +++ b/src/legacy/server/saved_objects/routes/index.ts @@ -23,5 +23,7 @@ export { createCreateRoute } from './create'; export { createDeleteRoute } from './delete'; export { createFindRoute } from './find'; export { createGetRoute } from './get'; +export { createImportRoute } from './import'; +export { createResolveImportConflictsRoute } from './resolve_import_conflicts'; export { createUpdateRoute } from './update'; export { createExportRoute } from './export'; diff --git a/src/legacy/server/saved_objects/routes/resolve_import_conflicts.test.ts b/src/legacy/server/saved_objects/routes/resolve_import_conflicts.test.ts new file mode 100644 index 0000000000000..0441c07d6f66b --- /dev/null +++ b/src/legacy/server/saved_objects/routes/resolve_import_conflicts.test.ts @@ -0,0 +1,229 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import Hapi from 'hapi'; +import { createMockServer } from './_mock_server'; +import { createResolveImportConflictsRoute } from './resolve_import_conflicts'; + +describe('POST /api/saved_objects/_resolve_import_conflicts', () => { + let server: Hapi.Server; + const savedObjectsClient = { + errors: {} as any, + bulkCreate: jest.fn(), + bulkGet: jest.fn(), + create: jest.fn(), + delete: jest.fn(), + find: jest.fn(), + get: jest.fn(), + update: jest.fn(), + }; + + beforeEach(() => { + server = createMockServer(); + savedObjectsClient.bulkCreate.mockReset(); + savedObjectsClient.bulkGet.mockReset(); + savedObjectsClient.create.mockReset(); + savedObjectsClient.delete.mockReset(); + savedObjectsClient.find.mockReset(); + savedObjectsClient.get.mockReset(); + savedObjectsClient.update.mockReset(); + + const prereqs = { + getSavedObjectsClient: { + assign: 'savedObjectsClient', + method() { + return savedObjectsClient; + }, + }, + }; + + server.route(createResolveImportConflictsRoute(prereqs, server)); + }); + + test('formats successful response', async () => { + const request = { + method: 'POST', + url: '/api/saved_objects/_resolve_import_conflicts', + payload: [ + '--BOUNDARY', + 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', + 'Content-Type: application/ndjson', + '', + '', + '--BOUNDARY--', + ].join('\r\n'), + headers: { + 'content-Type': 'multipart/form-data; boundary=BOUNDARY', + }, + }; + const { payload, statusCode } = await server.inject(request); + const response = JSON.parse(payload); + expect(statusCode).toBe(200); + expect(response).toEqual({ success: true, successCount: 0 }); + expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(0); + }); + + test('resolves conflicts for an index pattern and dashboard but skips the index pattern', async () => { + // NOTE: changes to this scenario should be reflected in the docs + const request = { + method: 'POST', + url: '/api/saved_objects/_resolve_import_conflicts', + payload: [ + '--EXAMPLE', + 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', + 'Content-Type: application/ndjson', + '', + '{"type":"index-pattern","id":"my-pattern","attributes":{"title":"my-pattern-*"}}', + '{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"}}', + '--EXAMPLE', + 'Content-Disposition: form-data; name="skips"', + '', + '[{"type":"index-pattern","id":"my-pattern"}]', + '--EXAMPLE', + 'Content-Disposition: form-data; name="overwrites"', + '', + '[{"type":"dashboard","id":"my-dashboard"}]', + '--EXAMPLE--', + ].join('\r\n'), + headers: { + 'content-Type': 'multipart/form-data; boundary=EXAMPLE', + }, + }; + savedObjectsClient.bulkCreate.mockResolvedValueOnce({ + saved_objects: [ + { + type: 'dashboard', + id: 'my-dashboard', + attributes: { + title: 'Look at my dashboard', + }, + }, + ], + }); + const { payload, statusCode } = await server.inject(request); + const response = JSON.parse(payload); + expect(statusCode).toBe(200); + expect(response).toEqual({ success: true, successCount: 1 }); + expect(savedObjectsClient.bulkCreate).toMatchInlineSnapshot(` +[MockFunction] { + "calls": Array [ + Array [ + Array [ + Object { + "attributes": Object { + "title": "Look at my dashboard", + }, + "id": "my-dashboard", + "type": "dashboard", + }, + ], + Object { + "overwrite": true, + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], +} +`); + }); + + test('resolves conflicts by replacing the visualization references', async () => { + // NOTE: changes to this scenario should be reflected in the docs + const request = { + method: 'POST', + url: '/api/saved_objects/_resolve_import_conflicts', + payload: [ + '--EXAMPLE', + 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', + 'Content-Type: application/ndjson', + '', + '{"type":"visualization","id":"my-vis","attributes":{"title":"Look at my visualization"}}', + '{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"},"references":[{"name":"panel_0","type":"visualization","id":"my-vis"}]}', + '--EXAMPLE', + 'Content-Disposition: form-data; name="replaceReferences"', + '', + '[{"type":"visualization","from":"my-vis","to":"my-vis-2"}]', + '--EXAMPLE--', + ].join('\r\n'), + headers: { + 'content-Type': 'multipart/form-data; boundary=EXAMPLE', + }, + }; + savedObjectsClient.bulkCreate.mockResolvedValueOnce({ + saved_objects: [ + { + type: 'dashboard', + id: 'my-dashboard', + attributes: { + title: 'Look at my dashboard', + }, + references: [ + { + name: 'panel_0', + type: 'visualization', + id: 'my-vis-2', + }, + ], + }, + ], + }); + const { payload, statusCode } = await server.inject(request); + const response = JSON.parse(payload); + expect(statusCode).toBe(200); + expect(response).toEqual({ success: true, successCount: 1 }); + expect(savedObjectsClient.bulkCreate).toMatchInlineSnapshot(` +[MockFunction] { + "calls": Array [ + Array [ + Array [ + Object { + "attributes": Object { + "title": "Look at my dashboard", + }, + "id": "my-dashboard", + "references": Array [ + Object { + "id": "my-vis-2", + "name": "panel_0", + "type": "visualization", + }, + ], + "type": "dashboard", + }, + ], + Object { + "overwrite": true, + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], +} +`); + }); +}); diff --git a/src/legacy/server/saved_objects/routes/resolve_import_conflicts.ts b/src/legacy/server/saved_objects/routes/resolve_import_conflicts.ts new file mode 100644 index 0000000000000..cbb758babd061 --- /dev/null +++ b/src/legacy/server/saved_objects/routes/resolve_import_conflicts.ts @@ -0,0 +1,114 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import Boom from 'boom'; +import Hapi from 'hapi'; +import Joi from 'joi'; +import { extname } from 'path'; +import { Readable } from 'stream'; +import { SavedObjectsClient } from '../'; +import { resolveImportConflicts } from '../lib'; +import { Prerequisites } from './types'; + +interface HapiReadableStream extends Readable { + hapi: { + filename: string; + }; +} + +interface ImportRequest extends Hapi.Request { + pre: { + savedObjectsClient: SavedObjectsClient; + }; + payload: { + file: HapiReadableStream; + overwrites: Array<{ + type: string; + id: string; + }>; + replaceReferences: Array<{ + type: string; + from: string; + to: string; + }>; + skips: Array<{ + type: string; + id: string; + }>; + }; +} + +export const createResolveImportConflictsRoute = (prereqs: Prerequisites, server: Hapi.Server) => ({ + path: '/api/saved_objects/_resolve_import_conflicts', + method: 'POST', + config: { + pre: [prereqs.getSavedObjectsClient], + payload: { + maxBytes: server.config().get('savedObjects.maxImportPayloadBytes'), + output: 'stream', + allow: 'multipart/form-data', + }, + validate: { + payload: Joi.object({ + file: Joi.object().required(), + overwrites: Joi.array() + .items( + Joi.object({ + type: Joi.string().required(), + id: Joi.string().required(), + }) + ) + .default([]), + replaceReferences: Joi.array() + .items( + Joi.object({ + type: Joi.string().required(), + from: Joi.string().required(), + to: Joi.string().required(), + }) + ) + .default([]), + skips: Joi.array() + .items( + Joi.object({ + type: Joi.string().required(), + id: Joi.string().required(), + }) + ) + .default([]), + }).default(), + }, + }, + async handler(request: ImportRequest) { + const { savedObjectsClient } = request.pre; + const { filename } = request.payload.file.hapi; + const fileExtension = extname(filename).toLowerCase(); + if (fileExtension !== '.ndjson') { + return Boom.badRequest(`Invalid file extension ${fileExtension}`); + } + return await resolveImportConflicts({ + savedObjectsClient, + readStream: request.payload.file, + objectLimit: request.server.config().get('savedObjects.maxImportExportSize'), + skips: request.payload.skips, + overwrites: request.payload.overwrites, + replaceReferences: request.payload.replaceReferences, + }); + }, +}); diff --git a/src/legacy/server/saved_objects/saved_objects_mixin.js b/src/legacy/server/saved_objects/saved_objects_mixin.js index 37bc560fbad99..2d97abb44da52 100644 --- a/src/legacy/server/saved_objects/saved_objects_mixin.js +++ b/src/legacy/server/saved_objects/saved_objects_mixin.js @@ -32,6 +32,8 @@ import { createGetRoute, createUpdateRoute, createExportRoute, + createImportRoute, + createResolveImportConflictsRoute, } from './routes'; export function savedObjectsMixin(kbnServer, server) { @@ -63,6 +65,8 @@ export function savedObjectsMixin(kbnServer, server) { server.route(createGetRoute(prereqs)); server.route(createUpdateRoute(prereqs)); server.route(createExportRoute(prereqs, server)); + server.route(createImportRoute(prereqs, server)); + server.route(createResolveImportConflictsRoute(prereqs, server)); const schema = new SavedObjectsSchema(kbnServer.uiExports.savedObjectSchemas); const serializer = new SavedObjectsSerializer(schema); diff --git a/src/legacy/server/saved_objects/saved_objects_mixin.test.js b/src/legacy/server/saved_objects/saved_objects_mixin.test.js index f878880bbe30b..35a1733276b2b 100644 --- a/src/legacy/server/saved_objects/saved_objects_mixin.test.js +++ b/src/legacy/server/saved_objects/saved_objects_mixin.test.js @@ -102,9 +102,9 @@ describe('Saved Objects Mixin', () => { }); describe('Routes', () => { - it('should create 8 routes', () => { + it('should create 10 routes', () => { savedObjectsMixin(mockKbnServer, mockServer); - expect(mockServer.route).toHaveBeenCalledTimes(8); + expect(mockServer.route).toHaveBeenCalledTimes(10); }); it('should add POST /api/saved_objects/_bulk_create', () => { savedObjectsMixin(mockKbnServer, mockServer); @@ -138,6 +138,15 @@ describe('Saved Objects Mixin', () => { savedObjectsMixin(mockKbnServer, mockServer); expect(mockServer.route).toHaveBeenCalledWith(expect.objectContaining({ path: '/api/saved_objects/_export', method: 'POST' })); }); + it('should add POST /api/saved_objects/_import', () => { + savedObjectsMixin(mockKbnServer, mockServer); + expect(mockServer.route).toHaveBeenCalledWith(expect.objectContaining({ path: '/api/saved_objects/_import', method: 'POST' })); + }); + it('should add POST /api/saved_objects/_resolve_import_conflicts', () => { + savedObjectsMixin(mockKbnServer, mockServer); + expect(mockServer.route) + .toHaveBeenCalledWith(expect.objectContaining({ path: '/api/saved_objects/_resolve_import_conflicts', method: 'POST' })); + }); }); describe('Saved object service', () => { diff --git a/src/legacy/server/saved_objects/service/saved_objects_client.d.ts b/src/legacy/server/saved_objects/service/saved_objects_client.d.ts index be807c9d17df7..2b9db99dc7275 100644 --- a/src/legacy/server/saved_objects/service/saved_objects_client.d.ts +++ b/src/legacy/server/saved_objects/service/saved_objects_client.d.ts @@ -26,6 +26,7 @@ export interface BaseOptions { export interface CreateOptions extends BaseOptions { id?: string; overwrite?: boolean; + migrationVersion?: MigrationVersion; references?: SavedObjectReference[]; } @@ -89,6 +90,7 @@ export interface SavedObject { updated_at?: string; error?: { message: string; + statusCode: number; }; attributes: T; references: SavedObjectReference[]; diff --git a/src/legacy/server/saved_objects/service/saved_objects_client.js b/src/legacy/server/saved_objects/service/saved_objects_client.js index a18881ad09d41..d4dae10a2e611 100644 --- a/src/legacy/server/saved_objects/service/saved_objects_client.js +++ b/src/legacy/server/saved_objects/service/saved_objects_client.js @@ -102,7 +102,9 @@ export class SavedObjectsClient { * @param {object} [options={}] * @property {string} [options.id] - force id on creation, not recommended * @property {boolean} [options.overwrite=false] + * @property {object} [options.migrationVersion=undefined] * @property {string} [options.namespace] + * @property {array} [options.references] - [{ name, type, id }] * @returns {promise} - { id, type, version, attributes } */ async create(type, attributes = {}, options = {}) { diff --git a/src/legacy/utils/streams/filter_stream.test.ts b/src/legacy/utils/streams/filter_stream.test.ts new file mode 100644 index 0000000000000..f5140b7639c74 --- /dev/null +++ b/src/legacy/utils/streams/filter_stream.test.ts @@ -0,0 +1,77 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + createConcatStream, + createFilterStream, + createListStream, + createPromiseFromStreams, +} from './'; + +describe('createFilterStream()', () => { + test('calls the function with each item in the source stream', async () => { + const filter = jest.fn().mockReturnValue(true); + + await createPromiseFromStreams([createListStream(['a', 'b', 'c']), createFilterStream(filter)]); + + expect(filter).toMatchInlineSnapshot(` +[MockFunction] { + "calls": Array [ + Array [ + "a", + ], + Array [ + "b", + ], + Array [ + "c", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": true, + }, + Object { + "type": "return", + "value": true, + }, + Object { + "type": "return", + "value": true, + }, + ], +} +`); + }); + + test('send the filtered values on the output stream', async () => { + const result = await createPromiseFromStreams([ + createListStream([1, 2, 3]), + createFilterStream(n => n % 2 === 0), + createConcatStream([]), + ]); + + expect(result).toMatchInlineSnapshot(` +Array [ + 2, +] +`); + }); +}); diff --git a/src/legacy/utils/streams/filter_stream.ts b/src/legacy/utils/streams/filter_stream.ts new file mode 100644 index 0000000000000..b7023228547d1 --- /dev/null +++ b/src/legacy/utils/streams/filter_stream.ts @@ -0,0 +1,33 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Transform } from 'stream'; + +export function createFilterStream(fn: (obj: T) => boolean) { + return new Transform({ + objectMode: true, + async transform(obj, enc, done) { + const canPushDownStream = fn(obj); + if (canPushDownStream) { + this.push(obj); + } + done(); + }, + }); +} diff --git a/src/legacy/utils/streams/index.d.ts b/src/legacy/utils/streams/index.d.ts new file mode 100644 index 0000000000000..c306000306c32 --- /dev/null +++ b/src/legacy/utils/streams/index.d.ts @@ -0,0 +1,31 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Readable, Transform, Writable, TransformOptions } from 'stream'; + +export function concatStreamProviders(sourceProviders: Readable[], options: TransformOptions): Transform; +export function createIntersperseStream(intersperseChunk: string | Buffer): Transform; +export function createSplitStream(splitChunk: T): Transform; +export function createListStream(items: any[]): Readable; +export function createReduceStream(reducer: (value: any, chunk: T, enc: string) => T): Transform; +export function createPromiseFromStreams([first, ...rest]: [Readable, ...Writable[]]): Promise; +export function createConcatStream(initial: any): Transform; +export function createMapStream(fn: (value: T, i: number) => void): Transform; +export function createReplaceStream(toReplace: string, replacement: string | Buffer): Transform; +export function createFilterStream(fn: (obj: T) => boolean): Transform; diff --git a/src/legacy/utils/streams/index.js b/src/legacy/utils/streams/index.js index 68387f9aa61fa..447d1ed5b1c53 100644 --- a/src/legacy/utils/streams/index.js +++ b/src/legacy/utils/streams/index.js @@ -26,3 +26,4 @@ export { createPromiseFromStreams } from './promise_from_streams'; export { createConcatStream } from './concat_stream'; export { createMapStream } from './map_stream'; export { createReplaceStream } from './replace_stream'; +export { createFilterStream } from './filter_stream'; diff --git a/test/api_integration/apis/saved_objects/import.js b/test/api_integration/apis/saved_objects/import.js new file mode 100644 index 0000000000000..dc62c4c6bd93f --- /dev/null +++ b/test/api_integration/apis/saved_objects/import.js @@ -0,0 +1,135 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import expect from 'expect.js'; +import { join } from 'path'; + +export default function ({ getService }) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('import', () => { + describe('with kibana index', () => { + describe('with basic data existing', () => { + before(() => esArchiver.load('saved_objects/basic')); + after(() => esArchiver.unload('saved_objects/basic')); + + it('should return 200', async () => { + await supertest + .post('/api/saved_objects/_import') + .query({ overwrite: true }) + .attach('file', join(__dirname, '../../fixtures/import.ndjson')) + .expect(200) + .then((resp) => { + expect(resp.body).to.eql({ + success: true, + successCount: 3, + }); + }); + }); + + it('should return 415 when no file passed in', async () => { + await supertest + .post('/api/saved_objects/_import') + .expect(415) + .then((resp) => { + expect(resp.body).to.eql({ + statusCode: 415, + error: 'Unsupported Media Type', + message: 'Unsupported Media Type', + }); + }); + }); + + it('should return 409 when conflicts exist', async () => { + await supertest + .post('/api/saved_objects/_import') + .attach('file', join(__dirname, '../../fixtures/import.ndjson')) + .expect(200) + .then((resp) => { + expect(resp.body).to.eql({ + success: false, + successCount: 0, + errors: [ + { + id: '91200a00-9efd-11e7-acb3-3dab96693fab', + type: 'index-pattern', + error: { + statusCode: 409, + message: 'version conflict, document already exists', + } + }, + { + id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', + type: 'visualization', + error: { + statusCode: 409, + message: 'version conflict, document already exists', + } + }, + { + id: 'be3733a0-9efe-11e7-acb3-3dab96693fab', + type: 'dashboard', + error: { + statusCode: 409, + message: 'version conflict, document already exists', + } + }, + ], + }); + }); + }); + + it('should return 200 when conflicts exist but overwrite is passed in', async () => { + await supertest + .post('/api/saved_objects/_import') + .query({ + overwrite: true, + }) + .attach('file', join(__dirname, '../../fixtures/import.ndjson')) + .expect(200) + .then((resp) => { + expect(resp.body).to.eql({ + success: true, + successCount: 3, + }); + }); + }); + + it('should return 400 when trying to import more than 10,000 objects', async () => { + const fileChunks = []; + for (let i = 0; i < 10001; i++) { + fileChunks.push(`{"type":"visualization","id":"${i}","attributes":{},"references":[]}`); + } + await supertest + .post('/api/saved_objects/_import') + .attach('file', Buffer.from(fileChunks.join('\n'), 'utf8'), 'export.ndjson') + .expect(400) + .then((resp) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: 'Can\'t import more than 10000 objects', + }); + }); + }); + }); + }); + }); +} diff --git a/test/api_integration/apis/saved_objects/index.js b/test/api_integration/apis/saved_objects/index.js index 85726a3771ad6..fa0c239a836b5 100644 --- a/test/api_integration/apis/saved_objects/index.js +++ b/test/api_integration/apis/saved_objects/index.js @@ -26,6 +26,8 @@ export default function ({ loadTestFile }) { loadTestFile(require.resolve('./export')); loadTestFile(require.resolve('./find')); loadTestFile(require.resolve('./get')); + loadTestFile(require.resolve('./import')); + loadTestFile(require.resolve('./resolve_import_conflicts')); loadTestFile(require.resolve('./update')); loadTestFile(require.resolve('./migrations')); }); diff --git a/test/api_integration/apis/saved_objects/resolve_import_conflicts.js b/test/api_integration/apis/saved_objects/resolve_import_conflicts.js new file mode 100644 index 0000000000000..95db7f44b9c2d --- /dev/null +++ b/test/api_integration/apis/saved_objects/resolve_import_conflicts.js @@ -0,0 +1,239 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import expect from 'expect.js'; +import { join } from 'path'; + +export default function ({ getService }) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('resolve_import_conflicts', () => { + describe('without kibana index', () => { + // Cleanup data that got created in import + after(() => esArchiver.unload('saved_objects/basic')); + + it('should return 200 and import nothing when empty parameters are passed in', async () => { + await supertest + .post('/api/saved_objects/_resolve_import_conflicts') + .attach('file', join(__dirname, '../../fixtures/import.ndjson')) + .expect(200) + .then((resp) => { + expect(resp.body).to.eql({ + success: true, + successCount: 0, + }); + }); + }); + + it('should return 200 and import everything when overwrite parameters contains all objects', async () => { + await supertest + .post('/api/saved_objects/_resolve_import_conflicts') + .field('overwrites', JSON.stringify([ + { + type: 'index-pattern', + id: '91200a00-9efd-11e7-acb3-3dab96693fab', + }, + { + type: 'visualization', + id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', + }, + { + type: 'dashboard', + id: 'be3733a0-9efe-11e7-acb3-3dab96693fab', + }, + ])) + .attach('file', join(__dirname, '../../fixtures/import.ndjson')) + .expect(200) + .then((resp) => { + expect(resp.body).to.eql({ + success: true, + successCount: 3, + }); + }); + }); + + it('should return 400 when no file passed in', async () => { + await supertest + .post('/api/saved_objects/_resolve_import_conflicts') + .field('skips', '[]') + .expect(400) + .then((resp) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: 'child "file" fails because ["file" is required]', + validation: { source: 'payload', keys: [ 'file' ] } + }); + }); + }); + + it('should return 200 when replacing references', async () => { + const objToInsert = { + id: '1', + type: 'visualization', + attributes: { + title: 'My favorite vis', + }, + references: [ + { + name: 'ref_0', + type: 'search', + id: '1', + }, + ] + }; + await supertest + .post('/api/saved_objects/_resolve_import_conflicts') + .field('replaceReferences', JSON.stringify( + [ + { + type: 'search', + from: '1', + to: '2', + } + ] + )) + .attach('file', Buffer.from(JSON.stringify(objToInsert), 'utf8'), 'export.ndjson') + .expect(200) + .then((resp) => { + expect(resp.body).to.eql({ + success: true, + successCount: 1, + }); + }); + await supertest + .get('/api/saved_objects/visualization/1') + .expect(200) + .then((resp) => { + expect(resp.body.references).to.eql([ + { + name: 'ref_0', + type: 'search', + id: '2', + }, + ]); + }); + }); + + it('should return 400 when resolving conflicts with a file containing more than 10,000 objects', async () => { + const fileChunks = []; + for (let i = 0; i < 10001; i++) { + fileChunks.push(`{"type":"visualization","id":"${i}","attributes":{},"references":[]}`); + } + await supertest + .post('/api/saved_objects/_resolve_import_conflicts') + .attach('file', Buffer.from(fileChunks.join('\n'), 'utf8'), 'export.ndjson') + .expect(400) + .then((resp) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: 'Can\'t import more than 10000 objects', + }); + }); + }); + }); + + describe('with kibana index', () => { + describe('with basic data existing', () => { + before(() => esArchiver.load('saved_objects/basic')); + after(() => esArchiver.unload('saved_objects/basic')); + + it('should return 200 when skipping all the records', async () => { + await supertest + .post('/api/saved_objects/_resolve_import_conflicts') + .field('skips', JSON.stringify( + [ + { + id: '91200a00-9efd-11e7-acb3-3dab96693fab', + type: 'index-pattern', + }, + { + id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', + type: 'visualization', + }, + { + id: 'be3733a0-9efe-11e7-acb3-3dab96693fab', + type: 'dashboard', + }, + ] + )) + .attach('file', join(__dirname, '../../fixtures/import.ndjson')) + .expect(200) + .then((resp) => { + expect(resp.body).to.eql({ success: true, successCount: 0 }); + }); + }); + + it('should return 200 when manually overwriting each object', async () => { + await supertest + .post('/api/saved_objects/_resolve_import_conflicts') + .field('overwrites', JSON.stringify( + [ + { + id: '91200a00-9efd-11e7-acb3-3dab96693fab', + type: 'index-pattern', + }, + { + id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', + type: 'visualization', + }, + { + id: 'be3733a0-9efe-11e7-acb3-3dab96693fab', + type: 'dashboard', + }, + ] + )) + .attach('file', join(__dirname, '../../fixtures/import.ndjson')) + .expect(200) + .then((resp) => { + expect(resp.body).to.eql({ success: true, successCount: 3 }); + }); + }); + + it('should return 200 with only one record when overwriting 1 and skipping 1', async () => { + await supertest + .post('/api/saved_objects/_resolve_import_conflicts') + .field('overwrites', JSON.stringify( + [ + { + id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', + type: 'visualization', + }, + ] + )) + .field('skips', JSON.stringify( + [ + { + id: '91200a00-9efd-11e7-acb3-3dab96693fab', + type: 'index-pattern', + }, + ] + )) + .attach('file', join(__dirname, '../../fixtures/import.ndjson')) + .expect(200) + .then((resp) => { + expect(resp.body).to.eql({ success: true, successCount: 1 }); + }); + }); + }); + }); + }); +} diff --git a/test/api_integration/fixtures/import.ndjson b/test/api_integration/fixtures/import.ndjson new file mode 100644 index 0000000000000..8c902bbf8a2f4 --- /dev/null +++ b/test/api_integration/fixtures/import.ndjson @@ -0,0 +1,3 @@ +{"attributes":{"fields":"[{\"name\":\"@message\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"@message.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"@tags\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"@tags.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"@timestamp\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"_id\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_score\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_source\",\"type\":\"_source\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"agent\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"agent.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"bytes\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"clientip\",\"type\":\"ip\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"extension\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"extension.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.coordinates\",\"type\":\"geo_point\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.dest\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.src\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.srcdest\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"headings\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"headings.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"host.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"id\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"index.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"ip\",\"type\":\"ip\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"links\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"links.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"machine.os\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"machine.os.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"machine.ram\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"memory\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"meta.char\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"meta.related\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"meta.user.firstname\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"meta.user.lastname\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"phpmemory\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"referer\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:modified_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:published_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:section\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.article:section.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:tag\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.article:tag.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:description\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:description.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:image\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:image:height\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image:height.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:image:width\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image:width.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:site_name\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:site_name.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:title\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:title.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:type.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:card\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:card.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:description\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:description.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:image\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:image.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:site\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:site.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:title\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:title.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"request\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"request.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"response\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"response.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"spaces\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"spaces.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"utc_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"xss\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"xss.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true}]","timeFieldName":"@timestamp","title":"logstash-*"},"id":"91200a00-9efd-11e7-acb3-3dab96693fab","references":[],"type":"index-pattern","updated_at":"2017-09-21T18:49:16.270Z","version":"WzAsMV0="} +{"attributes":{"description":"","kibanaSavedObjectMeta":{"searchSourceJSON":"{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}"},"title":"Count of requests","uiStateJSON":"{\"spy\":{\"mode\":{\"name\":null,\"fill\":false}}}","version":1,"visState":"{\"title\":\"Count of requests\",\"type\":\"area\",\"params\":{\"type\":\"area\",\"grid\":{\"categoryLines\":false,\"style\":{\"color\":\"#eee\"}},\"categoryAxes\":[{\"id\":\"CategoryAxis-1\",\"type\":\"category\",\"position\":\"bottom\",\"show\":true,\"style\":{},\"scale\":{\"type\":\"linear\"},\"labels\":{\"show\":true,\"truncate\":100},\"title\":{}}],\"valueAxes\":[{\"id\":\"ValueAxis-1\",\"name\":\"LeftAxis-1\",\"type\":\"value\",\"position\":\"left\",\"show\":true,\"style\":{},\"scale\":{\"type\":\"linear\",\"mode\":\"normal\"},\"labels\":{\"show\":true,\"rotate\":0,\"filter\":false,\"truncate\":100},\"title\":{\"text\":\"Count\"}}],\"seriesParams\":[{\"show\":\"true\",\"type\":\"area\",\"mode\":\"stacked\",\"data\":{\"label\":\"Count\",\"id\":\"1\"},\"drawLinesBetweenPoints\":true,\"showCircles\":true,\"interpolate\":\"linear\",\"valueAxis\":\"ValueAxis-1\"}],\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"times\":[],\"addTimeMarker\":false},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"date_histogram\",\"schema\":\"segment\",\"params\":{\"field\":\"@timestamp\",\"interval\":\"auto\",\"customInterval\":\"2h\",\"min_doc_count\":1,\"extended_bounds\":{}}}]}"},"id":"dd7caf20-9efd-11e7-acb3-3dab96693fab","migrationVersion":{"visualization":"7.0.0"},"references":[{"id":"91200a00-9efd-11e7-acb3-3dab96693fab","name":"kibanaSavedObjectMeta.searchSourceJSON.index","type":"index-pattern"}],"type":"visualization","updated_at":"2017-09-21T18:51:23.794Z","version":"WzIsMV0="} +{"attributes":{"description":"","hits":0,"kibanaSavedObjectMeta":{"searchSourceJSON":"{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"highlightAll\":true,\"version\":true}"},"optionsJSON":"{\"darkTheme\":false}","panelsJSON":"[{\"size_x\":6,\"size_y\":3,\"panelIndex\":1,\"col\":1,\"row\":1,\"panelRefName\":\"panel_0\"}]","refreshInterval":{"display":"Off","pause":false,"value":0},"timeFrom":"Wed Sep 16 2015 22:52:17 GMT-0700","timeRestore":true,"timeTo":"Fri Sep 18 2015 12:24:38 GMT-0700","title":"Requests","uiStateJSON":"{}","version":1},"id":"be3733a0-9efe-11e7-acb3-3dab96693fab","migrationVersion":{"dashboard":"7.0.0"},"references":[{"id":"dd7caf20-9efd-11e7-acb3-3dab96693fab","name":"panel_0","type":"visualization"}],"type":"dashboard","updated_at":"2017-09-21T18:57:40.826Z","version":"WzMsMV0="} diff --git a/x-pack/test/saved_object_api_integration/common/suites/import.ts b/x-pack/test/saved_object_api_integration/common/suites/import.ts new file mode 100644 index 0000000000000..82e1ce379325d --- /dev/null +++ b/x-pack/test/saved_object_api_integration/common/suites/import.ts @@ -0,0 +1,152 @@ +/* + * 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 'expect.js'; +import { SuperTest } from 'supertest'; +import { DEFAULT_SPACE_ID } from '../../../../plugins/spaces/common/constants'; +import { getIdPrefix, getUrlPrefix } from '../lib/space_test_utils'; +import { DescribeFn, TestDefinitionAuthentication } from '../lib/types'; + +interface ImportTest { + statusCode: number; + response: (resp: { [key: string]: any }) => void; +} + +interface ImportTests { + default: ImportTest; + unknownType: ImportTest; +} + +interface ImportTestDefinition { + user?: TestDefinitionAuthentication; + spaceId?: string; + tests: ImportTests; +} + +const createImportData = (spaceId: string) => [ + { + type: 'dashboard', + id: `${getIdPrefix(spaceId)}a01b2f57-fcfd-4864-b735-09e28f0d815e`, + attributes: { + title: 'A great new dashboard', + }, + }, + { + type: 'globaltype', + id: '05976c65-1145-4858-bbf0-d225cc78a06e', + attributes: { + name: 'A new globaltype object', + }, + }, +]; + +export function importTestSuiteFactory(es: any, esArchiver: any, supertest: SuperTest) { + const createExpectResults = (spaceId = DEFAULT_SPACE_ID) => async (resp: { + [key: string]: any; + }) => { + expect(resp.body).to.eql({ + success: true, + successCount: 2, + }); + }; + + const expectUnknownType = (resp: { [key: string]: any }) => { + expect(resp.body).to.eql({ + success: false, + successCount: 2, + errors: [ + { + id: '1', + type: 'wigwags', + error: { + message: `Unsupported saved object type: 'wigwags': Bad Request`, + statusCode: 400, + error: 'Bad Request', + }, + }, + ], + }); + }; + + const expectRbacForbidden = (resp: { [key: string]: any }) => { + expect(resp.body).to.eql({ + statusCode: 403, + error: 'Forbidden', + message: `Unable to bulk_create dashboard,globaltype, missing action:saved_objects/dashboard/bulk_create,action:saved_objects/globaltype/bulk_create`, + }); + }; + + const expectRbacForbiddenWithUnknownType = (resp: { [key: string]: any }) => { + expect(resp.body).to.eql({ + statusCode: 403, + error: 'Forbidden', + message: `Unable to bulk_create dashboard,globaltype,wigwags, missing action:saved_objects/dashboard/bulk_create,action:saved_objects/globaltype/bulk_create,action:saved_objects/wigwags/bulk_create`, + }); + }; + + const expectRbacForbiddenForUnknownType = (resp: { [key: string]: any }) => { + expect(resp.body).to.eql({ + statusCode: 403, + error: 'Forbidden', + message: `Unable to bulk_create dashboard,globaltype,wigwags, missing action:saved_objects/wigwags/bulk_create`, + }); + }; + + const makeImportTest = (describeFn: DescribeFn) => ( + description: string, + definition: ImportTestDefinition + ) => { + const { user = {}, spaceId = DEFAULT_SPACE_ID, tests } = definition; + + describeFn(description, () => { + before(() => esArchiver.load('saved_objects/spaces')); + after(() => esArchiver.unload('saved_objects/spaces')); + + it(`should return ${tests.default.statusCode}`, async () => { + const data = createImportData(spaceId); + await supertest + .post(`${getUrlPrefix(spaceId)}/api/saved_objects/_import`) + .auth(user.username, user.password) + .attach('file', Buffer.from(JSON.stringify(data), 'utf8'), 'export.ndjson') + .expect(tests.default.statusCode) + .then(tests.default.response); + }); + + describe('unknown type', () => { + it(`should return ${tests.unknownType.statusCode}`, async () => { + const data = createImportData(spaceId); + data.push({ + type: 'wigwags', + id: '1', + attributes: { + title: 'Wigwags title', + }, + }); + await supertest + .post(`${getUrlPrefix(spaceId)}/api/saved_objects/_import`) + .query({ overwrite: true }) + .auth(user.username, user.password) + .attach('file', Buffer.from(JSON.stringify(data), 'utf8'), 'export.ndjson') + .expect(tests.unknownType.statusCode) + .then(tests.unknownType.response); + }); + }); + }); + }; + + const importTest = makeImportTest(describe); + // @ts-ignore + importTest.only = makeImportTest(describe.only); + + return { + importTest, + createExpectResults, + expectRbacForbidden, + expectUnknownType, + expectRbacForbiddenWithUnknownType, + expectRbacForbiddenForUnknownType, + }; +} diff --git a/x-pack/test/saved_object_api_integration/common/suites/resolve_import_conflicts.ts b/x-pack/test/saved_object_api_integration/common/suites/resolve_import_conflicts.ts new file mode 100644 index 0000000000000..9d02781c674b4 --- /dev/null +++ b/x-pack/test/saved_object_api_integration/common/suites/resolve_import_conflicts.ts @@ -0,0 +1,185 @@ +/* + * 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 'expect.js'; +import { SuperTest } from 'supertest'; +import { DEFAULT_SPACE_ID } from '../../../../plugins/spaces/common/constants'; +import { getIdPrefix, getUrlPrefix } from '../lib/space_test_utils'; +import { DescribeFn, TestDefinitionAuthentication } from '../lib/types'; + +interface ResolveImportConflictsTest { + statusCode: number; + response: (resp: { [key: string]: any }) => void; +} + +interface ResolveImportConflictsTests { + default: ResolveImportConflictsTest; + unknownType: ResolveImportConflictsTest; +} + +interface ResolveImportConflictsTestDefinition { + user?: TestDefinitionAuthentication; + spaceId?: string; + tests: ResolveImportConflictsTests; +} + +const createImportData = (spaceId: string) => [ + { + type: 'dashboard', + id: `${getIdPrefix(spaceId)}a01b2f57-fcfd-4864-b735-09e28f0d815e`, + attributes: { + title: 'A great new dashboard', + }, + }, + { + type: 'globaltype', + id: '05976c65-1145-4858-bbf0-d225cc78a06e', + attributes: { + name: 'A new globaltype object', + }, + }, +]; + +export function resolveImportConflictsTestSuiteFactory( + es: any, + esArchiver: any, + supertest: SuperTest +) { + const createExpectResults = (spaceId = DEFAULT_SPACE_ID) => async (resp: { + [key: string]: any; + }) => { + expect(resp.body).to.eql({ + success: true, + successCount: 1, + }); + }; + + const expectUnknownType = (resp: { [key: string]: any }) => { + expect(resp.body).to.eql({ + success: false, + successCount: 1, + errors: [ + { + id: '1', + type: 'wigwags', + error: { + message: `Unsupported saved object type: 'wigwags': Bad Request`, + statusCode: 400, + error: 'Bad Request', + }, + }, + ], + }); + }; + + const expectRbacForbidden = (resp: { [key: string]: any }) => { + expect(resp.body).to.eql({ + statusCode: 403, + error: 'Forbidden', + message: `Unable to bulk_create dashboard, missing action:saved_objects/dashboard/bulk_create`, + }); + }; + + const expectRbacForbiddenWithUnknownType = (resp: { [key: string]: any }) => { + expect(resp.body).to.eql({ + statusCode: 403, + error: 'Forbidden', + message: `Unable to bulk_create dashboard,wigwags, missing action:saved_objects/dashboard/bulk_create,action:saved_objects/wigwags/bulk_create`, + }); + }; + + const expectRbacForbiddenForUnknownType = (resp: { [key: string]: any }) => { + expect(resp.body).to.eql({ + statusCode: 403, + error: 'Forbidden', + message: `Unable to bulk_create dashboard,wigwags, missing action:saved_objects/wigwags/bulk_create`, + }); + }; + + const makeResolveImportConflictsTest = (describeFn: DescribeFn) => ( + description: string, + definition: ResolveImportConflictsTestDefinition + ) => { + const { user = {}, spaceId = DEFAULT_SPACE_ID, tests } = definition; + + describeFn(description, () => { + before(() => esArchiver.load('saved_objects/spaces')); + after(() => esArchiver.unload('saved_objects/spaces')); + + it(`should return ${tests.default.statusCode}`, async () => { + const data = createImportData(spaceId); + await supertest + .post(`${getUrlPrefix(spaceId)}/api/saved_objects/_resolve_import_conflicts`) + .auth(user.username, user.password) + .field( + 'overwrites', + JSON.stringify([ + { + type: 'dashboard', + id: `${getIdPrefix(spaceId)}a01b2f57-fcfd-4864-b735-09e28f0d815e`, + }, + ]) + ) + .attach( + 'file', + Buffer.from(data.map(obj => JSON.stringify(obj)).join('\n'), 'utf8'), + 'export.ndjson' + ) + .expect(tests.default.statusCode) + .then(tests.default.response); + }); + + describe('unknown type', () => { + it(`should return ${tests.unknownType.statusCode}`, async () => { + const data = createImportData(spaceId); + data.push({ + type: 'wigwags', + id: '1', + attributes: { + title: 'Wigwags title', + }, + }); + await supertest + .post(`${getUrlPrefix(spaceId)}/api/saved_objects/_resolve_import_conflicts`) + .auth(user.username, user.password) + .field( + 'overwrites', + JSON.stringify([ + { + type: 'wigwags', + id: '1', + }, + { + type: 'dashboard', + id: `${getIdPrefix(spaceId)}a01b2f57-fcfd-4864-b735-09e28f0d815e`, + }, + ]) + ) + .attach( + 'file', + Buffer.from(data.map(obj => JSON.stringify(obj)).join('\n'), 'utf8'), + 'export.ndjson' + ) + .expect(tests.unknownType.statusCode) + .then(tests.unknownType.response); + }); + }); + }); + }; + + const resolveImportConflictsTest = makeResolveImportConflictsTest(describe); + // @ts-ignore + resolveImportConflictsTest.only = makeResolveImportConflictsTest(describe.only); + + return { + resolveImportConflictsTest, + createExpectResults, + expectRbacForbidden, + expectUnknownType, + expectRbacForbiddenWithUnknownType, + expectRbacForbiddenForUnknownType, + }; +} diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/import.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/import.ts new file mode 100644 index 0000000000000..f21e6f9275717 --- /dev/null +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/import.ts @@ -0,0 +1,211 @@ +/* + * 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 { importTestSuiteFactory } from '../../common/suites/import'; + +// tslint:disable:no-default-export +export default function({ getService }: TestInvoker) { + const supertest = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + const es = getService('es'); + + const { + importTest, + createExpectResults, + expectRbacForbidden, + expectUnknownType, + expectRbacForbiddenWithUnknownType, + expectRbacForbiddenForUnknownType, + } = importTestSuiteFactory(es, esArchiver, supertest); + + describe('_import', () => { + [ + { + spaceId: SPACES.DEFAULT.spaceId, + users: { + noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, + superuser: AUTHENTICATION.SUPERUSER, + legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, + allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, + readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, + dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, + dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, + allAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, + readAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, + allAtOtherSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, + }, + }, + { + spaceId: SPACES.SPACE_1.spaceId, + users: { + noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, + superuser: AUTHENTICATION.SUPERUSER, + legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, + allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, + readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, + dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, + dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, + allAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, + readAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, + allAtOtherSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, + }, + }, + ].forEach(scenario => { + importTest(`user with no access within the ${scenario.spaceId} space`, { + user: scenario.users.noAccess, + spaceId: scenario.spaceId, + tests: { + default: { + statusCode: 403, + response: expectRbacForbidden, + }, + unknownType: { + statusCode: 403, + response: expectRbacForbiddenWithUnknownType, + }, + }, + }); + + importTest(`superuser within the ${scenario.spaceId} space`, { + user: scenario.users.superuser, + spaceId: scenario.spaceId, + tests: { + default: { + statusCode: 200, + response: createExpectResults(scenario.spaceId), + }, + unknownType: { + statusCode: 200, + response: expectUnknownType, + }, + }, + }); + + importTest(`legacy user within the ${scenario.spaceId} space`, { + user: scenario.users.legacyAll, + spaceId: scenario.spaceId, + tests: { + default: { + statusCode: 403, + response: expectRbacForbidden, + }, + unknownType: { + statusCode: 403, + response: expectRbacForbiddenWithUnknownType, + }, + }, + }); + + importTest(`dual-privileges user within the ${scenario.spaceId} space`, { + user: scenario.users.dualAll, + spaceId: scenario.spaceId, + tests: { + default: { + statusCode: 200, + response: createExpectResults(scenario.spaceId), + }, + unknownType: { + statusCode: 403, + response: expectRbacForbiddenForUnknownType, + }, + }, + }); + + importTest(`dual-privileges readonly user within the ${scenario.spaceId} space`, { + user: scenario.users.dualRead, + spaceId: scenario.spaceId, + tests: { + default: { + statusCode: 403, + response: expectRbacForbidden, + }, + unknownType: { + statusCode: 403, + response: expectRbacForbiddenWithUnknownType, + }, + }, + }); + + importTest(`rbac user with all globally within the ${scenario.spaceId} space`, { + user: scenario.users.allGlobally, + spaceId: scenario.spaceId, + tests: { + default: { + statusCode: 200, + response: createExpectResults(scenario.spaceId), + }, + unknownType: { + statusCode: 403, + response: expectRbacForbiddenForUnknownType, + }, + }, + }); + + importTest(`rbac user with read globally within the ${scenario.spaceId} space`, { + user: scenario.users.readGlobally, + spaceId: scenario.spaceId, + tests: { + default: { + statusCode: 403, + response: expectRbacForbidden, + }, + unknownType: { + statusCode: 403, + response: expectRbacForbiddenWithUnknownType, + }, + }, + }); + + importTest(`rbac user with all at the space within the ${scenario.spaceId} space`, { + user: scenario.users.allAtSpace, + spaceId: scenario.spaceId, + tests: { + default: { + statusCode: 200, + response: createExpectResults(scenario.spaceId), + }, + unknownType: { + statusCode: 403, + response: expectRbacForbiddenForUnknownType, + }, + }, + }); + + importTest(`rbac user with read at the space within the ${scenario.spaceId} space`, { + user: scenario.users.readAtSpace, + spaceId: scenario.spaceId, + tests: { + default: { + statusCode: 403, + response: expectRbacForbidden, + }, + unknownType: { + statusCode: 403, + response: expectRbacForbiddenWithUnknownType, + }, + }, + }); + + importTest(`rbac user with all at other space within the ${scenario.spaceId} space`, { + user: scenario.users.allAtOtherSpace, + spaceId: scenario.spaceId, + tests: { + default: { + statusCode: 403, + response: expectRbacForbidden, + }, + unknownType: { + statusCode: 403, + response: expectRbacForbiddenWithUnknownType, + }, + }, + }); + }); + }); +} diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/index.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/index.ts index 415a1102ada3f..20a46737e9117 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/index.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/index.ts @@ -26,6 +26,8 @@ export default function({ getService, loadTestFile }: TestInvoker) { loadTestFile(require.resolve('./export')); loadTestFile(require.resolve('./find')); loadTestFile(require.resolve('./get')); + loadTestFile(require.resolve('./import')); + loadTestFile(require.resolve('./resolve_import_conflicts')); loadTestFile(require.resolve('./update')); }); } diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve_import_conflicts.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve_import_conflicts.ts new file mode 100644 index 0000000000000..22aba81120312 --- /dev/null +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve_import_conflicts.ts @@ -0,0 +1,229 @@ +/* + * 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 { resolveImportConflictsTestSuiteFactory } from '../../common/suites/resolve_import_conflicts'; + +// tslint:disable:no-default-export +export default function({ getService }: TestInvoker) { + const supertest = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + const es = getService('es'); + + const { + resolveImportConflictsTest, + createExpectResults, + expectRbacForbidden, + expectUnknownType, + expectRbacForbiddenWithUnknownType, + expectRbacForbiddenForUnknownType, + } = resolveImportConflictsTestSuiteFactory(es, esArchiver, supertest); + + describe('_resolve_import_conflicts', () => { + [ + { + spaceId: SPACES.DEFAULT.spaceId, + users: { + noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, + superuser: AUTHENTICATION.SUPERUSER, + legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, + allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, + readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, + dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, + dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, + allAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, + readAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, + allAtOtherSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, + }, + }, + { + spaceId: SPACES.SPACE_1.spaceId, + users: { + noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, + superuser: AUTHENTICATION.SUPERUSER, + legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, + allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, + readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, + dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, + dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, + allAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, + readAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, + allAtOtherSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, + }, + }, + ].forEach(scenario => { + resolveImportConflictsTest(`user with no access within the ${scenario.spaceId} space`, { + user: scenario.users.noAccess, + spaceId: scenario.spaceId, + tests: { + default: { + statusCode: 403, + response: expectRbacForbidden, + }, + unknownType: { + statusCode: 403, + response: expectRbacForbiddenWithUnknownType, + }, + }, + }); + + resolveImportConflictsTest(`superuser within the ${scenario.spaceId} space`, { + user: scenario.users.superuser, + spaceId: scenario.spaceId, + tests: { + default: { + statusCode: 200, + response: createExpectResults(scenario.spaceId), + }, + unknownType: { + statusCode: 200, + response: expectUnknownType, + }, + }, + }); + + resolveImportConflictsTest(`legacy user within the ${scenario.spaceId} space`, { + user: scenario.users.legacyAll, + spaceId: scenario.spaceId, + tests: { + default: { + statusCode: 403, + response: expectRbacForbidden, + }, + unknownType: { + statusCode: 403, + response: expectRbacForbiddenWithUnknownType, + }, + }, + }); + + resolveImportConflictsTest(`dual-privileges user within the ${scenario.spaceId} space`, { + user: scenario.users.dualAll, + spaceId: scenario.spaceId, + tests: { + default: { + statusCode: 200, + response: createExpectResults(scenario.spaceId), + }, + unknownType: { + statusCode: 403, + response: expectRbacForbiddenForUnknownType, + }, + }, + }); + + resolveImportConflictsTest( + `dual-privileges readonly user within the ${scenario.spaceId} space`, + { + user: scenario.users.dualRead, + spaceId: scenario.spaceId, + tests: { + default: { + statusCode: 403, + response: expectRbacForbidden, + }, + unknownType: { + statusCode: 403, + response: expectRbacForbiddenWithUnknownType, + }, + }, + } + ); + + resolveImportConflictsTest( + `rbac user with all globally within the ${scenario.spaceId} space`, + { + user: scenario.users.allGlobally, + spaceId: scenario.spaceId, + tests: { + default: { + statusCode: 200, + response: createExpectResults(scenario.spaceId), + }, + unknownType: { + statusCode: 403, + response: expectRbacForbiddenForUnknownType, + }, + }, + } + ); + + resolveImportConflictsTest( + `rbac user with read globally within the ${scenario.spaceId} space`, + { + user: scenario.users.readGlobally, + spaceId: scenario.spaceId, + tests: { + default: { + statusCode: 403, + response: expectRbacForbidden, + }, + unknownType: { + statusCode: 403, + response: expectRbacForbiddenWithUnknownType, + }, + }, + } + ); + + resolveImportConflictsTest( + `rbac user with all at the space within the ${scenario.spaceId} space`, + { + user: scenario.users.allAtSpace, + spaceId: scenario.spaceId, + tests: { + default: { + statusCode: 200, + response: createExpectResults(scenario.spaceId), + }, + unknownType: { + statusCode: 403, + response: expectRbacForbiddenForUnknownType, + }, + }, + } + ); + + resolveImportConflictsTest( + `rbac user with read at the space within the ${scenario.spaceId} space`, + { + user: scenario.users.readAtSpace, + spaceId: scenario.spaceId, + tests: { + default: { + statusCode: 403, + response: expectRbacForbidden, + }, + unknownType: { + statusCode: 403, + response: expectRbacForbiddenWithUnknownType, + }, + }, + } + ); + + resolveImportConflictsTest( + `rbac user with all at other space within the ${scenario.spaceId} space`, + { + user: scenario.users.allAtOtherSpace, + spaceId: scenario.spaceId, + tests: { + default: { + statusCode: 403, + response: expectRbacForbidden, + }, + unknownType: { + statusCode: 403, + response: expectRbacForbiddenWithUnknownType, + }, + }, + } + ); + }); + }); +} diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/import.ts b/x-pack/test/saved_object_api_integration/security_only/apis/import.ts new file mode 100644 index 0000000000000..c36a192ea0423 --- /dev/null +++ b/x-pack/test/saved_object_api_integration/security_only/apis/import.ts @@ -0,0 +1,181 @@ +/* + * 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 { TestInvoker } from '../../common/lib/types'; +import { importTestSuiteFactory } from '../../common/suites/import'; + +// tslint:disable:no-default-export +export default function({ getService }: TestInvoker) { + const supertest = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + const es = getService('es'); + + const { + importTest, + createExpectResults, + expectRbacForbidden, + expectUnknownType, + expectRbacForbiddenWithUnknownType, + expectRbacForbiddenForUnknownType, + } = importTestSuiteFactory(es, esArchiver, supertest); + + describe('_import', () => { + importTest(`user with no access`, { + user: AUTHENTICATION.NOT_A_KIBANA_USER, + tests: { + default: { + statusCode: 403, + response: expectRbacForbidden, + }, + unknownType: { + statusCode: 403, + response: expectRbacForbiddenWithUnknownType, + }, + }, + }); + + importTest(`superuser`, { + user: AUTHENTICATION.SUPERUSER, + tests: { + default: { + statusCode: 200, + response: createExpectResults(), + }, + unknownType: { + statusCode: 200, + response: expectUnknownType, + }, + }, + }); + + importTest(`legacy user`, { + user: AUTHENTICATION.KIBANA_LEGACY_USER, + tests: { + default: { + statusCode: 403, + response: expectRbacForbidden, + }, + unknownType: { + statusCode: 403, + response: expectRbacForbiddenWithUnknownType, + }, + }, + }); + + importTest(`dual-privileges user`, { + user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, + tests: { + default: { + statusCode: 200, + response: createExpectResults(), + }, + unknownType: { + statusCode: 403, + response: expectRbacForbiddenForUnknownType, + }, + }, + }); + + importTest(`dual-privileges readonly user`, { + user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, + tests: { + default: { + statusCode: 403, + response: expectRbacForbidden, + }, + unknownType: { + statusCode: 403, + response: expectRbacForbiddenWithUnknownType, + }, + }, + }); + + importTest(`rbac user with all globally`, { + user: AUTHENTICATION.KIBANA_RBAC_USER, + tests: { + default: { + statusCode: 200, + response: createExpectResults(), + }, + unknownType: { + statusCode: 403, + response: expectRbacForbiddenForUnknownType, + }, + }, + }); + + importTest(`rbac readonly user`, { + user: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, + tests: { + default: { + statusCode: 403, + response: expectRbacForbidden, + }, + unknownType: { + statusCode: 403, + response: expectRbacForbiddenWithUnknownType, + }, + }, + }); + + importTest(`rbac user with all at default space`, { + user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, + tests: { + default: { + statusCode: 403, + response: expectRbacForbidden, + }, + unknownType: { + statusCode: 403, + response: expectRbacForbiddenWithUnknownType, + }, + }, + }); + + importTest(`rbac user with read at default space`, { + user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, + tests: { + default: { + statusCode: 403, + response: expectRbacForbidden, + }, + unknownType: { + statusCode: 403, + response: expectRbacForbiddenWithUnknownType, + }, + }, + }); + + importTest(`rbac user with all at space_1`, { + user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, + tests: { + default: { + statusCode: 403, + response: expectRbacForbidden, + }, + unknownType: { + statusCode: 403, + response: expectRbacForbiddenWithUnknownType, + }, + }, + }); + + importTest(`rbac user with read at space_1`, { + user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, + tests: { + default: { + statusCode: 403, + response: expectRbacForbidden, + }, + unknownType: { + statusCode: 403, + response: expectRbacForbiddenWithUnknownType, + }, + }, + }); + }); +} diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/index.ts b/x-pack/test/saved_object_api_integration/security_only/apis/index.ts index 4a38b6a8373e7..3090bb5e3f503 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/index.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/index.ts @@ -26,6 +26,8 @@ export default function({ getService, loadTestFile }: TestInvoker) { loadTestFile(require.resolve('./export')); loadTestFile(require.resolve('./find')); loadTestFile(require.resolve('./get')); + loadTestFile(require.resolve('./import')); + loadTestFile(require.resolve('./resolve_import_conflicts')); loadTestFile(require.resolve('./update')); }); } diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/resolve_import_conflicts.ts b/x-pack/test/saved_object_api_integration/security_only/apis/resolve_import_conflicts.ts new file mode 100644 index 0000000000000..16cf71c0ac651 --- /dev/null +++ b/x-pack/test/saved_object_api_integration/security_only/apis/resolve_import_conflicts.ts @@ -0,0 +1,181 @@ +/* + * 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 { TestInvoker } from '../../common/lib/types'; +import { resolveImportConflictsTestSuiteFactory } from '../../common/suites/resolve_import_conflicts'; + +// tslint:disable:no-default-export +export default function({ getService }: TestInvoker) { + const supertest = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + const es = getService('es'); + + const { + resolveImportConflictsTest, + createExpectResults, + expectRbacForbidden, + expectUnknownType, + expectRbacForbiddenWithUnknownType, + expectRbacForbiddenForUnknownType, + } = resolveImportConflictsTestSuiteFactory(es, esArchiver, supertest); + + describe('_resolve_import_conflicts', () => { + resolveImportConflictsTest(`user with no access`, { + user: AUTHENTICATION.NOT_A_KIBANA_USER, + tests: { + default: { + statusCode: 403, + response: expectRbacForbidden, + }, + unknownType: { + statusCode: 403, + response: expectRbacForbiddenWithUnknownType, + }, + }, + }); + + resolveImportConflictsTest(`superuser`, { + user: AUTHENTICATION.SUPERUSER, + tests: { + default: { + statusCode: 200, + response: createExpectResults(), + }, + unknownType: { + statusCode: 200, + response: expectUnknownType, + }, + }, + }); + + resolveImportConflictsTest(`legacy user`, { + user: AUTHENTICATION.KIBANA_LEGACY_USER, + tests: { + default: { + statusCode: 403, + response: expectRbacForbidden, + }, + unknownType: { + statusCode: 403, + response: expectRbacForbiddenWithUnknownType, + }, + }, + }); + + resolveImportConflictsTest(`dual-privileges user`, { + user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, + tests: { + default: { + statusCode: 200, + response: createExpectResults(), + }, + unknownType: { + statusCode: 403, + response: expectRbacForbiddenForUnknownType, + }, + }, + }); + + resolveImportConflictsTest(`dual-privileges readonly user`, { + user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, + tests: { + default: { + statusCode: 403, + response: expectRbacForbidden, + }, + unknownType: { + statusCode: 403, + response: expectRbacForbiddenWithUnknownType, + }, + }, + }); + + resolveImportConflictsTest(`rbac user with all globally`, { + user: AUTHENTICATION.KIBANA_RBAC_USER, + tests: { + default: { + statusCode: 200, + response: createExpectResults(), + }, + unknownType: { + statusCode: 403, + response: expectRbacForbiddenForUnknownType, + }, + }, + }); + + resolveImportConflictsTest(`rbac readonly user`, { + user: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, + tests: { + default: { + statusCode: 403, + response: expectRbacForbidden, + }, + unknownType: { + statusCode: 403, + response: expectRbacForbiddenWithUnknownType, + }, + }, + }); + + resolveImportConflictsTest(`rbac user with all at default space`, { + user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, + tests: { + default: { + statusCode: 403, + response: expectRbacForbidden, + }, + unknownType: { + statusCode: 403, + response: expectRbacForbiddenWithUnknownType, + }, + }, + }); + + resolveImportConflictsTest(`rbac user with read at default space`, { + user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, + tests: { + default: { + statusCode: 403, + response: expectRbacForbidden, + }, + unknownType: { + statusCode: 403, + response: expectRbacForbiddenWithUnknownType, + }, + }, + }); + + resolveImportConflictsTest(`rbac user with all at space_1`, { + user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, + tests: { + default: { + statusCode: 403, + response: expectRbacForbidden, + }, + unknownType: { + statusCode: 403, + response: expectRbacForbiddenWithUnknownType, + }, + }, + }); + + resolveImportConflictsTest(`rbac user with read at space_1`, { + user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, + tests: { + default: { + statusCode: 403, + response: expectRbacForbidden, + }, + unknownType: { + statusCode: 403, + response: expectRbacForbiddenWithUnknownType, + }, + }, + }); + }); +} diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/import.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/import.ts new file mode 100644 index 0000000000000..d602fc51ddd71 --- /dev/null +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/import.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SPACES } from '../../common/lib/spaces'; +import { TestInvoker } from '../../common/lib/types'; +import { importTestSuiteFactory } from '../../common/suites/import'; + +// tslint:disable:no-default-export +export default function({ getService }: TestInvoker) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + const es = getService('es'); + + const { importTest, createExpectResults, expectUnknownType } = importTestSuiteFactory( + es, + esArchiver, + supertest + ); + + describe('_import', () => { + importTest('in the current space (space_1)', { + ...SPACES.SPACE_1, + tests: { + default: { + statusCode: 200, + response: createExpectResults(SPACES.SPACE_1.spaceId), + }, + unknownType: { + statusCode: 200, + response: expectUnknownType, + }, + }, + }); + + importTest('in the default space', { + ...SPACES.DEFAULT, + tests: { + default: { + statusCode: 200, + response: createExpectResults(SPACES.DEFAULT.spaceId), + }, + unknownType: { + statusCode: 200, + response: expectUnknownType, + }, + }, + }); + }); +} diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/index.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/index.ts index 74d369406e98c..5fba9389ba22b 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/index.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/index.ts @@ -18,6 +18,8 @@ export default function({ loadTestFile }: TestInvoker) { loadTestFile(require.resolve('./export')); loadTestFile(require.resolve('./find')); loadTestFile(require.resolve('./get')); + loadTestFile(require.resolve('./import')); + loadTestFile(require.resolve('./resolve_import_conflicts')); loadTestFile(require.resolve('./update')); }); } diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve_import_conflicts.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve_import_conflicts.ts new file mode 100644 index 0000000000000..4797cb91e2251 --- /dev/null +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve_import_conflicts.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SPACES } from '../../common/lib/spaces'; +import { TestInvoker } from '../../common/lib/types'; +import { resolveImportConflictsTestSuiteFactory } from '../../common/suites/resolve_import_conflicts'; + +// tslint:disable:no-default-export +export default function({ getService }: TestInvoker) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + const es = getService('es'); + + const { + resolveImportConflictsTest, + createExpectResults, + expectUnknownType, + } = resolveImportConflictsTestSuiteFactory(es, esArchiver, supertest); + + describe('_resolve_import_conflicts', () => { + resolveImportConflictsTest('in the current space (space_1)', { + ...SPACES.SPACE_1, + tests: { + default: { + statusCode: 200, + response: createExpectResults(SPACES.SPACE_1.spaceId), + }, + unknownType: { + statusCode: 200, + response: expectUnknownType, + }, + }, + }); + + resolveImportConflictsTest('in the default space', { + ...SPACES.DEFAULT, + tests: { + default: { + statusCode: 200, + response: createExpectResults(SPACES.DEFAULT.spaceId), + }, + unknownType: { + statusCode: 200, + response: expectUnknownType, + }, + }, + }); + }); +}