diff --git a/src/core_plugins/kibana/ui_setting_defaults.js b/src/core_plugins/kibana/ui_setting_defaults.js index dc00edaad0b27..2965f0624c347 100644 --- a/src/core_plugins/kibana/ui_setting_defaults.js +++ b/src/core_plugins/kibana/ui_setting_defaults.js @@ -511,4 +511,4 @@ export function getUiSettingDefaults() { category: ['discover'], }, }; -} +} \ No newline at end of file diff --git a/src/server/saved_objects/service/lib/repository.js b/src/server/saved_objects/service/lib/repository.js index 4ff8eaa8d6b6e..6ede707c1819a 100644 --- a/src/server/saved_objects/service/lib/repository.js +++ b/src/server/saved_objects/service/lib/repository.js @@ -36,7 +36,7 @@ export class SavedObjectsRepository { index, mappings, callCluster, - onBeforeWrite = () => {}, + onBeforeWrite = () => { }, } = options; this._index = index; @@ -54,11 +54,13 @@ export class SavedObjectsRepository { * @param {object} [options={}] * @property {string} [options.id] - force id on creation, not recommended * @property {boolean} [options.overwrite=false] + * @property {object} [options.extraDocumentProperties={}] - extra properties to append to the document body, outside of the object's type property * @returns {promise} - { id, type, version, attributes } */ async create(type, attributes = {}, options = {}) { const { id, + extraDocumentProperties = {}, overwrite = false } = options; @@ -72,9 +74,10 @@ export class SavedObjectsRepository { index: this._index, refresh: 'wait_for', body: { + ...extraDocumentProperties, type, updated_at: time, - [type]: attributes + [type]: attributes, }, }); @@ -98,7 +101,7 @@ export class SavedObjectsRepository { /** * Creates multiple documents at once * - * @param {array} objects - [{ type, id, attributes }] + * @param {array} objects - [{ type, id, attributes, extraDocumentProperties }] * @param {object} [options={}] * @property {boolean} [options.overwrite=false] - overwrites existing documents * @returns {promise} - {saved_objects: [[{ id, type, version, attributes, error: { message } }]} @@ -119,9 +122,10 @@ export class SavedObjectsRepository { } }, { + ...object.extraDocumentProperties, type: object.type, updated_at: time, - [object.type]: object.attributes + [object.type]: object.attributes, } ]; }; @@ -216,6 +220,7 @@ export class SavedObjectsRepository { * @property {string} [options.search] * @property {Array} [options.searchFields] - see Elasticsearch Simple Query String * Query field argument for more information + * @property {object} [options.filters] - ES Query filters to append * @property {integer} [options.page=1] * @property {integer} [options.perPage=20] * @property {string} [options.sortField] @@ -233,6 +238,7 @@ export class SavedObjectsRepository { sortField, sortOrder, fields, + filters, } = options; if (searchFields && !Array.isArray(searchFields)) { @@ -243,6 +249,10 @@ export class SavedObjectsRepository { throw new TypeError('options.searchFields must be an array'); } + if (filters && !Array.isArray(filters)) { + throw new TypeError('options.filters must be an array'); + } + const esOptions = { index: this._index, size: perPage, @@ -256,7 +266,8 @@ export class SavedObjectsRepository { searchFields, type, sortField, - sortOrder + sortOrder, + filters }) } }; @@ -295,6 +306,8 @@ export class SavedObjectsRepository { * Returns an array of objects by id * * @param {array} objects - an array ids, or an array of objects containing id and optionally type + * @param {object} [options = {}] + * @param {array} [options.extraDocumentProperties = []] - an array of extra properties to return from the underlying document * @returns {promise} - { saved_objects: [{ id, type, version, attributes }] } * @example * @@ -303,7 +316,7 @@ export class SavedObjectsRepository { * { id: 'foo', type: 'index-pattern' } * ]) */ - async bulkGet(objects = []) { + async bulkGet(objects = [], options = {}) { if (objects.length === 0) { return { saved_objects: [] }; } @@ -318,8 +331,12 @@ export class SavedObjectsRepository { } }); + const { docs } = response; + + const { extraDocumentProperties = [] } = options; + return { - saved_objects: response.docs.map((doc, i) => { + saved_objects: docs.map((doc, i) => { const { id, type } = objects[i]; if (!doc.found) { @@ -331,13 +348,20 @@ export class SavedObjectsRepository { } const time = doc._source.updated_at; - return { + const savedObject = { id, type, ...time && { updated_at: time }, version: doc._version, - attributes: doc._source[type] + ...extraDocumentProperties + .map(s => ({ [s]: doc._source[s] })) + .reduce((acc, prop) => ({ ...acc, ...prop }), {}), + attributes: { + ...doc._source[type], + } }; + + return savedObject; }) }; } @@ -347,9 +371,11 @@ export class SavedObjectsRepository { * * @param {string} type * @param {string} id + * @param {object} [options = {}] + * @param {array} [options.extraDocumentProperties = []] - an array of extra properties to return from the underlying document * @returns {promise} - { id, type, version, attributes } */ - async get(type, id) { + async get(type, id, options = {}) { const response = await this._callCluster('get', { id: this._generateEsId(type, id), type: this._type, @@ -364,6 +390,8 @@ export class SavedObjectsRepository { throw errors.createGenericNotFoundError(type, id); } + const { extraDocumentProperties = [] } = options; + const { updated_at: updatedAt } = response._source; return { @@ -371,7 +399,12 @@ export class SavedObjectsRepository { type, ...updatedAt && { updated_at: updatedAt }, version: response._version, - attributes: response._source[type] + ...extraDocumentProperties + .map(s => ({ [s]: response._source[s] })) + .reduce((acc, prop) => ({ ...acc, ...prop }), {}), + attributes: { + ...response._source[type], + } }; } @@ -382,6 +415,7 @@ export class SavedObjectsRepository { * @param {string} id * @param {object} [options={}] * @property {integer} options.version - ensures version matches that of persisted object + * @param {array} [options.extraDocumentProperties = {}] - an object of extra properties to write into the underlying document * @returns {promise} */ async update(type, id, attributes, options = {}) { @@ -395,8 +429,9 @@ export class SavedObjectsRepository { ignore: [404], body: { doc: { + ...options.extraDocumentProperties, updated_at: time, - [type]: attributes + [type]: attributes, } }, }); diff --git a/src/server/saved_objects/service/lib/repository.test.js b/src/server/saved_objects/service/lib/repository.test.js index 39174d204a146..2b84e773e0276 100644 --- a/src/server/saved_objects/service/lib/repository.test.js +++ b/src/server/saved_objects/service/lib/repository.test.js @@ -155,11 +155,12 @@ describe('SavedObjectsRepository', () => { }); it('should use create action if ID defined and overwrite=false', async () => { - await savedObjectsRepository.create('index-pattern', { - title: 'Logstash' - }, { - id: 'logstash-*', - }); + await savedObjectsRepository.create('index-pattern', + { + title: 'Logstash' + }, { + id: 'logstash-*', + }); sinon.assert.calledOnce(callAdminCluster); sinon.assert.calledWith(callAdminCluster, 'create'); @@ -191,6 +192,64 @@ describe('SavedObjectsRepository', () => { sinon.assert.calledOnce(onBeforeWrite); }); + + it('appends extraDocumentProperties to the document', async () => { + await savedObjectsRepository.create('index-pattern', + { + title: 'Logstash' + }, + { + extraDocumentProperties: { + myExtraProp: 'myExtraValue', + myOtherExtraProp: true, + } + } + ); + + sinon.assert.calledOnce(callAdminCluster); + sinon.assert.calledWithExactly(callAdminCluster, sinon.match.string, sinon.match({ + body: { + [`index-pattern`]: { title: 'Logstash' }, + myExtraProp: 'myExtraValue', + myOtherExtraProp: true, + type: 'index-pattern', + updated_at: '2017-08-14T15:49:14.886Z' + } + })); + + sinon.assert.calledOnce(onBeforeWrite); + }); + + it('does not allow extraDocumentProperties to overwrite existing properties', async () => { + await savedObjectsRepository.create('index-pattern', + { + title: 'Logstash' + }, + { + extraDocumentProperties: { + myExtraProp: 'myExtraValue', + myOtherExtraProp: true, + updated_at: 'should_not_be_used', + 'index-pattern': { + title: 'should_not_be_used' + } + } + } + ); + + sinon.assert.calledOnce(callAdminCluster); + sinon.assert.calledWithExactly(callAdminCluster, sinon.match.string, sinon.match({ + body: { + [`index-pattern`]: { title: 'Logstash' }, + myExtraProp: 'myExtraValue', + myOtherExtraProp: true, + type: 'index-pattern', + updated_at: '2017-08-14T15:49:14.886Z' + } + })); + + sinon.assert.calledOnce(onBeforeWrite); + }); }); describe('#bulkCreate', () => { @@ -329,10 +388,77 @@ describe('SavedObjectsRepository', () => { ] }); }); + + it('appends extraDocumentProperties to each created object', async () => { + callAdminCluster.returns({ items: [] }); + + await savedObjectsRepository.bulkCreate( + [ + { type: 'config', id: 'one', attributes: { title: 'Test One' }, extraDocumentProperties: { extraConfigValue: true } }, + { type: 'index-pattern', id: 'two', attributes: { title: 'Test Two' }, extraDocumentProperties: { extraIndexValue: true } } + ]); + + sinon.assert.calledOnce(callAdminCluster); + sinon.assert.calledWithExactly(callAdminCluster, 'bulk', sinon.match({ + body: [ + { create: { _type: 'doc', _id: 'config:one' } }, + { type: 'config', ...mockTimestampFields, config: { title: 'Test One' }, extraConfigValue: true }, + { create: { _type: 'doc', _id: 'index-pattern:two' } }, + { type: 'index-pattern', ...mockTimestampFields, 'index-pattern': { title: 'Test Two' }, extraIndexValue: true } + ] + })); + + sinon.assert.calledOnce(onBeforeWrite); + }); + + it('does not allow extraDocumentProperties to overwrite existing properties', async () => { + + callAdminCluster.returns({ items: [] }); + + const extraDocumentProperties = { + extraProp: 'extraVal', + updated_at: 'should_not_be_used', + }; + const configExtraDocumentProperties = { + ...extraDocumentProperties, + 'config': { newIgnoredProp: 'should_not_be_used' } + }; + const indexPatternExtraDocumentProperties = { + ...extraDocumentProperties, + 'index-pattern': { title: 'should_not_be_used', newIgnoredProp: 'should_not_be_used' } + }; + + await savedObjectsRepository.bulkCreate( + [{ + type: 'config', + id: 'one', + attributes: { title: 'Test One' }, + extraDocumentProperties: configExtraDocumentProperties + }, + { + type: 'index-pattern', + id: 'two', + attributes: { title: 'Test Two' }, + extraDocumentProperties: indexPatternExtraDocumentProperties + }] + ); + + sinon.assert.calledOnce(callAdminCluster); + sinon.assert.calledWithExactly(callAdminCluster, 'bulk', sinon.match({ + body: [ + { create: { _type: 'doc', _id: 'config:one' } }, + { type: 'config', ...mockTimestampFields, config: { title: 'Test One' }, extraProp: 'extraVal' }, + { create: { _type: 'doc', _id: 'index-pattern:two' } }, + { type: 'index-pattern', ...mockTimestampFields, 'index-pattern': { title: 'Test Two' }, extraProp: 'extraVal' } + ] + })); + + sinon.assert.calledOnce(onBeforeWrite); + }); }); describe('#delete', () => { - it('throws notFound when ES is unable to find the document', async () => { + it('throws notFound when ES is unable to find the document', async () => { expect.assertions(1); callAdminCluster.returns(Promise.resolve({ @@ -341,7 +467,7 @@ describe('SavedObjectsRepository', () => { try { await savedObjectsRepository.delete('index-pattern', 'logstash-*'); - } catch(e) { + } catch (e) { expect(e.output.statusCode).toEqual(404); } }); @@ -392,13 +518,25 @@ describe('SavedObjectsRepository', () => { } }); - it('passes mappings, search, searchFields, type, sortField, and sortOrder to getSearchDsl', async () => { + it('requires filters to be an array if defined', async () => { + try { + await savedObjectsRepository.find({ filters: 'string' }); + throw new Error('expected find() to reject'); + } catch (error) { + sinon.assert.notCalled(callAdminCluster); + sinon.assert.notCalled(onBeforeWrite); + expect(error.message).toMatch('must be an array'); + } + }); + + it('passes mappings, search, searchFields, type, sortField, filters, and sortOrder to getSearchDsl', async () => { const relevantOpts = { search: 'foo*', searchFields: ['foo'], type: 'bar', sortField: 'name', sortOrder: 'desc', + filters: [{ bool: {} }], }; await savedObjectsRepository.find(relevantOpts); @@ -472,6 +610,7 @@ describe('SavedObjectsRepository', () => { _version: 2, _source: { type: 'index-pattern', + specialProperty: 'specialValue', ...mockTimestampFields, 'index-pattern': { title: 'Testing' @@ -504,6 +643,24 @@ describe('SavedObjectsRepository', () => { type: 'doc' })); }); + + it('includes the requested extraDocumentProperties in the response for the requested object', async () => { + const response = await savedObjectsRepository.get('index-pattern', 'logstash-*', { + extraDocumentProperties: ['specialProperty', 'undefinedProperty'] + }); + + expect(response).toEqual({ + id: 'logstash-*', + type: 'index-pattern', + updated_at: mockTimestamp, + version: 2, + specialProperty: 'specialValue', + undefinedProperty: undefined, + attributes: { + title: 'Testing' + } + }); + }); }); describe('#bulkGet', () => { @@ -574,6 +731,80 @@ describe('SavedObjectsRepository', () => { error: { statusCode: 404, message: 'Not found' } }); }); + + it('includes the requested extraDocumentProperties in the response for each requested object', async () => { + callAdminCluster.returns(Promise.resolve({ + docs: [{ + _type: 'doc', + _id: 'config:good', + found: true, + _version: 2, + _source: { + ...mockTimestampFields, + type: 'config', + specialProperty: 'specialValue', + config: { title: 'Test' } + } + }, { + _type: 'doc', + _id: 'config:bad', + found: false + }, { + _id: 'index-pattern:logstash-*', + _type: 'doc', + found: true, + _version: 2, + _source: { + type: 'index-pattern', + specialProperty: 'anotherSpecialValue', + ...mockTimestampFields, + 'index-pattern': { + title: 'Testing' + } + } + }] + })); + + const { saved_objects: savedObjects } = await savedObjectsRepository.bulkGet( + [ + { id: 'good', type: 'config' }, + { id: 'bad', type: 'config' }, + { id: 'logstash-*', type: 'index-pattern' } + ], { + extraDocumentProperties: ['specialProperty', 'undefinedProperty'] + } + ); + + expect(savedObjects).toHaveLength(3); + + expect(savedObjects[0]).toEqual({ + id: 'good', + type: 'config', + ...mockTimestampFields, + version: 2, + specialProperty: 'specialValue', + undefinedProperty: undefined, + attributes: { title: 'Test' } + }); + + expect(savedObjects[1]).toEqual({ + id: 'bad', + type: 'config', + error: { statusCode: 404, message: 'Not found' } + }); + + expect(savedObjects[2]).toEqual({ + id: 'logstash-*', + type: 'index-pattern', + ...mockTimestampFields, + version: 2, + specialProperty: 'anotherSpecialValue', + undefinedProperty: undefined, + attributes: { + title: 'Testing' + } + }); + }); }); describe('#update', () => { @@ -634,6 +865,60 @@ describe('SavedObjectsRepository', () => { sinon.assert.calledOnce(onBeforeWrite); }); + + it('updates the document including all provided extraDocumentProperties', async () => { + await savedObjectsRepository.update( + 'index-pattern', + 'logstash-*', + { title: 'Testing' }, + { extraDocumentProperties: { extraProp: 'extraVal' } } + ); + + sinon.assert.calledOnce(callAdminCluster); + sinon.assert.calledWithExactly(callAdminCluster, 'update', { + type: 'doc', + id: 'index-pattern:logstash-*', + version: undefined, + body: { + doc: { updated_at: mockTimestamp, extraProp: 'extraVal', 'index-pattern': { title: 'Testing' } } + }, + ignore: [404], + refresh: 'wait_for', + index: '.kibana-test' + }); + + sinon.assert.calledOnce(onBeforeWrite); + }); + + it('does not allow extraDocumentProperties to overwrite existing properties', async () => { + await savedObjectsRepository.update( + 'index-pattern', + 'logstash-*', + { title: 'Testing' }, + { + extraDocumentProperties: { + extraProp: 'extraVal', + updated_at: 'should_not_be_used', + 'index-pattern': { title: 'should_not_be_used', newIgnoredProp: 'should_not_be_used' } + } + } + ); + + sinon.assert.calledOnce(callAdminCluster); + sinon.assert.calledWithExactly(callAdminCluster, 'update', { + type: 'doc', + id: 'index-pattern:logstash-*', + version: undefined, + body: { + doc: { updated_at: mockTimestamp, extraProp: 'extraVal', 'index-pattern': { title: 'Testing' } } + }, + ignore: [404], + refresh: 'wait_for', + index: '.kibana-test' + }); + + sinon.assert.calledOnce(onBeforeWrite); + }); }); describe('onBeforeWrite', () => { diff --git a/src/server/saved_objects/service/lib/search_dsl/query_params.js b/src/server/saved_objects/service/lib/search_dsl/query_params.js index bcf62f21ef415..c5ca55f985bdd 100644 --- a/src/server/saved_objects/service/lib/search_dsl/query_params.js +++ b/src/server/saved_objects/service/lib/search_dsl/query_params.js @@ -65,24 +65,21 @@ function getFieldsForTypes(searchFields, types) { * @param {(string|Array)} type * @param {String} search * @param {Array} searchFields + * @param {Array} filters additional query filters * @return {Object} */ -export function getQueryParams(mappings, type, search, searchFields) { - if (!type && !search) { - return {}; - } +export function getQueryParams(mappings, type, search, searchFields, filters = []) { - const bool = {}; + const bool = { + filter: [...filters], + }; if (type) { - bool.filter = [ - { [Array.isArray(type) ? 'terms' : 'term']: { type } } - ]; + bool.filter.push({ [Array.isArray(type) ? 'terms' : 'term']: { type } }); } if (search) { bool.must = [ - ...bool.must || [], { simple_query_string: { query: search, @@ -95,5 +92,12 @@ export function getQueryParams(mappings, type, search, searchFields) { ]; } + // Don't construct a query if there is nothing to search on. + if (bool.filter.length === 0 && !search) { + return {}; + } + return { query: { bool } }; } + + diff --git a/src/server/saved_objects/service/lib/search_dsl/query_params.test.js b/src/server/saved_objects/service/lib/search_dsl/query_params.test.js index 53b943ee6793b..e3e261db1e77e 100644 --- a/src/server/saved_objects/service/lib/search_dsl/query_params.test.js +++ b/src/server/saved_objects/service/lib/search_dsl/query_params.test.js @@ -64,7 +64,7 @@ describe('searchDsl/queryParams', () => { }); describe('{type}', () => { - it('adds a term filter when a string', () => { + it('includes just a terms filter', () => { expect(getQueryParams(MAPPINGS, 'saved')) .toEqual({ query: { @@ -78,15 +78,18 @@ describe('searchDsl/queryParams', () => { } }); }); + }); - it('adds a terms filter when an array', () => { - expect(getQueryParams(MAPPINGS, ['saved', 'vis'])) + describe('{type,filters}', () => { + it('includes filters and a term filter for type', () => { + expect(getQueryParams(MAPPINGS, 'saved', null, null, [{ terms: { foo: ['bar', 'baz'] } }])) .toEqual({ query: { bool: { filter: [ + { terms: { foo: ['bar', 'baz'] } }, { - terms: { type: ['saved', 'vis'] } + term: { type: 'saved' } } ] } @@ -101,6 +104,30 @@ describe('searchDsl/queryParams', () => { .toEqual({ query: { bool: { + filter: [], + must: [ + { + simple_query_string: { + query: 'us*', + all_fields: true + } + } + ] + } + } + }); + }); + }); + + describe('{search,filters}', () => { + it('includes filters and a sqs query', () => { + expect(getQueryParams(MAPPINGS, null, 'us*', null, [{ terms: { foo: ['bar', 'baz'] } }])) + .toEqual({ + query: { + bool: { + filter: [ + { terms: { foo: ['bar', 'baz'] } } + ], must: [ { simple_query_string: { @@ -136,13 +163,17 @@ describe('searchDsl/queryParams', () => { } }); }); - it('includes bool with sqs query and terms filter for type', () => { - expect(getQueryParams(MAPPINGS, ['saved', 'vis'], 'y*')) + }); + + describe('{type,search,filters}', () => { + it('includes bool with sqs query, filters and term filter for type', () => { + expect(getQueryParams(MAPPINGS, 'saved', 'y*', null, [{ terms: { foo: ['bar', 'baz'] } }])) .toEqual({ query: { bool: { filter: [ - { terms: { type: ['saved', 'vis'] } } + { terms: { foo: ['bar', 'baz'] } }, + { term: { type: 'saved' } } ], must: [ { @@ -164,6 +195,7 @@ describe('searchDsl/queryParams', () => { .toEqual({ query: { bool: { + filter: [], must: [ { simple_query_string: { @@ -184,6 +216,7 @@ describe('searchDsl/queryParams', () => { .toEqual({ query: { bool: { + filter: [], must: [ { simple_query_string: { @@ -204,6 +237,81 @@ describe('searchDsl/queryParams', () => { .toEqual({ query: { bool: { + filter: [], + must: [ + { + simple_query_string: { + query: 'y*', + fields: [ + 'pending.title', + 'saved.title', + 'pending.title.raw', + 'saved.title.raw', + ] + } + } + ] + } + } + }); + }); + }); + + describe('{search,searchFields,filters}', () => { + it('specifies filters and includes all types for field', () => { + expect(getQueryParams(MAPPINGS, null, 'y*', ['title'], [{ terms: { foo: ['bar', 'baz'] } }])) + .toEqual({ + query: { + bool: { + filter: [ + { terms: { foo: ['bar', 'baz'] } }, + ], + must: [ + { + simple_query_string: { + query: 'y*', + fields: [ + 'pending.title', + 'saved.title' + ] + } + } + ] + } + } + }); + }); + it('specifies filters and supports field boosting', () => { + expect(getQueryParams(MAPPINGS, null, 'y*', ['title^3'], [{ terms: { foo: ['bar', 'baz'] } }])) + .toEqual({ + query: { + bool: { + filter: [ + { terms: { foo: ['bar', 'baz'] } }, + ], + must: [ + { + simple_query_string: { + query: 'y*', + fields: [ + 'pending.title^3', + 'saved.title^3' + ] + } + } + ] + } + } + }); + }); + it('specifies filters and supports field and multi-field', () => { + expect(getQueryParams(MAPPINGS, null, 'y*', ['title', 'title.raw'], [{ terms: { foo: ['bar', 'baz'] } }])) + .toEqual({ + query: { + bool: { + filter: [ + { terms: { foo: ['bar', 'baz'] } }, + ], must: [ { simple_query_string: { @@ -224,7 +332,7 @@ describe('searchDsl/queryParams', () => { }); describe('{type,search,searchFields}', () => { - it('includes bool, with term filter and sqs with field list', () => { + it('includes bool, and sqs with field list', () => { expect(getQueryParams(MAPPINGS, 'saved', 'y*', ['title'])) .toEqual({ query: { @@ -246,13 +354,35 @@ describe('searchDsl/queryParams', () => { } }); }); - it('includes bool, with terms filter and sqs with field list', () => { - expect(getQueryParams(MAPPINGS, ['saved', 'vis'], 'y*', ['title'])) + it('supports fields pointing to multi-fields', () => { + expect(getQueryParams(MAPPINGS, 'saved', 'y*', ['title.raw'])) .toEqual({ query: { bool: { filter: [ - { terms: { type: ['saved', 'vis'] } } + { term: { type: 'saved' } } + ], + must: [ + { + simple_query_string: { + query: 'y*', + fields: [ + 'saved.title.raw' + ] + } + } + ] + } + } + }); + }); + it('supports multiple search fields', () => { + expect(getQueryParams(MAPPINGS, 'saved', 'y*', ['title', 'title.raw'])) + .toEqual({ + query: { + bool: { + filter: [ + { term: { type: 'saved' } } ], must: [ { @@ -260,7 +390,33 @@ describe('searchDsl/queryParams', () => { query: 'y*', fields: [ 'saved.title', - 'vis.title' + 'saved.title.raw' + ] + } + } + ] + } + } + }); + }); + }); + + describe('{type,search,searchFields,filters}', () => { + it('includes specified filters, type filter and sqs with field list', () => { + expect(getQueryParams(MAPPINGS, 'saved', 'y*', ['title'], [{ terms: { foo: ['bar', 'baz'] } }])) + .toEqual({ + query: { + bool: { + filter: [ + { terms: { foo: ['bar', 'baz'] } }, + { term: { type: 'saved' } } + ], + must: [ + { + simple_query_string: { + query: 'y*', + fields: [ + 'saved.title' ] } } @@ -270,11 +426,12 @@ describe('searchDsl/queryParams', () => { }); }); it('supports fields pointing to multi-fields', () => { - expect(getQueryParams(MAPPINGS, 'saved', 'y*', ['title.raw'])) + expect(getQueryParams(MAPPINGS, 'saved', 'y*', ['title.raw'], [{ terms: { foo: ['bar', 'baz'] } }])) .toEqual({ query: { bool: { filter: [ + { terms: { foo: ['bar', 'baz'] } }, { term: { type: 'saved' } } ], must: [ @@ -292,11 +449,12 @@ describe('searchDsl/queryParams', () => { }); }); it('supports multiple search fields', () => { - expect(getQueryParams(MAPPINGS, 'saved', 'y*', ['title', 'title.raw'])) + expect(getQueryParams(MAPPINGS, 'saved', 'y*', ['title', 'title.raw'], [{ terms: { foo: ['bar', 'baz'] } }])) .toEqual({ query: { bool: { filter: [ + { terms: { foo: ['bar', 'baz'] } }, { term: { type: 'saved' } } ], must: [ diff --git a/src/server/saved_objects/service/lib/search_dsl/search_dsl.js b/src/server/saved_objects/service/lib/search_dsl/search_dsl.js index ea34c127e9854..94f938c921ed0 100644 --- a/src/server/saved_objects/service/lib/search_dsl/search_dsl.js +++ b/src/server/saved_objects/service/lib/search_dsl/search_dsl.js @@ -28,7 +28,8 @@ export function getSearchDsl(mappings, options = {}) { search, searchFields, sortField, - sortOrder + sortOrder, + filters, } = options; if (!type && sortField) { @@ -39,8 +40,12 @@ export function getSearchDsl(mappings, options = {}) { throw Boom.notAcceptable('sortOrder requires a sortField'); } + if (filters && !Array.isArray(filters)) { + throw Boom.notAcceptable('filters must be an array'); + } + return { - ...getQueryParams(mappings, type, search, searchFields), + ...getQueryParams(mappings, type, search, searchFields, filters), ...getSortingParams(mappings, type, sortField, sortOrder), }; } diff --git a/src/server/saved_objects/service/lib/search_dsl/search_dsl.test.js b/src/server/saved_objects/service/lib/search_dsl/search_dsl.test.js index 85302b5e25722..6ed40a7929dcf 100644 --- a/src/server/saved_objects/service/lib/search_dsl/search_dsl.test.js +++ b/src/server/saved_objects/service/lib/search_dsl/search_dsl.test.js @@ -46,13 +46,14 @@ describe('getSearchDsl', () => { }); describe('passes control', () => { - it('passes (mappings, type, search, searchFields) to getQueryParams', () => { + it('passes (mappings, type, search, searchFields, filters) to getQueryParams', () => { const spy = sandbox.spy(queryParamsNS, 'getQueryParams'); const mappings = { type: { properties: {} } }; const opts = { type: 'foo', search: 'bar', searchFields: ['baz'], + filters: [{ bool: {} }], }; getSearchDsl(mappings, opts); @@ -63,6 +64,7 @@ describe('getSearchDsl', () => { opts.type, opts.search, opts.searchFields, + opts.filters, ); }); diff --git a/src/server/saved_objects/service/saved_objects_client.js b/src/server/saved_objects/service/saved_objects_client.js index 7741800fc2e27..1d69d55f9f4ae 100644 --- a/src/server/saved_objects/service/saved_objects_client.js +++ b/src/server/saved_objects/service/saved_objects_client.js @@ -102,6 +102,7 @@ export class SavedObjectsClient { * @param {object} [options={}] * @property {string} [options.id] - force id on creation, not recommended * @property {boolean} [options.overwrite=false] + * @property {object} [options.extraDocumentProperties={}] - extra properties to append to the document body, outside of the object's type property * @returns {promise} - { id, type, version, attributes } */ async create(type, attributes = {}, options = {}) { @@ -111,7 +112,7 @@ export class SavedObjectsClient { /** * Creates multiple documents at once * - * @param {array} objects - [{ type, id, attributes }] + * @param {array} objects - [{ type, id, attributes, extraDocumentProperties }] * @param {object} [options={}] * @property {boolean} [options.overwrite=false] - overwrites existing documents * @returns {promise} - { saved_objects: [{ id, type, version, attributes, error: { message } }]} @@ -137,6 +138,7 @@ export class SavedObjectsClient { * @property {string} [options.search] * @property {Array} [options.searchFields] - see Elasticsearch Simple Query String * Query field argument for more information + * @property {object} [options.filters] - ES Query filters to append * @property {integer} [options.page=1] * @property {integer} [options.perPage=20] * @property {string} [options.sortField] @@ -152,6 +154,8 @@ export class SavedObjectsClient { * Returns an array of objects by id * * @param {array} objects - an array ids, or an array of objects containing id and optionally type + * @param {object} [options = {}] + * @param {array} [options.extraSourceProperties = []] - an array of extra properties to return from the underlying document * @returns {promise} - { saved_objects: [{ id, type, version, attributes }] } * @example * @@ -160,8 +164,8 @@ export class SavedObjectsClient { * { id: 'foo', type: 'index-pattern' } * ]) */ - async bulkGet(objects = []) { - return this._repository.bulkGet(objects); + async bulkGet(objects = [], options = {}) { + return this._repository.bulkGet(objects, options); } /** @@ -169,10 +173,12 @@ export class SavedObjectsClient { * * @param {string} type * @param {string} id + * @param {object} [options = {}] + * @param {array} [options.extraSourceProperties = []] - an array of extra properties to return from the underlying document * @returns {promise} - { id, type, version, attributes } */ - async get(type, id) { - return this._repository.get(type, id); + async get(type, id, options = {}) { + return this._repository.get(type, id, options); } /** @@ -182,6 +188,7 @@ export class SavedObjectsClient { * @param {string} id * @param {object} [options={}] * @property {integer} options.version - ensures version matches that of persisted object + * @param {array} [options.extraDocumentProperties = {}] - an object of extra properties to write into the underlying document * @returns {promise} */ async update(type, id, attributes, options = {}) { diff --git a/src/server/saved_objects/service/saved_objects_client.test.js b/src/server/saved_objects/service/saved_objects_client.test.js index a7189f5a39898..56127023dd1fe 100644 --- a/src/server/saved_objects/service/saved_objects_client.test.js +++ b/src/server/saved_objects/service/saved_objects_client.test.js @@ -87,9 +87,10 @@ test(`#bulkGet`, async () => { const client = new SavedObjectsClient(mockRepository); const objects = {}; - const result = await client.bulkGet(objects); + const options = Symbol(); + const result = await client.bulkGet(objects, options); - expect(mockRepository.bulkGet).toHaveBeenCalledWith(objects); + expect(mockRepository.bulkGet).toHaveBeenCalledWith(objects, options); expect(result).toBe(returnValue); }); @@ -102,9 +103,10 @@ test(`#get`, async () => { const type = 'foo'; const id = 1; - const result = await client.get(type, id); + const options = Symbol(); + const result = await client.get(type, id, options); - expect(mockRepository.get).toHaveBeenCalledWith(type, id); + expect(mockRepository.get).toHaveBeenCalledWith(type, id, options); expect(result).toBe(returnValue); }); diff --git a/src/ui/ui_settings/create_or_upgrade_saved_config/__tests__/create_or_upgrade_integration.js b/src/ui/ui_settings/create_or_upgrade_saved_config/__tests__/create_or_upgrade_integration.js index 2fb7a325d61c1..e5ce9b9ea3b70 100644 --- a/src/ui/ui_settings/create_or_upgrade_saved_config/__tests__/create_or_upgrade_integration.js +++ b/src/ui/ui_settings/create_or_upgrade_saved_config/__tests__/create_or_upgrade_integration.js @@ -224,4 +224,4 @@ describe('createOrUpgradeSavedConfig()', () => { '5.4.0-rc1': true, }); }); -}); +}); \ No newline at end of file diff --git a/src/ui/ui_settings/create_or_upgrade_saved_config/__tests__/create_or_upgrade_saved_config.js b/src/ui/ui_settings/create_or_upgrade_saved_config/__tests__/create_or_upgrade_saved_config.js index e25002e9c9c1c..b0b823800a660 100644 --- a/src/ui/ui_settings/create_or_upgrade_saved_config/__tests__/create_or_upgrade_saved_config.js +++ b/src/ui/ui_settings/create_or_upgrade_saved_config/__tests__/create_or_upgrade_saved_config.js @@ -135,4 +135,4 @@ describe('uiSettings/createOrUpgradeSavedConfig', function () { ); }); }); -}); +}); \ No newline at end of file diff --git a/src/ui/ui_settings/create_or_upgrade_saved_config/create_or_upgrade_saved_config.js b/src/ui/ui_settings/create_or_upgrade_saved_config/create_or_upgrade_saved_config.js index bf3a49fcbdc3e..4bd4d8e86b73a 100644 --- a/src/ui/ui_settings/create_or_upgrade_saved_config/create_or_upgrade_saved_config.js +++ b/src/ui/ui_settings/create_or_upgrade_saved_config/create_or_upgrade_saved_config.js @@ -55,4 +55,4 @@ export async function createOrUpgradeSavedConfig(options) { attributes, { id: version } ); -} +} \ No newline at end of file diff --git a/src/ui/ui_settings/ui_settings_service.js b/src/ui/ui_settings/ui_settings_service.js index bf9b12133999a..c6a65ce2f0501 100644 --- a/src/ui/ui_settings/ui_settings_service.js +++ b/src/ui/ui_settings/ui_settings_service.js @@ -167,4 +167,4 @@ export class UiSettingsService { throw error; } } -} +} \ No newline at end of file diff --git a/src/ui/ui_settings/ui_settings_service_factory.js b/src/ui/ui_settings/ui_settings_service_factory.js index 4b9859abf6bdb..1eae614650262 100644 --- a/src/ui/ui_settings/ui_settings_service_factory.js +++ b/src/ui/ui_settings/ui_settings_service_factory.js @@ -47,4 +47,4 @@ export function uiSettingsServiceFactory(server, options) { getDefaults, log: (...args) => server.log(...args), }); -} +} \ No newline at end of file diff --git a/x-pack/plugins/security/index.js b/x-pack/plugins/security/index.js index b86489171ff26..b9301a8666149 100644 --- a/x-pack/plugins/security/index.js +++ b/x-pack/plugins/security/index.js @@ -21,7 +21,7 @@ import { SecurityAuditLogger } from './server/lib/audit_logger'; import { AuditLogger } from '../../server/lib/audit_logger'; import { SecureSavedObjectsClient } from './server/lib/saved_objects_client/secure_saved_objects_client'; import { initAuthorizationService, registerPrivilegesWithCluster } from './server/lib/authorization'; -import { watchStatusAndLicenseToInitialize } from './server/lib/watch_status_and_license_to_initialize'; +import { watchStatusAndLicenseToInitialize } from '../../server/lib/watch_status_and_license_to_initialize'; export const security = (kibana) => new kibana.Plugin({ id: 'security', diff --git a/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.js b/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.js index eb19f59c29693..f246088f87cdc 100644 --- a/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.js +++ b/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.js @@ -64,22 +64,22 @@ export class SecureSavedObjectsClient { return await this._findAcrossAllTypes(options); } - async bulkGet(objects = []) { + async bulkGet(objects = [], options = {}) { const types = uniq(objects.map(o => o.type)); return await this._execute( types, 'bulk_get', - { objects }, - repository => repository.bulkGet(objects) + { objects, options }, + repository => repository.bulkGet(objects, options) ); } - async get(type, id) { + async get(type, id, options = {}) { return await this._execute( type, 'get', - { type, id }, - repository => repository.get(type, id) + { type, id, options }, + repository => repository.get(type, id, options) ); } @@ -134,7 +134,7 @@ export class SecureSavedObjectsClient { } const authorizedTypes = Array.from(typesToPrivilegesMap.entries()) - .filter(([ , privilege]) => !missing.includes(privilege)) + .filter(([, privilege]) => !missing.includes(privilege)) .map(([type]) => type); if (authorizedTypes.length === 0) { diff --git a/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.test.js b/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.test.js index 99656772c0df7..e9ac730f5831b 100644 --- a/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.test.js +++ b/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.test.js @@ -508,6 +508,7 @@ describe('#find', () => { ], }; }); + const mockAuditLogger = createMockAuditLogger(); const mockActions = createMockActions(); const client = new SecureSavedObjectsClient({ @@ -516,7 +517,7 @@ describe('#find', () => { auditLogger: mockAuditLogger, actions: mockActions, }); - const options = { type: [ type1, type2 ] }; + const options = { type: [type1, type2] }; await expect(client.find(options)).rejects.toThrowError(mockErrors.forbiddenError); @@ -820,8 +821,9 @@ describe('#bulkGet', () => { { type: type1 }, { type: type2 }, ]; + const options = Symbol(); - await expect(client.bulkGet(objects)).rejects.toThrowError(mockErrors.forbiddenError); + await expect(client.bulkGet(objects, options)).rejects.toThrowError(mockErrors.forbiddenError); expect(mockCheckPrivileges).toHaveBeenCalledWith([ mockActions.getSavedObjectAction(type1, 'bulk_get'), @@ -834,7 +836,8 @@ describe('#bulkGet', () => { [type1, type2], [mockActions.getSavedObjectAction(type1, 'bulk_get')], { - objects + objects, + options, } ); expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); @@ -863,14 +866,16 @@ describe('#bulkGet', () => { { type: type1, id: 'foo-id' }, { type: type2, id: 'bar-id' }, ]; + const options = Symbol(); - const result = await client.bulkGet(objects); + const result = await client.bulkGet(objects, options); expect(result).toBe(returnValue); - expect(mockRepository.bulkGet).toHaveBeenCalledWith(objects); + expect(mockRepository.bulkGet).toHaveBeenCalledWith(objects, options); expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'bulk_get', [type1, type2], { objects, + options, }); }); @@ -898,11 +903,12 @@ describe('#bulkGet', () => { { type: type1, id: 'foo-id' }, { type: type2, id: 'bar-id' }, ]; + const options = Symbol(); - const result = await client.bulkGet(objects); + const result = await client.bulkGet(objects, options); expect(result).toBe(returnValue); - expect(mockRepository.bulkGet).toHaveBeenCalledWith(objects); + expect(mockRepository.bulkGet).toHaveBeenCalledWith(objects, options); expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); }); @@ -950,8 +956,9 @@ describe('#get', () => { actions: mockActions, }); const id = Symbol(); + const options = Symbol(); - await expect(client.get(type, id)).rejects.toThrowError(mockErrors.forbiddenError); + await expect(client.get(type, id, options)).rejects.toThrowError(mockErrors.forbiddenError); expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.getSavedObjectAction(type, 'get')]); expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); @@ -963,6 +970,7 @@ describe('#get', () => { { type, id, + options, } ); expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); @@ -987,15 +995,17 @@ describe('#get', () => { actions: createMockActions(), }); const id = Symbol(); + const options = Symbol(); - const result = await client.get(type, id); + const result = await client.get(type, id, options); expect(result).toBe(returnValue); - expect(mockRepository.get).toHaveBeenCalledWith(type, id); + expect(mockRepository.get).toHaveBeenCalledWith(type, id, options); expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'get', [type], { type, id, + options }); }); @@ -1019,11 +1029,12 @@ describe('#get', () => { actions: createMockActions(), }); const id = Symbol(); + const options = Symbol(); - const result = await client.get(type, id); + const result = await client.get(type, id, options); expect(result).toBe(returnValue); - expect(mockRepository.get).toHaveBeenCalledWith(type, id); + expect(mockRepository.get).toHaveBeenCalledWith(type, id, options); expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); }); diff --git a/x-pack/plugins/spaces/common/spaces_url_parser.js b/x-pack/plugins/spaces/common/spaces_url_parser.js deleted file mode 100644 index 075f09eea03a3..0000000000000 --- a/x-pack/plugins/spaces/common/spaces_url_parser.js +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -export function getSpaceUrlContext(basePath = '/', defaultContext = null) { - // Look for `/s/space-url-context` in the base path - const matchResult = basePath.match(/\/s\/([a-z0-9\-]+)/); - - if (!matchResult || matchResult.length === 0) { - return defaultContext; - } - - // Ignoring first result, we only want the capture group result at index 1 - const [, urlContext = defaultContext] = matchResult; - - return urlContext; -} - -export function stripSpaceUrlContext(basePath = '/') { - const currentSpaceUrlContext = getSpaceUrlContext(basePath); - - let basePathWithoutSpace; - if (currentSpaceUrlContext) { - const indexOfSpaceContext = basePath.indexOf(`/s/${currentSpaceUrlContext}`); - - const startsWithSpace = indexOfSpaceContext === 0; - - if (startsWithSpace) { - basePathWithoutSpace = '/'; - } else { - basePathWithoutSpace = basePath.substring(0, indexOfSpaceContext); - } - } else { - basePathWithoutSpace = basePath; - } - - if (basePathWithoutSpace.endsWith('/')) { - return basePathWithoutSpace.substr(0, -1); - } - - return basePathWithoutSpace; -} diff --git a/x-pack/plugins/spaces/common/spaces_url_parser.test.js b/x-pack/plugins/spaces/common/spaces_url_parser.test.js deleted file mode 100644 index ee0813b2f70ac..0000000000000 --- a/x-pack/plugins/spaces/common/spaces_url_parser.test.js +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { stripSpaceUrlContext, getSpaceUrlContext } from './spaces_url_parser'; - -test('it removes the space url context from the base path when the space is not at the root', () => { - const basePath = `/foo/s/my-space`; - expect(stripSpaceUrlContext(basePath)).toEqual('/foo'); -}); - -test('it removes the space url context from the base path when the space is the root', () => { - const basePath = `/s/my-space`; - expect(stripSpaceUrlContext(basePath)).toEqual(''); -}); - -test(`it doesn't change base paths without a space url context`, () => { - const basePath = `/this/is/a-base-path/ok`; - expect(stripSpaceUrlContext(basePath)).toEqual(basePath); -}); - -test('it accepts no parameters', () => { - expect(stripSpaceUrlContext()).toEqual(''); -}); - -test('it remove the trailing slash', () => { - expect(stripSpaceUrlContext('/')).toEqual(''); -}); - -test('it identifies the space url context', () => { - const basePath = `/this/is/a/crazy/path/s/my-awesome-space-lives-here`; - expect(getSpaceUrlContext(basePath)).toEqual('my-awesome-space-lives-here'); -}); - -test('it handles base url without a space url context', () => { - const basePath = `/this/is/a/crazy/path/s`; - expect(getSpaceUrlContext(basePath)).toEqual(null); -}); diff --git a/x-pack/plugins/spaces/index.js b/x-pack/plugins/spaces/index.js index 38d2e84cc992f..480641cc792f2 100644 --- a/x-pack/plugins/spaces/index.js +++ b/x-pack/plugins/spaces/index.js @@ -10,10 +10,12 @@ import { checkLicense } from './server/lib/check_license'; import { initSpacesApi } from './server/routes/api/v1/spaces'; import { initSpacesRequestInterceptors } from './server/lib/space_request_interceptors'; import { createDefaultSpace } from './server/lib/create_default_space'; -import { mirrorPluginStatus } from '../../server/lib/mirror_plugin_status'; +import { createSpacesService } from './server/lib/create_spaces_service'; import { getActiveSpace } from './server/lib/get_active_space'; import { wrapError } from './server/lib/errors'; import mappings from './mappings.json'; +import { spacesSavedObjectsClientWrapperFactory } from './server/lib/saved_objects_client/saved_objects_client_wrapper_factory'; +import { watchStatusAndLicenseToInitialize } from '../../server/lib/watch_status_and_license_to_initialize'; export const spaces = (kibana) => new kibana.Plugin({ id: 'spaces', @@ -46,11 +48,11 @@ export const spaces = (kibana) => new kibana.Plugin({ activeSpace: null }; }, - replaceInjectedVars: async function (vars, request) { + replaceInjectedVars: async function (vars, request, server) { try { vars.activeSpace = { valid: true, - space: await getActiveSpace(request.getSavedObjectsClient(), request.getBasePath()) + space: await getActiveSpace(request.getSavedObjectsClient(), request.getBasePath(), server.config().get('server.basePath')) }; } catch (e) { vars.activeSpace = { @@ -65,7 +67,10 @@ export const spaces = (kibana) => new kibana.Plugin({ async init(server) { const thisPlugin = this; const xpackMainPlugin = server.plugins.xpack_main; - mirrorPluginStatus(xpackMainPlugin, thisPlugin); + + watchStatusAndLicenseToInitialize(xpackMainPlugin, thisPlugin, async () => { + await createDefaultSpace(server); + }); // Register a function that is called whenever the xpack info changes, // to re-compute the license check results for this plugin @@ -74,10 +79,16 @@ export const spaces = (kibana) => new kibana.Plugin({ const config = server.config(); validateConfig(config, message => server.log(['spaces', 'warning'], message)); + const spacesService = createSpacesService(server); + server.decorate('server', 'spaces', spacesService); + + const { addScopedSavedObjectsClientWrapperFactory, types } = server.savedObjects; + addScopedSavedObjectsClientWrapperFactory( + spacesSavedObjectsClientWrapperFactory(spacesService, types) + ); + initSpacesApi(server); initSpacesRequestInterceptors(server); - - await createDefaultSpace(server); } }); diff --git a/x-pack/plugins/spaces/public/lib/spaces_manager.js b/x-pack/plugins/spaces/public/lib/spaces_manager.js index 475f8c1b03b72..7d0243dc038c4 100644 --- a/x-pack/plugins/spaces/public/lib/spaces_manager.js +++ b/x-pack/plugins/spaces/public/lib/spaces_manager.js @@ -3,6 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import { toastNotifications } from 'ui/notify'; import { EventEmitter } from 'events'; @@ -39,7 +40,27 @@ export class SpacesManager extends EventEmitter { .delete(`${this._baseUrl}/space/${space.id}`); } + async changeSelectedSpace(space) { + return await this._httpAgent + .post(`${this._baseUrl}/space/${space.id}/select`) + .then(response => { + if (response.data && response.data.location) { + window.location = response.data.location; + } else { + this._displayError(); + } + }) + .catch(() => this._displayError()); + } + async requestRefresh() { this.emit('request_refresh'); } + + _displayError() { + toastNotifications.addDanger({ + title: 'Unable to change your Space', + text: 'please try again later' + }); + } } diff --git a/x-pack/plugins/spaces/public/views/components/__snapshots__/space_avatar.test.js.snap b/x-pack/plugins/spaces/public/views/components/__snapshots__/space_avatar.test.js.snap new file mode 100644 index 0000000000000..14a898d7e66d6 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/components/__snapshots__/space_avatar.test.js.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders without crashing 1`] = ` + +`; diff --git a/x-pack/plugins/spaces/public/views/components/space_avatar.test.js b/x-pack/plugins/spaces/public/views/components/space_avatar.test.js new file mode 100644 index 0000000000000..d63d3b393940d --- /dev/null +++ b/x-pack/plugins/spaces/public/views/components/space_avatar.test.js @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; +import { SpaceAvatar } from './space_avatar'; + +test('renders without crashing', () => { + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); +}); diff --git a/x-pack/plugins/spaces/public/views/components/space_cards.js b/x-pack/plugins/spaces/public/views/components/space_cards.js index 5e96bc50cc4e2..7a96485d7c175 100644 --- a/x-pack/plugins/spaces/public/views/components/space_cards.js +++ b/x-pack/plugins/spaces/public/views/components/space_cards.js @@ -6,9 +6,7 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; -import chrome from 'ui/chrome'; import { SpaceCard } from './space_card'; -import { stripSpaceUrlContext } from '../../../common/spaces_url_parser'; import { EuiFlexGroup, EuiFlexItem, @@ -34,13 +32,12 @@ export class SpaceCards extends Component { createSpaceClickHandler = (space) => { return () => { - const baseUrlWithoutSpace = stripSpaceUrlContext(chrome.getBasePath()); - - window.location = `${baseUrlWithoutSpace}/s/${space.urlContext}`; + this.props.onSpaceSelect(space); }; }; } SpaceCards.propTypes = { spaces: PropTypes.array.isRequired, + onSpaceSelect: PropTypes.func.isRequired, }; diff --git a/x-pack/plugins/spaces/public/views/components/space_cards.test.js b/x-pack/plugins/spaces/public/views/components/space_cards.test.js index e970c37996036..0a7fdd79d2059 100644 --- a/x-pack/plugins/spaces/public/views/components/space_cards.test.js +++ b/x-pack/plugins/spaces/public/views/components/space_cards.test.js @@ -15,5 +15,5 @@ test('it renders without crashing', () => { description: 'space description' }; - shallow(); -}); \ No newline at end of file + shallow(); +}); diff --git a/x-pack/plugins/spaces/public/views/nav_control/nav_control_modal.js b/x-pack/plugins/spaces/public/views/nav_control/nav_control_modal.js index 5157621d2727e..6ef9b338645ec 100644 --- a/x-pack/plugins/spaces/public/views/nav_control/nav_control_modal.js +++ b/x-pack/plugins/spaces/public/views/nav_control/nav_control_modal.js @@ -155,7 +155,7 @@ export class NavControlModal extends Component { {callout} - + ); @@ -177,6 +177,10 @@ export class NavControlModal extends Component { isOpen: false }); } + + onSelectSpace = (space) => { + this.props.spacesManager.changeSelectedSpace(space); + } } NavControlModal.propTypes = { diff --git a/x-pack/plugins/spaces/public/views/space_selector/__snapshots__/space_selector.test.js.snap b/x-pack/plugins/spaces/public/views/space_selector/__snapshots__/space_selector.test.js.snap index a9a69385339a2..debc9de08283d 100644 --- a/x-pack/plugins/spaces/public/views/space_selector/__snapshots__/space_selector.test.js.snap +++ b/x-pack/plugins/spaces/public/views/space_selector/__snapshots__/space_selector.test.js.snap @@ -6,61 +6,61 @@ exports[`it renders without crashing 1`] = ` style={undefined} >
- + + + + + + +
+
+ - - - - - +

+ Select your space +

+
-
- -

- Select your space -

-
-
-
@@ -98,11 +98,11 @@ exports[`it renders without crashing 1`] = `
- No spaces match search criteria - +
diff --git a/x-pack/plugins/spaces/public/views/space_selector/space_selector.js b/x-pack/plugins/spaces/public/views/space_selector/space_selector.js index 57fd8d8e4b488..b0ac621ce845d 100644 --- a/x-pack/plugins/spaces/public/views/space_selector/space_selector.js +++ b/x-pack/plugins/spaces/public/views/space_selector/space_selector.js @@ -66,7 +66,7 @@ export class SpaceSelector extends Component { let filteredSpaces = spaces; if (searchTerm) { filteredSpaces = spaces - .filter(s => s.name.toLowerCase().indexOf(searchTerm) >= 0 || s.description.toLowerCase().indexOf(searchTerm) >= 0); + .filter(space => space.name.toLowerCase().indexOf(searchTerm) >= 0 || space.description.toLowerCase().indexOf(searchTerm) >= 0); } return ( @@ -97,7 +97,7 @@ export class SpaceSelector extends Component { - + { filteredSpaces.length === 0 && @@ -134,6 +134,10 @@ export class SpaceSelector extends Component { searchTerm: searchTerm.trim().toLowerCase() }); } + + onSelectSpace = (space) => { + this.props.spacesManager.changeSelectedSpace(space); + } } SpaceSelector.propTypes = { diff --git a/x-pack/plugins/spaces/server/lib/__snapshots__/spaces_url_parser.test.js.snap b/x-pack/plugins/spaces/server/lib/__snapshots__/spaces_url_parser.test.js.snap new file mode 100644 index 0000000000000..a42d029097b67 --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/__snapshots__/spaces_url_parser.test.js.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`addSpaceUrlContext it throws an error when the requested path does not start with a slash 1`] = `"path must start with a /"`; diff --git a/x-pack/plugins/spaces/server/lib/create_spaces_service.js b/x-pack/plugins/spaces/server/lib/create_spaces_service.js new file mode 100644 index 0000000000000..d393a6937ef50 --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/create_spaces_service.js @@ -0,0 +1,35 @@ +/* + * 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 { getSpaceUrlContext } from './spaces_url_parser'; + +export function createSpacesService(server) { + + const serverBasePath = server.config().get('server.basePath'); + + const contextCache = new WeakMap(); + + function getUrlContext(request) { + if (!contextCache.has(request)) { + populateCache(request); + } + + const { urlContext } = contextCache.get(request); + return urlContext; + } + + function populateCache(request) { + const urlContext = getSpaceUrlContext(request.getBasePath(), serverBasePath); + + contextCache.set(request, { + urlContext + }); + } + + return { + getUrlContext, + }; +} diff --git a/x-pack/plugins/spaces/server/lib/create_spaces_service.test.js b/x-pack/plugins/spaces/server/lib/create_spaces_service.test.js new file mode 100644 index 0000000000000..578a161229c41 --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/create_spaces_service.test.js @@ -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 { createSpacesService } from "./create_spaces_service"; + +const createRequest = (urlContext, serverBasePath = '') => ({ + getBasePath: () => urlContext ? `${serverBasePath}/s/${urlContext}` : serverBasePath +}); + +const createMockServer = (config) => { + return { + config: jest.fn(() => { + return { + get: jest.fn((key) => { + return config[key]; + }) + }; + }) + }; +}; + +test('returns empty string for the default space', () => { + const server = createMockServer({ + 'server.basePath': '' + }); + + const service = createSpacesService(server); + expect(service.getUrlContext(createRequest())).toEqual(''); +}); + +test('returns the urlContext for the current space', () => { + const request = createRequest('my-space-context'); + const server = createMockServer({ + 'server.basePath': '' + }); + + const service = createSpacesService(server); + expect(service.getUrlContext(request)).toEqual('my-space-context'); +}); + +test(`returns the urlContext for the current space when a server basepath is defined`, () => { + const request = createRequest('my-space-context', '/foo'); + const server = createMockServer({ + 'server.basePath': '/foo' + }); + + const service = createSpacesService(server); + expect(service.getUrlContext(request)).toEqual('my-space-context'); +}); diff --git a/x-pack/plugins/spaces/server/lib/get_active_space.js b/x-pack/plugins/spaces/server/lib/get_active_space.js index c57957b1a6f93..834682228eaa4 100644 --- a/x-pack/plugins/spaces/server/lib/get_active_space.js +++ b/x-pack/plugins/spaces/server/lib/get_active_space.js @@ -6,11 +6,11 @@ import Boom from 'boom'; import { wrapError } from './errors'; -import { getSpaceUrlContext } from '../../common/spaces_url_parser'; +import { getSpaceUrlContext } from './spaces_url_parser'; import { DEFAULT_SPACE_ID } from '../../common/constants'; -export async function getActiveSpace(savedObjectsClient, basePath) { - const spaceContext = getSpaceUrlContext(basePath); +export async function getActiveSpace(savedObjectsClient, requestBasePath, serverBasePath) { + const spaceContext = getSpaceUrlContext(requestBasePath, serverBasePath); let space; diff --git a/x-pack/plugins/spaces/server/lib/saved_objects_client/__snapshots__/spaces_saved_objects_client.test.js.snap b/x-pack/plugins/spaces/server/lib/saved_objects_client/__snapshots__/spaces_saved_objects_client.test.js.snap new file mode 100644 index 0000000000000..861b170685f99 --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/saved_objects_client/__snapshots__/spaces_saved_objects_client.test.js.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`current space (space_1) #create #delete does not allow an object to be deleted via a different space 1`] = `"not found"`; + +exports[`current space (space_1) #create #update does not allow an object to be updated via a different space 1`] = `"not found"`; + +exports[`current space (space_1) #get returns error when the object belongs to a different space 1`] = `"not found"`; + +exports[`default space #delete does not allow an object to be deleted via a different space 1`] = `"not found"`; + +exports[`default space #get returns error when the object belongs to a different space 1`] = `"not found"`; + +exports[`default space #update does not allow an object to be updated via a different space 1`] = `"not found"`; diff --git a/x-pack/plugins/spaces/server/lib/saved_objects_client/lib/__snapshots__/query_filters.test.js.snap b/x-pack/plugins/spaces/server/lib/saved_objects_client/lib/__snapshots__/query_filters.test.js.snap new file mode 100644 index 0000000000000..d3b95886e8353 --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/saved_objects_client/lib/__snapshots__/query_filters.test.js.snap @@ -0,0 +1,5 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`throws if types contains an empty entry 1`] = `"type is required to build filter clause"`; + +exports[`throws when no types are provided 1`] = `"At least one type must be provided to \\"getSpacesQueryFilters\\""`; diff --git a/x-pack/plugins/spaces/server/lib/saved_objects_client/lib/is_type_space_aware.js b/x-pack/plugins/spaces/server/lib/saved_objects_client/lib/is_type_space_aware.js new file mode 100644 index 0000000000000..720fb7e28e117 --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/saved_objects_client/lib/is_type_space_aware.js @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/** + * Returns if the provided Saved Object type is "space aware". + * Most types should be space-aware, and those that aren't should typically strive to become space-aware. + * Types that are not space-aware will appear in every space, and are not bound by any space-specific access controls. + */ +export function isTypeSpaceAware(type) { + return type !== 'space' && type !== 'config'; +} diff --git a/x-pack/plugins/spaces/server/lib/saved_objects_client/lib/is_type_space_aware.test.js b/x-pack/plugins/spaces/server/lib/saved_objects_client/lib/is_type_space_aware.test.js new file mode 100644 index 0000000000000..a88d4c35125f7 --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/saved_objects_client/lib/is_type_space_aware.test.js @@ -0,0 +1,29 @@ +/* + * 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 { isTypeSpaceAware } from "./is_type_space_aware"; + +const knownSpaceAwareTypes = [ + 'dashboard', + 'visualization', + 'saved_search', + 'timelion_sheet', + 'index_pattern' +]; + +const unawareTypes = ['space']; + +knownSpaceAwareTypes.forEach(type => test(`${type} should be space-aware`, () => { + expect(isTypeSpaceAware(type)).toBe(true); +})); + +unawareTypes.forEach(type => test(`${type} should not be space-aware`, () => { + expect(isTypeSpaceAware(type)).toBe(false); +})); + +test(`unknown types should default to space-aware`, () => { + expect(isTypeSpaceAware('an-unknown-type')).toBe(true); +}); diff --git a/x-pack/plugins/spaces/server/lib/saved_objects_client/lib/query_filters.js b/x-pack/plugins/spaces/server/lib/saved_objects_client/lib/query_filters.js new file mode 100644 index 0000000000000..cf1754c056c3f --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/saved_objects_client/lib/query_filters.js @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { DEFAULT_SPACE_ID } from "../../../../common/constants"; +import { isTypeSpaceAware } from "./is_type_space_aware"; + +function getClauseForType(spaceId, type) { + const shouldFilterOnSpace = isTypeSpaceAware(type) && spaceId; + const isDefaultSpace = spaceId === DEFAULT_SPACE_ID; + + const bool = { + must: [] + }; + + if (!type) { + throw new Error(`type is required to build filter clause`); + } + + bool.must.push({ + term: { + type + } + }); + + if (shouldFilterOnSpace) { + if (isDefaultSpace) { + // The default space does not add its spaceId to the objects that belong to it, in order + // to be compatible with installations that are not always space-aware. + bool.must_not = [{ + exists: { + field: "spaceId" + } + }]; + } else { + bool.must.push({ + term: { + spaceId + } + }); + } + } + + return { + bool + }; +} + +export function getSpacesQueryFilters(spaceId, types = []) { + if (types.length === 0) { + throw new Error(`At least one type must be provided to "getSpacesQueryFilters"`); + } + + const filters = []; + + const typeClauses = types.map((type) => getClauseForType(spaceId, type)); + + if (typeClauses.length > 0) { + filters.push({ + bool: { + should: typeClauses, + minimum_should_match: 1 + } + }); + } + + return filters; +} diff --git a/x-pack/plugins/spaces/server/lib/saved_objects_client/lib/query_filters.test.js b/x-pack/plugins/spaces/server/lib/saved_objects_client/lib/query_filters.test.js new file mode 100644 index 0000000000000..a7bcacc524caa --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/saved_objects_client/lib/query_filters.test.js @@ -0,0 +1,139 @@ +/* + * 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 { getSpacesQueryFilters } from './query_filters'; + +test('throws when no types are provided', () => { + expect(() => getSpacesQueryFilters('space_1', [])).toThrowErrorMatchingSnapshot(); +}); + +test('throws if types contains an empty entry', () => { + expect(() => getSpacesQueryFilters('space_1', ['dashboard', ''])).toThrowErrorMatchingSnapshot(); +}); + +test('creates a query that filters on type, but not on space, for types that are not space-aware', () => { + const spaceId = 'space_1'; + const type = 'space'; + + const expectedTypeClause = { + bool: { + must: [{ + term: { + type + } + }] + } + }; + expect(getSpacesQueryFilters(spaceId, [type])).toEqual([{ + bool: { + should: [expectedTypeClause], + minimum_should_match: 1 + } + }]); +}); + +test('creates a query that restricts a space-aware type to the provided space (space_1)', () => { + const spaceId = 'space_1'; + const type = 'dashboard'; + + const expectedTypeClause = { + bool: { + must: [{ + term: { + type + } + }, { + term: { + spaceId + } + }] + } + }; + + expect(getSpacesQueryFilters(spaceId, [type])).toEqual([{ + bool: { + should: [expectedTypeClause], + minimum_should_match: 1 + } + }]); +}); + +test('creates a query that restricts a space-aware type to the provided space (default)', () => { + const spaceId = 'default'; + const type = 'dashboard'; + + const expectedTypeClause = { + bool: { + must: [{ + term: { + type + } + }], + // The default space does not add its spaceId to the objects that belong to it, in order + // to be compatible with installations that are not always space-aware. + must_not: [{ + exists: { + field: 'spaceId' + } + }] + } + }; + + expect(getSpacesQueryFilters(spaceId, [type])).toEqual([{ + bool: { + should: [expectedTypeClause], + minimum_should_match: 1 + } + }]); +}); + +test('creates a query supporting a find operation on multiple types', () => { + const spaceId = 'space_1'; + const types = [ + 'dashboard', + 'space', + 'visualization', + ]; + + const expectedSpaceClause = { + term: { + spaceId + } + }; + + const expectedTypeClauses = [{ + bool: { + must: [{ + term: { + type: 'dashboard' + } + }, expectedSpaceClause] + } + }, { + bool: { + must: [{ + term: { + type: 'space' + } + }] + } + }, { + bool: { + must: [{ + term: { + type: 'visualization' + } + }, expectedSpaceClause] + } + }]; + + expect(getSpacesQueryFilters(spaceId, types)).toEqual([{ + bool: { + should: expectedTypeClauses, + minimum_should_match: 1 + } + }]); +}); diff --git a/x-pack/plugins/spaces/server/lib/saved_objects_client/saved_objects_client_wrapper_factory.js b/x-pack/plugins/spaces/server/lib/saved_objects_client/saved_objects_client_wrapper_factory.js new file mode 100644 index 0000000000000..da7a448d86d2d --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/saved_objects_client/saved_objects_client_wrapper_factory.js @@ -0,0 +1,16 @@ +/* + * 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 { SpacesSavedObjectsClient } from './spaces_saved_objects_client'; + +export function spacesSavedObjectsClientWrapperFactory(spacesService, types) { + return ({ client, request }) => new SpacesSavedObjectsClient({ + baseClient: client, + request, + spacesService, + types, + }); +} diff --git a/x-pack/plugins/spaces/server/lib/saved_objects_client/spaces_saved_objects_client.js b/x-pack/plugins/spaces/server/lib/saved_objects_client/spaces_saved_objects_client.js new file mode 100644 index 0000000000000..28471c3d928ba --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/saved_objects_client/spaces_saved_objects_client.js @@ -0,0 +1,282 @@ +/* + * 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 { DEFAULT_SPACE_ID } from '../../../common/constants'; +import { isTypeSpaceAware } from './lib/is_type_space_aware'; +import { getSpacesQueryFilters } from './lib/query_filters'; +import uniq from 'lodash'; + +export class SpacesSavedObjectsClient { + constructor(options) { + const { + request, + baseClient, + spacesService, + types, + } = options; + + this.errors = baseClient.errors; + + this._client = baseClient; + this._types = types; + + this._spaceUrlContext = spacesService.getUrlContext(request); + } + + /** + * Persists an object + * + * @param {string} type + * @param {object} attributes + * @param {object} [options={}] + * @property {string} [options.id] - force id on creation, not recommended + * @property {boolean} [options.overwrite=false] + * @property {object} [options.extraDocumentProperties={}] - extra properties to append to the document body, outside of the object's type property + * @returns {promise} - { id, type, version, attributes } + */ + async create(type, attributes = {}, options = {}) { + + const spaceId = await this._getSpaceId(); + + const createOptions = { + ...options, + extraDocumentProperties: { + ...options.extraDocumentProperties + } + }; + + if (this._shouldAssignSpaceId(type, spaceId)) { + createOptions.extraDocumentProperties.spaceId = spaceId; + } else { + delete createOptions.extraDocumentProperties.spaceId; + } + + return await this._client.create(type, attributes, createOptions); + } + + /** + * Creates multiple documents at once + * + * @param {array} objects - [{ type, id, attributes, extraDocumentProperties }] + * @param {object} [options={}] + * @property {boolean} [options.overwrite=false] - overwrites existing documents + * @returns {promise} - { saved_objects: [{ id, type, version, attributes, error: { message } }]} + */ + async bulkCreate(objects, options = {}) { + const spaceId = await this._getSpaceId(); + const objectsToCreate = objects.map(object => { + + const objectToCreate = { + ...object, + extraDocumentProperties: { + ...object.extraDocumentProperties + } + }; + + if (this._shouldAssignSpaceId(object.type, spaceId)) { + objectToCreate.extraDocumentProperties.spaceId = spaceId; + } else { + delete objectToCreate.extraDocumentProperties.spaceId; + } + + return objectToCreate; + }); + + return await this._client.bulkCreate(objectsToCreate, options); + } + + /** + * Deletes an object + * + * @param {string} type + * @param {string} id + * @returns {promise} + */ + async delete(type, id) { + // attempt to retrieve document before deleting. + // this ensures that the document belongs to the current space. + await this.get(type, id); + + return await this._client.delete(type, id); + } + + /** + * @param {object} [options={}] + * @property {(string|Array)} [options.type] + * @property {string} [options.search] + * @property {Array} [options.searchFields] - see Elasticsearch Simple Query String + * Query field argument for more information + * @property {object} [options.filters] - ES Query filters to append + * @property {integer} [options.page=1] + * @property {integer} [options.perPage=20] + * @property {string} [options.sortField] + * @property {string} [options.sortOrder] + * @property {Array} [options.fields] + * @returns {promise} - { saved_objects: [{ id, type, version, attributes }], total, per_page, page } + */ + async find(options = {}) { + const spaceOptions = {}; + + let types = options.type || this._types; + if (!Array.isArray(types)) { + types = [types]; + } + + const filters = options.filters || []; + + const spaceId = await this._getSpaceId(); + + spaceOptions.filters = [...filters, ...getSpacesQueryFilters(spaceId, types)]; + + return await this._client.find({ ...options, ...spaceOptions }); + } + + /** + * Returns an array of objects by id + * + * @param {array} objects - an array ids, or an array of objects containing id and optionally type + * @param {object} [options = {}] + * @param {array} [options.extraSourceProperties = []] - an array of extra properties to return from the underlying document + * @returns {promise} - { saved_objects: [{ id, type, version, attributes }] } + * @example + * + * bulkGet([ + * { id: 'one', type: 'config' }, + * { id: 'foo', type: 'index-pattern' } + * ]) + */ + async bulkGet(objects = [], options = {}) { + // ES 'mget' does not support queries, so we have to filter results after the fact. + const thisSpaceId = await this._getSpaceId(); + + const extraDocumentProperties = this._collectExtraDocumentProperties(['spaceId', 'type'], options.extraDocumentProperties); + + const result = await this._client.bulkGet(objects, { + ...options, + extraDocumentProperties + }); + + result.saved_objects = result.saved_objects.map(savedObject => { + const { id, type, spaceId = DEFAULT_SPACE_ID } = savedObject; + + if (isTypeSpaceAware(type)) { + if (spaceId !== thisSpaceId) { + return { + id, + type, + error: { statusCode: 404, message: 'Not found' } + }; + } + } + + return savedObject; + }); + + return result; + } + + /** + * Gets a single object + * + * @param {string} type + * @param {string} id + * @param {object} [options = {}] + * @param {array} [options.extraSourceProperties = []] - an array of extra properties to return from the underlying document + * @returns {promise} - { id, type, version, attributes } + */ + async get(type, id, options = {}) { + // ES 'get' does not support queries, so we have to filter results after the fact. + + const extraDocumentProperties = this._collectExtraDocumentProperties(['spaceId'], options.extraDocumentProperties); + + const response = await this._client.get(type, id, { + ...options, + extraDocumentProperties + }); + + const { spaceId: objectSpaceId = DEFAULT_SPACE_ID } = response; + + if (isTypeSpaceAware(type)) { + const thisSpaceId = await this._getSpaceId(); + if (objectSpaceId !== thisSpaceId) { + throw this._client.errors.createGenericNotFoundError(); + } + } + + return response; + } + + /** + * Updates an object + * + * @param {string} type + * @param {string} id + * @param {object} [options={}] + * @property {integer} options.version - ensures version matches that of persisted object + * @param {array} [options.extraDocumentProperties = {}] - an object of extra properties to write into the underlying document + * @returns {promise} + */ + async update(type, id, attributes, options = {}) { + const updateOptions = { + ...options, + extraDocumentProperties: { + ...options.extraDocumentProperties + } + }; + + // attempt to retrieve document before updating. + // this ensures that the document belongs to the current space. + if (isTypeSpaceAware(type)) { + await this.get(type, id); + + const spaceId = await this._getSpaceId(); + + if (this._shouldAssignSpaceId(type, spaceId)) { + updateOptions.extraDocumentProperties.spaceId = spaceId; + } else { + delete updateOptions.extraDocumentProperties.spaceId; + } + } + + return await this._client.update(type, id, attributes, updateOptions); + } + + async _getSpaceId() { + if (!this._spaceUrlContext) { + return DEFAULT_SPACE_ID; + } + + if (!this._spaceId) { + this._spaceId = await this._findSpace(this._spaceUrlContext); + } + + return this._spaceId; + } + + async _findSpace(urlContext) { + const { + saved_objects: spaces = [] + } = await this._client.find({ + type: 'space', + search: `${urlContext}`, + search_fields: ['urlContext'] + }); + + if (spaces.length > 0) { + return spaces[0].id; + } + + return null; + } + + _collectExtraDocumentProperties(thisClientProperties, optionalProperties = []) { + return uniq([...thisClientProperties, ...optionalProperties]).value(); + } + + _shouldAssignSpaceId(type, spaceId) { + return spaceId !== DEFAULT_SPACE_ID && isTypeSpaceAware(type); + } +} diff --git a/x-pack/plugins/spaces/server/lib/saved_objects_client/spaces_saved_objects_client.test.js b/x-pack/plugins/spaces/server/lib/saved_objects_client/spaces_saved_objects_client.test.js new file mode 100644 index 0000000000000..4c7454d43f5da --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/saved_objects_client/spaces_saved_objects_client.test.js @@ -0,0 +1,1340 @@ +/* + * 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 { SpacesSavedObjectsClient } from './spaces_saved_objects_client'; +import { createSpacesService } from '../create_spaces_service'; + +const createObjectEntry = (type, id, spaceId) => ({ + [id]: { + id, + type, + spaceId + } +}); + +const SAVED_OBJECTS = { + ...createObjectEntry('foo', 'object_0'), + ...createObjectEntry('foo', 'object_1', 'space_1'), + ...createObjectEntry('foo', 'object_2', 'space_2'), + ...createObjectEntry('space', 'space_1'), +}; + +const config = { + 'server.basePath': '/' +}; + +const server = { + config: () => ({ + get: (key) => { + return config[key]; + } + }) +}; + +const createMockRequest = (space) => ({ + getBasePath: () => space.urlContext ? `/s/${space.urlContext}` : '', +}); + +const createMockClient = (space) => { + return { + get: jest.fn((type, id) => { + return SAVED_OBJECTS[id]; + }), + bulkGet: jest.fn((objects) => { + return { + saved_objects: objects.map(object => SAVED_OBJECTS[object.id]) + }; + }), + find: jest.fn(({ type }) => { + // used to locate spaces when type is `space` within these tests + if (type === 'space') { + return { + saved_objects: [space] + }; + } + return { + saved_objects: [] + }; + }), + create: jest.fn(), + bulkCreate: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + errors: { + createGenericNotFoundError: jest.fn(() => { + return new Error('not found'); + }) + } + }; +}; + +describe('default space', () => { + const currentSpace = { + id: 'default', + urlContext: '' + }; + + describe('#get', () => { + test(`returns the object when it belongs to the current space`, async () => { + + const request = createMockRequest(currentSpace); + const baseClient = createMockClient(currentSpace); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types: [], + }); + + const type = 'foo'; + const id = 'object_0'; + const options = {}; + + const result = await client.get(type, id, options); + + expect(result).toBe(SAVED_OBJECTS[id]); + }); + + test(`returns global objects that don't belong to a specific space`, async () => { + const request = createMockRequest(currentSpace); + const baseClient = createMockClient(currentSpace); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types: [], + }); + + const type = 'space'; + const id = 'space_1'; + const options = {}; + + const result = await client.get(type, id, options); + + expect(result).toBe(SAVED_OBJECTS[id]); + }); + + test(`merges options.extraDocumentProperties`, async () => { + + const request = createMockRequest(currentSpace); + const baseClient = createMockClient(currentSpace); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types: [], + }); + + const type = 'foo'; + const id = 'object_0'; + const options = { + extraDocumentProperties: ['otherSourceProp'] + }; + + await client.get(type, id, options); + + expect(baseClient.get).toHaveBeenCalledWith(type, id, { + extraDocumentProperties: ['spaceId', 'otherSourceProp'] + }); + }); + + test(`returns error when the object belongs to a different space`, async () => { + + const request = createMockRequest(currentSpace); + const baseClient = createMockClient(currentSpace); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types: [], + }); + + const type = 'foo'; + const id = 'object_2'; + const options = {}; + + await expect(client.get(type, id, options)).rejects.toThrowErrorMatchingSnapshot(); + }); + }); + + describe('#bulk_get', () => { + test(`only returns objects belonging to the current space`, async () => { + + const request = createMockRequest(currentSpace); + const baseClient = createMockClient(currentSpace); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types: [], + }); + + const type = 'foo'; + const options = {}; + + const result = await client.bulkGet([{ + type, + id: 'object_0' + }, { + type, + id: 'object_2' + }], options); + + expect(result).toEqual({ + saved_objects: [{ + id: 'object_0', + type: 'foo', + }, { + id: 'object_2', + type: 'foo', + error: { + message: 'Not found', + statusCode: 404 + } + }] + }); + }); + + test(`returns global objects that don't belong to a specific space`, async () => { + const request = createMockRequest(currentSpace); + const baseClient = createMockClient(currentSpace); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types: [], + }); + + const type = 'foo'; + const options = {}; + + const result = await client.bulkGet([{ + type, + id: 'object_0' + }, { + type, + id: 'space_1' + }], options); + + expect(result).toEqual({ + saved_objects: [{ + id: 'object_0', + type: 'foo', + }, { + id: 'space_1', + type: 'space', + }] + }); + }); + + test(`merges options.extraDocumentProperties`, async () => { + const request = createMockRequest(currentSpace); + const baseClient = createMockClient(currentSpace); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types: [], + }); + + const type = 'foo'; + + const objects = [{ + type, + id: 'object_1' + }, { + type, + id: 'object_2' + }]; + + const options = { + extraDocumentProperties: ['otherSourceProp'] + }; + + await client.bulkGet(objects, options); + + expect(baseClient.bulkGet).toHaveBeenCalledWith(objects, { + extraDocumentProperties: ['spaceId', 'type', 'otherSourceProp'] + }); + }); + }); + + describe('#find', () => { + test(`creates ES query filters restricting objects to the current space`, async () => { + + const request = createMockRequest(currentSpace); + const baseClient = createMockClient(currentSpace); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types: [], + }); + + const type = ['foo', 'space']; + const options = { + type + }; + + await client.find(options); + + expect(baseClient.find).toHaveBeenCalledWith({ + type, + filters: [{ + bool: { + minimum_should_match: 1, + should: [{ + bool: { + must: [{ + term: { + type: 'foo' + }, + }], + must_not: [{ + exists: { + field: "spaceId" + } + }] + } + }, { + bool: { + must: [{ + term: { + type: 'space' + }, + }], + } + }] + } + }] + }); + }); + + test(`merges incoming filters with filters generated by Spaces Saved Objects Client`, async () => { + const request = createMockRequest(currentSpace); + const baseClient = createMockClient(currentSpace); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types: [], + }); + + const type = 'foo'; + const otherFilters = [{ + bool: { + must: [{ + term: { + foo: 'bar' + } + }] + } + }]; + + const options = { + type, + filters: otherFilters + }; + + await client.find(options); + + expect(baseClient.find).toHaveBeenCalledWith({ + type, + filters: [...otherFilters, { + bool: { + minimum_should_match: 1, + should: [{ + bool: { + must: [{ + term: { + type + }, + }], + must_not: [{ + exists: { + field: "spaceId" + } + }] + } + }] + } + }] + }); + }); + }); + + describe('#create', () => { + + test('does not assign a space-unaware (global) object to a space', async () => { + + const request = createMockRequest(currentSpace); + const baseClient = createMockClient(currentSpace); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types: [], + }); + + const type = 'space'; + const attributes = { + prop1: 'value 1', + prop2: 'value 2' + }; + + await client.create(type, attributes); + + expect(baseClient.create).toHaveBeenCalledWith(type, attributes, { extraDocumentProperties: {} }); + }); + + test('does not assign a spaceId to space-aware objects belonging to the default space', async () => { + + const request = createMockRequest(currentSpace); + const baseClient = createMockClient(currentSpace); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types: [], + }); + + const type = 'foo'; + const attributes = { + prop1: 'value 1', + prop2: 'value 2' + }; + + await client.create(type, attributes); + + // called without extraDocumentProperties + expect(baseClient.create).toHaveBeenCalledWith(type, attributes, { extraDocumentProperties: {} }); + }); + }); + + describe('#bulk_create', () => { + test('allows for bulk creation when all types are space-aware', async () => { + + const request = createMockRequest(currentSpace); + const baseClient = createMockClient(currentSpace); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types: [], + }); + + + const attributes = { + prop1: 'value 1', + prop2: 'value 2' + }; + const objects = [{ + type: 'foo', + attributes + }, { + type: 'bar', + attributes + }]; + + await client.bulkCreate(objects, {}); + + const expectedCalledWithObjects = objects.map(object => ({ + ...object, + extraDocumentProperties: {} + })); + + expect(baseClient.bulkCreate).toHaveBeenCalledWith(expectedCalledWithObjects, {}); + }); + + test('allows for bulk creation when all types are not space-aware (global)', async () => { + + const request = createMockRequest(currentSpace); + const baseClient = createMockClient(currentSpace); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types: [], + }); + + const attributes = { + prop1: 'value 1', + prop2: 'value 2' + }; + + const objects = [{ + type: 'space', + attributes + }, { + type: 'space', + attributes + }]; + + await client.bulkCreate(objects, {}); + + expect(baseClient.bulkCreate).toHaveBeenCalledWith(objects.map(o => { + return { ...o, extraDocumentProperties: {} }; + }), {}); + }); + + test('allows space-aware and non-space-aware (global) objects to be created at the same time', async () => { + + const request = createMockRequest(currentSpace); + const baseClient = createMockClient(currentSpace); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types: [], + }); + + const attributes = { + prop1: 'value 1', + prop2: 'value 2' + }; + + const objects = [{ + type: 'space', + attributes + }, { + type: 'foo', + attributes + }]; + + await client.bulkCreate(objects, {}); + + const expectedCalledWithObjects = [...objects]; + expectedCalledWithObjects[0] = { + ...expectedCalledWithObjects[0], + extraDocumentProperties: {} + }; + expectedCalledWithObjects[1] = { + ...expectedCalledWithObjects[1], + extraDocumentProperties: {} + }; + + expect(baseClient.bulkCreate).toHaveBeenCalledWith(expectedCalledWithObjects, {}); + }); + + test('does not assign a spaceId to space-aware objects that belong to the default space', async () => { + + const request = createMockRequest(currentSpace); + const baseClient = createMockClient(currentSpace); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types: [], + }); + + + const attributes = { + prop1: 'value 1', + prop2: 'value 2' + }; + const objects = [{ + type: 'foo', + attributes + }, { + type: 'bar', + attributes + }]; + + await client.bulkCreate(objects, {}); + + // called with empty extraDocumentProperties + expect(baseClient.bulkCreate).toHaveBeenCalledWith(objects.map(o => ({ + ...o, + extraDocumentProperties: {} + })), {}); + }); + }); + + describe('#update', () => { + test('allows an object to be updated if it exists in the same space', async () => { + + const request = createMockRequest(currentSpace); + const baseClient = createMockClient(currentSpace); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types: [], + }); + + const id = 'object_0'; + const type = 'foo'; + const attributes = { + prop1: 'value 1', + prop2: 'value 2' + }; + + await client.update(type, id, attributes); + + expect(baseClient.update).toHaveBeenCalledWith(type, id, attributes, { extraDocumentProperties: {} }); + }); + + test('allows a global object to be updated', async () => { + const request = createMockRequest(currentSpace); + const baseClient = createMockClient(currentSpace); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types: [], + }); + + const id = 'space_1'; + const type = 'space'; + const attributes = { + prop1: 'value 1', + prop2: 'value 2' + }; + + await client.update(type, id, attributes); + + expect(baseClient.update).toHaveBeenCalledWith(type, id, attributes, { extraDocumentProperties: {} }); + }); + + test('does not allow an object to be updated via a different space', async () => { + + const request = createMockRequest(currentSpace); + const baseClient = createMockClient(currentSpace); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types: [], + }); + + const id = 'object_2'; + const type = 'foo'; + const attributes = { + prop1: 'value 1', + prop2: 'value 2' + }; + + await expect(client.update(type, id, attributes)).rejects.toThrowErrorMatchingSnapshot(); + }); + }); + + describe('#delete', () => { + test('allows an object to be deleted if it exists in the same space', async () => { + + const request = createMockRequest(currentSpace); + const baseClient = createMockClient(currentSpace); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types: [], + }); + + const id = 'object_0'; + const type = 'foo'; + + await client.delete(type, id); + + expect(baseClient.delete).toHaveBeenCalledWith(type, id); + }); + + test(`allows a global object to be deleted`, async () => { + const request = createMockRequest(currentSpace); + const baseClient = createMockClient(currentSpace); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types: [], + }); + + const id = 'space_1'; + const type = 'space'; + + await client.delete(type, id); + + expect(baseClient.delete).toHaveBeenCalledWith(type, id); + }); + + test('does not allow an object to be deleted via a different space', async () => { + + const request = createMockRequest(currentSpace); + const baseClient = createMockClient(currentSpace); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types: [], + }); + + const id = 'object_2'; + const type = 'foo'; + + await expect(client.delete(type, id)).rejects.toThrowErrorMatchingSnapshot(); + }); + }); +}); + +describe('current space (space_1)', () => { + const currentSpace = { + id: 'space_1', + urlContext: 'space-1' + }; + + describe('#get', () => { + test(`returns the object when it belongs to the current space`, async () => { + + const request = createMockRequest(currentSpace); + const baseClient = createMockClient(currentSpace); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types: [], + }); + + const type = 'foo'; + const id = 'object_1'; + const options = {}; + + const result = await client.get(type, id, options); + + expect(result).toBe(SAVED_OBJECTS[id]); + }); + + test(`returns global objects that don't belong to a specific space`, async () => { + const request = createMockRequest(currentSpace); + const baseClient = createMockClient(currentSpace); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types: [], + }); + + const type = 'space'; + const id = 'space_1'; + const options = {}; + + const result = await client.get(type, id, options); + + expect(result).toBe(SAVED_OBJECTS[id]); + }); + + test(`merges options.extraDocumentProperties`, async () => { + + const request = createMockRequest(currentSpace); + const baseClient = createMockClient(currentSpace); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types: [], + }); + + const type = 'foo'; + const id = 'object_1'; + const options = { + extraDocumentProperties: ['otherSourceProp'] + }; + + await client.get(type, id, options); + + expect(baseClient.get).toHaveBeenCalledWith(type, id, { + extraDocumentProperties: ['spaceId', 'otherSourceProp'] + }); + }); + + test(`returns error when the object belongs to a different space`, async () => { + + const request = createMockRequest(currentSpace); + const baseClient = createMockClient(currentSpace); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types: [], + }); + + const type = 'foo'; + const id = 'object_2'; + const options = {}; + + await expect(client.get(type, id, options)).rejects.toThrowErrorMatchingSnapshot(); + }); + }); + + describe('#bulk_get', () => { + test(`only returns objects belonging to the current space`, async () => { + + const request = createMockRequest(currentSpace); + const baseClient = createMockClient(currentSpace); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types: [], + }); + + const type = 'foo'; + const options = {}; + + const result = await client.bulkGet([{ + type, + id: 'object_1' + }, { + type, + id: 'object_2' + }], options); + + expect(result).toEqual({ + saved_objects: [{ + id: 'object_1', + spaceId: 'space_1', + type: 'foo', + }, { + id: 'object_2', + type: 'foo', + error: { + message: 'Not found', + statusCode: 404 + } + }] + }); + }); + + test(`returns global objects that don't belong to a specific space`, async () => { + const request = createMockRequest(currentSpace); + const baseClient = createMockClient(currentSpace); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types: [], + }); + + const type = 'foo'; + const options = {}; + + const result = await client.bulkGet([{ + type, + id: 'object_1' + }, { + type, + id: 'space_1' + }], options); + + expect(result).toEqual({ + saved_objects: [{ + id: 'object_1', + spaceId: 'space_1', + type: 'foo', + }, { + id: 'space_1', + type: 'space', + }] + }); + }); + + test(`merges options.extraDocumentProperties`, async () => { + const request = createMockRequest(currentSpace); + const baseClient = createMockClient(currentSpace); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types: [], + }); + + const type = 'foo'; + + const objects = [{ + type, + id: 'object_1' + }, { + type, + id: 'object_2' + }]; + + const options = { + extraDocumentProperties: ['otherSourceProp'] + }; + + await client.bulkGet(objects, options); + + expect(baseClient.bulkGet).toHaveBeenCalledWith(objects, { + extraDocumentProperties: ['spaceId', 'type', 'otherSourceProp'] + }); + }); + }); + + describe('#find', () => { + test(`creates ES query filters restricting objects to the current space`, async () => { + + const request = createMockRequest(currentSpace); + const baseClient = createMockClient(currentSpace); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types: [], + }); + + const type = ['foo', 'space']; + const options = { + type + }; + + await client.find(options); + + expect(baseClient.find).toHaveBeenCalledWith({ + type, + filters: [{ + bool: { + minimum_should_match: 1, + should: [{ + bool: { + must: [{ + term: { + type: 'foo' + }, + }, { + term: { + spaceId: 'space_1' + } + }], + } + }, { + bool: { + must: [{ + term: { + type: 'space' + }, + }], + } + }] + } + }] + }); + }); + + test(`merges incoming filters with filters generated by Spaces Saved Objects Client`, async () => { + const request = createMockRequest(currentSpace); + const baseClient = createMockClient(currentSpace); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types: [], + }); + + const type = 'foo'; + const otherFilters = [{ + bool: { + must: [{ + term: { + foo: 'bar' + } + }] + } + }]; + + const options = { + type, + filters: otherFilters + }; + + await client.find(options); + + expect(baseClient.find).toHaveBeenCalledWith({ + type, + filters: [...otherFilters, { + bool: { + minimum_should_match: 1, + should: [{ + bool: { + must: [{ + term: { + type + }, + }, { + term: { + spaceId: 'space_1' + } + }] + } + }] + } + }] + }); + }); + }); + + describe('#create', () => { + test('automatically assigns the object to the current space via extraDocumentProperties', async () => { + + const request = createMockRequest(currentSpace); + const baseClient = createMockClient(currentSpace); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types: [], + }); + + const type = 'foo'; + const attributes = { + prop1: 'value 1', + prop2: 'value 2' + }; + + await client.create(type, attributes); + + expect(baseClient.create).toHaveBeenCalledWith(type, attributes, { + extraDocumentProperties: { + spaceId: 'space_1' + } + }); + }); + + test('does not assign a space-unaware (global) object to a space', async () => { + + const request = createMockRequest(currentSpace); + const baseClient = createMockClient(currentSpace); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types: [], + }); + + const type = 'space'; + const attributes = { + prop1: 'value 1', + prop2: 'value 2' + }; + + await client.create(type, attributes); + + expect(baseClient.create).toHaveBeenCalledWith(type, attributes, { extraDocumentProperties: {} }); + }); + + describe('#bulk_create', () => { + test('allows for bulk creation when all types are space-aware', async () => { + + const request = createMockRequest(currentSpace); + const baseClient = createMockClient(currentSpace); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types: [], + }); + + + const attributes = { + prop1: 'value 1', + prop2: 'value 2' + }; + const objects = [{ + type: 'foo', + attributes + }, { + type: 'bar', + attributes + }]; + + await client.bulkCreate(objects, {}); + + const expectedCalledWithObjects = objects.map(object => ({ + ...object, + extraDocumentProperties: { + spaceId: 'space_1' + } + })); + + expect(baseClient.bulkCreate).toHaveBeenCalledWith(expectedCalledWithObjects, {}); + }); + + test('allows for bulk creation when all types are not space-aware', async () => { + + const request = createMockRequest(currentSpace); + const baseClient = createMockClient(currentSpace); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types: [], + }); + + const attributes = { + prop1: 'value 1', + prop2: 'value 2' + }; + + const objects = [{ + type: 'space', + attributes + }, { + type: 'space', + attributes + }]; + + await client.bulkCreate(objects, {}); + + expect(baseClient.bulkCreate).toHaveBeenCalledWith(objects.map(o => { + return { ...o, extraDocumentProperties: {} }; + }), {}); + }); + + test('allows space-aware and non-space-aware (global) objects to be created at the same time', async () => { + + const request = createMockRequest(currentSpace); + const baseClient = createMockClient(currentSpace); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types: [], + }); + + const attributes = { + prop1: 'value 1', + prop2: 'value 2' + }; + + const objects = [{ + type: 'space', + attributes + }, { + type: 'foo', + attributes + }]; + + await client.bulkCreate(objects, {}); + + const expectedCalledWithObjects = [...objects]; + expectedCalledWithObjects[0] = { + ...expectedCalledWithObjects[0], + extraDocumentProperties: {} + }; + expectedCalledWithObjects[1] = { + ...expectedCalledWithObjects[1], + extraDocumentProperties: { + spaceId: 'space_1' + } + }; + + expect(baseClient.bulkCreate).toHaveBeenCalledWith(expectedCalledWithObjects, {}); + }); + }); + + describe('#update', () => { + test('allows an object to be updated if it exists in the same space', async () => { + + const request = createMockRequest(currentSpace); + const baseClient = createMockClient(currentSpace); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types: [], + }); + + const id = 'object_1'; + const type = 'foo'; + const attributes = { + prop1: 'value 1', + prop2: 'value 2' + }; + + await client.update(type, id, attributes); + + expect(baseClient.update).toHaveBeenCalledWith(type, id, attributes, { extraDocumentProperties: { spaceId: 'space_1' } }); + }); + + test('allows a global object to be updated', async () => { + const request = createMockRequest(currentSpace); + const baseClient = createMockClient(currentSpace); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types: [], + }); + + const id = 'space_1'; + const type = 'space'; + const attributes = { + prop1: 'value 1', + prop2: 'value 2' + }; + + await client.update(type, id, attributes); + + expect(baseClient.update).toHaveBeenCalledWith(type, id, attributes, { extraDocumentProperties: {} }); + }); + + test('does not allow an object to be updated via a different space', async () => { + + const request = createMockRequest(currentSpace); + const baseClient = createMockClient(currentSpace); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types: [], + }); + + const id = 'object_2'; + const type = 'foo'; + const attributes = { + prop1: 'value 1', + prop2: 'value 2' + }; + + await expect(client.update(type, id, attributes)).rejects.toThrowErrorMatchingSnapshot(); + }); + }); + + describe('#delete', () => { + test('allows an object to be deleted if it exists in the same space', async () => { + const request = createMockRequest(currentSpace); + const baseClient = createMockClient(currentSpace); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types: [], + }); + + const id = 'object_1'; + const type = 'foo'; + + await client.delete(type, id); + + expect(baseClient.delete).toHaveBeenCalledWith(type, id); + }); + + test('allows a global object to be deleted', async () => { + const request = createMockRequest(currentSpace); + const baseClient = createMockClient(currentSpace); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types: [], + }); + + const id = 'space_1'; + const type = 'space'; + + await client.delete(type, id); + + expect(baseClient.delete).toHaveBeenCalledWith(type, id); + }); + + test('does not allow an object to be deleted via a different space', async () => { + + const request = createMockRequest(currentSpace); + const baseClient = createMockClient(currentSpace); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types: [], + }); + + const id = 'object_2'; + const type = 'foo'; + + await expect(client.delete(type, id)).rejects.toThrowErrorMatchingSnapshot(); + }); + }); + }); +}); diff --git a/x-pack/plugins/spaces/server/lib/space_request_interceptors.js b/x-pack/plugins/spaces/server/lib/space_request_interceptors.js index 6f4330ba283cc..a5ec62b2f7e76 100644 --- a/x-pack/plugins/spaces/server/lib/space_request_interceptors.js +++ b/x-pack/plugins/spaces/server/lib/space_request_interceptors.js @@ -5,33 +5,33 @@ */ import { wrapError } from './errors'; +import { addSpaceUrlContext, getSpaceUrlContext } from './spaces_url_parser'; export function initSpacesRequestInterceptors(server) { - const contextCache = new WeakMap(); + + const serverBasePath = server.config().get('server.basePath'); server.ext('onRequest', async function spacesOnRequestHandler(request, reply) { const path = request.path; // If navigating within the context of a space, then we store the Space's URL Context on the request, // and rewrite the request to not include the space identifier in the URL. + const spaceUrlContext = getSpaceUrlContext(path, serverBasePath); - if (path.startsWith('/s/')) { - const pathParts = path.split('/'); - - const spaceUrlContext = pathParts[2]; - + if (spaceUrlContext) { const reqBasePath = `/s/${spaceUrlContext}`; request.setBasePath(reqBasePath); + const newLocation = path.substr(reqBasePath.length) || '/'; + const newUrl = { ...request.url, - path: path.substr(reqBasePath.length) || '/', - pathname: path.substr(reqBasePath.length) || '/', - href: path.substr(reqBasePath.length) || '/' + path: newLocation, + pathname: newLocation, + href: newLocation, }; request.setUrl(newUrl); - contextCache.set(request, spaceUrlContext); } return reply.continue(); @@ -41,7 +41,8 @@ export function initSpacesRequestInterceptors(server) { const path = request.path; const isRequestingKibanaRoot = path === '/'; - const urlContext = contextCache.get(request); + const { spaces } = server; + const urlContext = await spaces.getUrlContext(request); // if requesting the application root, then show the Space Selector UI to allow the user to choose which space // they wish to visit. This is done "onPostAuth" to allow the Saved Objects Client to use the request's auth scope, @@ -53,6 +54,10 @@ export function initSpacesRequestInterceptors(server) { type: 'space' }); + const config = server.config(); + const basePath = config.get('server.basePath'); + const defaultRoute = config.get('server.defaultRoute'); + if (total === 1) { // If only one space is available, then send user there directly. // No need for an interstitial screen where there is only one possible outcome. @@ -61,17 +66,7 @@ export function initSpacesRequestInterceptors(server) { urlContext } = space.attributes; - const config = server.config(); - const basePath = config.get('server.basePath'); - const defaultRoute = config.get('server.defaultRoute'); - - let destination; - if (urlContext) { - destination = `${basePath}/s/${urlContext}${defaultRoute}`; - } else { - destination = `${basePath}${defaultRoute}`; - } - + const destination = addSpaceUrlContext(basePath, urlContext, defaultRoute); return reply.redirect(destination); } @@ -83,8 +78,8 @@ export function initSpacesRequestInterceptors(server) { }); } - } catch (e) { - return reply(wrapError(e)); + } catch (error) { + return reply(wrapError(error)); } } diff --git a/x-pack/plugins/spaces/server/lib/space_request_interceptors.test.js b/x-pack/plugins/spaces/server/lib/space_request_interceptors.test.js index 434ec530ba6f3..05fddbe43fbba 100644 --- a/x-pack/plugins/spaces/server/lib/space_request_interceptors.test.js +++ b/x-pack/plugins/spaces/server/lib/space_request_interceptors.test.js @@ -6,6 +6,7 @@ import sinon from 'sinon'; import { Server } from 'hapi'; import { initSpacesRequestInterceptors } from './space_request_interceptors'; +import { createSpacesService } from './create_spaces_service'; describe('interceptors', () => { const sandbox = sinon.sandbox.create(); @@ -14,12 +15,28 @@ describe('interceptors', () => { beforeEach(() => { teardowns.push(() => sandbox.restore()); - request = async (path, setupFn = () => { }) => { + request = async (path, setupFn = () => { }, testConfig = {}) => { const server = new Server(); server.connection({ port: 0 }); + const config = { + 'server.basePath': '/foo', + ...testConfig, + }; + + server.decorate('server', 'config', jest.fn(() => { + return { + get: jest.fn(key => { + return config[key]; + }) + }; + })); + + const spacesService = createSpacesService(server); + server.decorate('server', 'spaces', spacesService); + initSpacesRequestInterceptors(server); server.route({ @@ -115,15 +132,6 @@ describe('interceptors', () => { }; const setupTest = (server, spaces, testHandler) => { - // Mock server.config() - server.decorate('server', 'config', () => { - return { - get: (key) => { - return config[key]; - } - }; - }); - // Mock server.getSavedObjectsClient() server.decorate('request', 'getSavedObjectsClient', () => { return { @@ -165,7 +173,7 @@ describe('interceptors', () => { await request('/', (server) => { setupTest(server, spaces, testHandler); - }); + }, config); expect(testHandler).toHaveBeenCalledTimes(1); }); @@ -198,7 +206,7 @@ describe('interceptors', () => { await request('/', (server) => { setupTest(server, spaces, testHandler); - }); + }, config); expect(testHandler).toHaveBeenCalledTimes(1); }); @@ -243,7 +251,7 @@ describe('interceptors', () => { }); setupTest(server, spaces, testHandler); - }); + }, config); expect(getHiddenUiAppHandler).toHaveBeenCalledTimes(1); expect(getHiddenUiAppHandler).toHaveBeenCalledWith('space_selector'); diff --git a/x-pack/plugins/spaces/server/lib/spaces_url_parser.js b/x-pack/plugins/spaces/server/lib/spaces_url_parser.js new file mode 100644 index 0000000000000..7c0cf5d32776b --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/spaces_url_parser.js @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export function getSpaceUrlContext(requestBasePath = '/', serverBasePath = '/') { + let pathToCheck = requestBasePath; + + if (serverBasePath && serverBasePath !== '/' && requestBasePath.startsWith(serverBasePath)) { + pathToCheck = requestBasePath.substr(serverBasePath.length); + } + // Look for `/s/space-url-context` in the base path + const matchResult = pathToCheck.match(/^\/s\/([a-z0-9\-]+)/); + + if (!matchResult || matchResult.length === 0) { + return ''; + } + + // Ignoring first result, we only want the capture group result at index 1 + const [, urlContext = ''] = matchResult; + + return urlContext; +} + +export function addSpaceUrlContext(basePath = '/', urlContext = '', requestedPath = '') { + if (requestedPath && !requestedPath.startsWith('/')) { + throw new Error(`path must start with a /`); + } + + if (urlContext) { + return `${basePath}/s/${urlContext}${requestedPath}`; + } + return `${basePath}${requestedPath}`; +} diff --git a/x-pack/plugins/spaces/server/lib/spaces_url_parser.test.js b/x-pack/plugins/spaces/server/lib/spaces_url_parser.test.js new file mode 100644 index 0000000000000..3d2ddaee6137f --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/spaces_url_parser.test.js @@ -0,0 +1,67 @@ +/* + * 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 { getSpaceUrlContext, addSpaceUrlContext } from './spaces_url_parser'; + +describe('getSpaceUrlContext', () => { + describe('without a serverBasePath defined', () => { + test('it identifies the space url context', () => { + const basePath = `/s/my-awesome-space-lives-here`; + expect(getSpaceUrlContext(basePath)).toEqual('my-awesome-space-lives-here'); + }); + + test('ignores space identifiers in the middle of the path', () => { + const basePath = `/this/is/a/crazy/path/s/my-awesome-space-lives-here`; + expect(getSpaceUrlContext(basePath)).toEqual(''); + }); + + test('it handles base url without a space url context', () => { + const basePath = `/this/is/a/crazy/path/s`; + expect(getSpaceUrlContext(basePath)).toEqual(''); + }); + }); + + describe('with a serverBasePath defined', () => { + test('it identifies the space url context', () => { + const basePath = `/s/my-awesome-space-lives-here`; + expect(getSpaceUrlContext(basePath, '/')).toEqual('my-awesome-space-lives-here'); + }); + + test('it identifies the space url context following the server base path', () => { + const basePath = `/server-base-path-here/s/my-awesome-space-lives-here`; + expect(getSpaceUrlContext(basePath, '/server-base-path-here')).toEqual('my-awesome-space-lives-here'); + }); + + test('ignores space identifiers in the middle of the path', () => { + const basePath = `/this/is/a/crazy/path/s/my-awesome-space-lives-here`; + expect(getSpaceUrlContext(basePath, '/this/is/a')).toEqual(''); + }); + + test('it handles base url without a space url context', () => { + const basePath = `/this/is/a/crazy/path/s`; + expect(getSpaceUrlContext(basePath, basePath)).toEqual(''); + }); + }); +}); + +describe('addSpaceUrlContext', () => { + test('handles no parameters', () => { + expect(addSpaceUrlContext()).toEqual(`/`); + }); + + test('it adds to the basePath correctly', () => { + expect(addSpaceUrlContext('/my/base/path', 'url-context')).toEqual('/my/base/path/s/url-context'); + }); + + test('it appends the requested path to the end of the url context', () => { + expect(addSpaceUrlContext('/base', 'context', '/final/destination')).toEqual('/base/s/context/final/destination'); + }); + + test('it throws an error when the requested path does not start with a slash', () => { + expect(() => { + addSpaceUrlContext('', '', 'foo'); + }).toThrowErrorMatchingSnapshot(); + }); +}); diff --git a/x-pack/plugins/spaces/server/routes/api/v1/spaces.js b/x-pack/plugins/spaces/server/routes/api/v1/spaces.js index 01ce98f97cf4f..60a60c01088ee 100644 --- a/x-pack/plugins/spaces/server/routes/api/v1/spaces.js +++ b/x-pack/plugins/spaces/server/routes/api/v1/spaces.js @@ -12,6 +12,7 @@ import { spaceSchema } from '../../../lib/space_schema'; import { wrapError } from '../../../lib/errors'; import { isReservedSpace } from '../../../../common/is_reserved_space'; import { createDuplicateContextQuery } from '../../../lib/check_duplicate_context'; +import { addSpaceUrlContext } from '../../../lib/spaces_url_parser'; export function initSpacesApi(server) { const routePreCheckLicenseFn = routePreCheckLicense(server); @@ -63,8 +64,8 @@ export function initSpacesApi(server) { }); spaces = result.saved_objects.map(convertSavedObjectToSpace); - } catch (e) { - return reply(wrapError(e)); + } catch (error) { + return reply(wrapError(error)); } return reply(spaces); @@ -86,8 +87,8 @@ export function initSpacesApi(server) { const response = await client.get('space', spaceId); return reply(convertSavedObjectToSpace(response)); - } catch (e) { - return reply(wrapError(e)); + } catch (error) { + return reply(wrapError(error)); } }, config: { @@ -107,10 +108,10 @@ export function initSpacesApi(server) { const space = omit(request.payload, ['id', '_reserved']); - const { error } = await checkForDuplicateContext(space); + const { error: contextError } = await checkForDuplicateContext(space); - if (error) { - return reply(wrapError(error)); + if (contextError) { + return reply(wrapError(contextError)); } const id = request.params.id; @@ -126,8 +127,8 @@ export function initSpacesApi(server) { } result = await client.create('space', { ...space }, { id, overwrite }); - } catch (e) { - return reply(wrapError(e)); + } catch (error) { + return reply(wrapError(error)); } return reply(convertSavedObjectToSpace(result)); @@ -162,8 +163,8 @@ export function initSpacesApi(server) { let result; try { result = await client.create('space', { ...space }, { id, overwrite }); - } catch (e) { - return reply(wrapError(e)); + } catch (error) { + return reply(wrapError(error)); } return reply(convertSavedObjectToSpace(result)); @@ -193,8 +194,8 @@ export function initSpacesApi(server) { } result = await client.delete('space', id); - } catch (e) { - return reply(wrapError(e)); + } catch (error) { + return reply(wrapError(error)); } return reply(result).code(204); @@ -204,6 +205,29 @@ export function initSpacesApi(server) { } }); + server.route({ + method: 'POST', + path: '/api/spaces/v1/space/{id}/select', + async handler(request, reply) { + const client = request.getSavedObjectsClient(); + + const id = request.params.id; + + try { + const existingSpace = await getSpaceById(client, id); + + const config = server.config(); + + return reply({ + location: addSpaceUrlContext(config.get('server.basePath'), existingSpace.urlContext, config.get('server.defaultRoute')) + }); + + } catch (error) { + return reply(wrapError(error)); + } + } + }); + async function getSpaceById(client, spaceId) { try { const existingSpace = await client.get('space', spaceId); @@ -211,11 +235,11 @@ export function initSpacesApi(server) { id: existingSpace.id, ...existingSpace.attributes }; - } catch (e) { - if (client.errors.isNotFoundError(e)) { + } catch (error) { + if (client.errors.isNotFoundError(error)) { return null; } - throw e; + throw error; } } } diff --git a/x-pack/plugins/spaces/server/routes/api/v1/spaces.test.js b/x-pack/plugins/spaces/server/routes/api/v1/spaces.test.js index 2318b4cd24319..cd3910e539873 100644 --- a/x-pack/plugins/spaces/server/routes/api/v1/spaces.test.js +++ b/x-pack/plugins/spaces/server/routes/api/v1/spaces.test.js @@ -17,7 +17,7 @@ jest.mock('../../../../../../server/lib/get_client_shield', () => { return { getClient: () => { return { - callWithInternalUser: jest.fn(() => {}) + callWithInternalUser: jest.fn(() => { }) }; } }; @@ -48,18 +48,27 @@ describe('Spaces API', () => { const teardowns = []; let request; + const baseConfig = { + 'server.basePath': '' + }; + beforeEach(() => { - request = async (method, path, setupFn = () => { }) => { + request = async (method, path, setupFn = () => { }, testConfig = {}) => { const server = new Server(); + const config = { + ...baseConfig, + ...testConfig + }; + server.connection({ port: 0 }); await setupFn(server); server.decorate('server', 'config', jest.fn(() => { return { - get: () => '' + get: (key) => config[key] }; })); @@ -148,4 +157,34 @@ describe('Spaces API', () => { message: "This Space cannot be deleted because it is reserved." }); }); + + test('POST space/{id}/select should respond with the new space location', async () => { + const response = await request('POST', '/api/spaces/v1/space/a-space/select'); + + const { + statusCode, + payload + } = response; + + expect(statusCode).toEqual(200); + + const result = JSON.parse(payload); + expect(result.location).toEqual('/s/a-space'); + }); + + test('POST space/{id}/select should respond with the new space location when a baseUrl is provided', async () => { + const response = await request('POST', '/api/spaces/v1/space/a-space/select', () => { }, { + 'server.basePath': '/my/base/path' + }); + + const { + statusCode, + payload + } = response; + + expect(statusCode).toEqual(200); + + const result = JSON.parse(payload); + expect(result.location).toEqual('/my/base/path/s/a-space'); + }); }); diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index 0658878996ec2..e86b73182b943 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -15,4 +15,5 @@ require('@kbn/test').runTestsCli([ require.resolve('../test/api_integration/config.js'), require.resolve('../test/saml_api_integration/config.js'), require.resolve('../test/rbac_api_integration/config.js'), + require.resolve('../test/spaces_api_integration/config.js'), ]); diff --git a/x-pack/plugins/security/server/lib/watch_status_and_license_to_initialize.js b/x-pack/server/lib/watch_status_and_license_to_initialize.js similarity index 100% rename from x-pack/plugins/security/server/lib/watch_status_and_license_to_initialize.js rename to x-pack/server/lib/watch_status_and_license_to_initialize.js diff --git a/x-pack/plugins/security/server/lib/watch_status_and_license_to_initialize.test.js b/x-pack/server/lib/watch_status_and_license_to_initialize.test.js similarity index 100% rename from x-pack/plugins/security/server/lib/watch_status_and_license_to_initialize.test.js rename to x-pack/server/lib/watch_status_and_license_to_initialize.test.js diff --git a/x-pack/test/spaces_api_integration/apis/index.js b/x-pack/test/spaces_api_integration/apis/index.js new file mode 100644 index 0000000000000..3c747f6554132 --- /dev/null +++ b/x-pack/test/spaces_api_integration/apis/index.js @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export default function ({ loadTestFile }) { + describe('apis spaces', () => { + loadTestFile(require.resolve('./saved_objects')); + }); +} diff --git a/x-pack/test/spaces_api_integration/apis/saved_objects/bulk_get.js b/x-pack/test/spaces_api_integration/apis/saved_objects/bulk_get.js new file mode 100644 index 0000000000000..3d747051d8c43 --- /dev/null +++ b/x-pack/test/spaces_api_integration/apis/saved_objects/bulk_get.js @@ -0,0 +1,146 @@ +/* + * 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 { SPACES } from './lib/spaces'; +import { getIdPrefix, getUrlPrefix, getExpectedSpaceIdProperty } from './lib/space_test_utils'; + +export default function ({ getService }) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + const BULK_REQUESTS = [ + { + type: 'visualization', + id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', + }, + { + type: 'dashboard', + id: 'does not exist', + }, + { + type: 'config', + id: '7.0.0-alpha1', + }, + ]; + + const createBulkRequests = (spaceId) => BULK_REQUESTS.map(r => ({ + ...r, + id: `${getIdPrefix(spaceId)}${r.id}` + })); + + describe('_bulk_get', () => { + const expectNotFoundResults = (spaceId) => resp => { + expect(resp.body).to.eql({ + saved_objects: [ + { + id: `${getIdPrefix(spaceId)}dd7caf20-9efd-11e7-acb3-3dab96693fab`, + type: 'visualization', + error: { + statusCode: 404, + message: 'Not found', + }, + }, + { + id: `${getIdPrefix(spaceId)}does not exist`, + type: 'dashboard', + error: { + statusCode: 404, + message: 'Not found', + }, + }, + //todo(legrego) fix when config is space aware + { + id: `${getIdPrefix(spaceId)}7.0.0-alpha1`, + type: 'config', + error: { + statusCode: 404, + message: 'Not found', + }, + }, + ], + }); + }; + + const expectResults = (spaceId) => resp => { + expect(resp.body).to.eql({ + saved_objects: [ + { + id: `${getIdPrefix(spaceId)}dd7caf20-9efd-11e7-acb3-3dab96693fab`, + type: 'visualization', + updated_at: '2017-09-21T18:51:23.794Z', + version: resp.body.saved_objects[0].version, + ...getExpectedSpaceIdProperty(spaceId), + attributes: { + title: 'Count of requests', + description: '', + version: 1, + // cheat for some of the more complex attributes + visState: resp.body.saved_objects[0].attributes.visState, + uiStateJSON: resp.body.saved_objects[0].attributes.uiStateJSON, + kibanaSavedObjectMeta: + resp.body.saved_objects[0].attributes.kibanaSavedObjectMeta, + }, + }, + { + id: `${getIdPrefix(spaceId)}does not exist`, + type: 'dashboard', + error: { + statusCode: 404, + message: 'Not found', + }, + }, + //todo(legrego) fix when config is space aware + { + id: `${getIdPrefix(spaceId)}7.0.0-alpha1`, + type: 'config', + error: { + statusCode: 404, + message: 'Not found', + }, + }, + ], + }); + }; + + const bulkGetTest = (description, { spaceId, urlContext, tests }) => { + describe(description, () => { + before(() => esArchiver.load('saved_objects/spaces')); + after(() => esArchiver.unload('saved_objects/spaces')); + + it(`should return ${tests.default.statusCode}`, async () => { + await supertest + .post(`${getUrlPrefix(urlContext)}/api/saved_objects/_bulk_get`) + .send(createBulkRequests(spaceId)) + .expect(tests.default.statusCode) + .then(tests.default.response); + }); + }); + }; + + bulkGetTest(`objects within the current space (space_1)`, { + ...SPACES.SPACE_1, + tests: { + default: { + statusCode: 200, + response: expectResults(SPACES.SPACE_1.spaceId), + }, + } + }); + + bulkGetTest(`objects within another space`, { + ...SPACES.SPACE_1, + urlContext: SPACES.SPACE_2.urlContext, + tests: { + default: { + statusCode: 200, + response: expectNotFoundResults(SPACES.SPACE_1.spaceId) + }, + } + }); + + }); +} diff --git a/x-pack/test/spaces_api_integration/apis/saved_objects/create.js b/x-pack/test/spaces_api_integration/apis/saved_objects/create.js new file mode 100644 index 0000000000000..2eee219f739f6 --- /dev/null +++ b/x-pack/test/spaces_api_integration/apis/saved_objects/create.js @@ -0,0 +1,143 @@ +/* + * 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 { getUrlPrefix } from './lib/space_test_utils'; +import { SPACES } from './lib/spaces'; +import { DEFAULT_SPACE_ID } from '../../../../plugins/spaces/common/constants'; + +export default function ({ getService }) { + const supertest = getService('supertest'); + const es = getService('es'); + const esArchiver = getService('esArchiver'); + + describe('create', () => { + const expectSpaceAwareResults = (spaceId) => async (resp) => { + expect(resp.body).to.have.property('id').match(/^[0-9a-f-]{36}$/); + + // loose ISO8601 UTC time with milliseconds validation + expect(resp.body).to.have.property('updated_at').match(/^[\d-]{10}T[\d:\.]{12}Z$/); + + expect(resp.body).to.eql({ + id: resp.body.id, + type: 'visualization', + updated_at: resp.body.updated_at, + version: 1, + attributes: { + title: 'My favorite vis' + } + }); + + // query ES directory to assert on space id + const { _source } = await es.get({ + id: `visualization:${resp.body.id}`, + type: 'doc', + index: '.kibana' + }); + + const { + spaceId: actualSpaceId = '**not defined**' + } = _source; + + if (spaceId === DEFAULT_SPACE_ID) { + expect(actualSpaceId).to.eql('**not defined**'); + } else { + expect(actualSpaceId).to.eql(spaceId); + } + }; + + const expectNotSpaceAwareResults = () => async (resp) => { + expect(resp.body).to.have.property('id').match(/^[0-9a-f-]{36}$/); + + // loose ISO8601 UTC time with milliseconds validation + expect(resp.body).to.have.property('updated_at').match(/^[\d-]{10}T[\d:\.]{12}Z$/); + + expect(resp.body).to.eql({ + id: resp.body.id, + type: 'space', + updated_at: resp.body.updated_at, + version: 1, + attributes: { + name: 'My favorite space', + urlContext: 'my-favorite-space' + } + }); + + // query ES directory to assert on space id + const { _source } = await es.get({ + id: `space:${resp.body.id}`, + type: 'doc', + index: '.kibana' + }); + + const { + spaceId: actualSpaceId = '**not defined**' + } = _source; + + expect(actualSpaceId).to.eql('**not defined**'); + }; + + const createTest = (description, { urlContext, tests }) => { + describe(description, () => { + before(() => esArchiver.load('saved_objects/spaces')); + after(() => esArchiver.unload('saved_objects/spaces')); + it(`should return ${tests.spaceAware.statusCode} for a space-aware type`, async () => { + await supertest + .post(`${getUrlPrefix(urlContext)}/api/saved_objects/visualization`) + .send({ + attributes: { + title: 'My favorite vis' + } + }) + .expect(tests.spaceAware.statusCode) + .then(tests.spaceAware.response); + }); + + it(`should return ${tests.notSpaceAware.statusCode} for a non space-aware type`, async () => { + await supertest + .post(`${getUrlPrefix(urlContext)}/api/saved_objects/space`) + .send({ + attributes: { + name: 'My favorite space', + urlContext: 'my-favorite-space' + } + }) + .expect(tests.notSpaceAware.statusCode) + .then(tests.notSpaceAware.response); + }); + + }); + }; + + createTest('in the current space (space_1)', { + ...SPACES.SPACE_1, + tests: { + spaceAware: { + statusCode: 200, + response: expectSpaceAwareResults(SPACES.SPACE_1.spaceId), + }, + notSpaceAware: { + statusCode: 200, + response: expectNotSpaceAwareResults(SPACES.SPACE_1.spaceId), + } + } + }); + + createTest('in the default space', { + ...SPACES.DEFAULT, + tests: { + spaceAware: { + statusCode: 200, + response: expectSpaceAwareResults(SPACES.DEFAULT.spaceId), + }, + notSpaceAware: { + statusCode: 200, + response: expectNotSpaceAwareResults(SPACES.SPACE_1.spaceId), + } + } + }); + }); +} diff --git a/x-pack/test/spaces_api_integration/apis/saved_objects/delete.js b/x-pack/test/spaces_api_integration/apis/saved_objects/delete.js new file mode 100644 index 0000000000000..dc1efb057d4e7 --- /dev/null +++ b/x-pack/test/spaces_api_integration/apis/saved_objects/delete.js @@ -0,0 +1,93 @@ +/* + * 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 { SPACES } from './lib/spaces'; +import { getUrlPrefix, getIdPrefix } from './lib/space_test_utils'; + +export default function ({ getService }) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('delete', () => { + + const expectEmpty = (resp) => { + expect(resp.body).to.eql({}); + }; + + const expectNotFound = (resp) => { + expect(resp.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Not Found' + }); + }; + + const deleteTest = (description, { urlContext, spaceId, tests }) => { + describe(description, () => { + before(() => esArchiver.load('saved_objects/spaces')); + after(() => esArchiver.unload('saved_objects/spaces')); + + it(`should return ${tests.spaceAware.statusCode} when deleting a space-aware doc`, async () => ( + await supertest + .delete(`${getUrlPrefix(urlContext)}/api/saved_objects/dashboard/${getIdPrefix(spaceId)}be3733a0-9efe-11e7-acb3-3dab96693fab`) + .expect(tests.spaceAware.statusCode) + .then(tests.spaceAware.response) + )); + + it(`should return ${tests.notSpaceAware.statusCode} when deleting a non-space-aware doc`, async () => ( + await supertest + .delete(`${getUrlPrefix(urlContext)}/api/saved_objects/space/space_2`) + .expect(tests.notSpaceAware.statusCode) + .then(tests.notSpaceAware.response) + )); + + it(`should return ${tests.inOtherSpace.statusCode} when deleting a doc belonging to another space`, async () => { + await supertest + .delete(`${getUrlPrefix(urlContext)}/api/saved_objects/dashboard/${getIdPrefix('space_2')}be3733a0-9efe-11e7-acb3-3dab96693fab`) + .expect(tests.inOtherSpace.statusCode) + .then(tests.inOtherSpace.response); + }); + }); + }; + + deleteTest(`in the default space`, { + ...SPACES.DEFAULT, + tests: { + spaceAware: { + statusCode: 200, + response: expectEmpty + }, + notSpaceAware: { + statusCode: 200, + response: expectEmpty + }, + inOtherSpace: { + statusCode: 404, + response: expectNotFound + } + } + }); + + deleteTest(`in the current space (space_1)`, { + ...SPACES.SPACE_1, + tests: { + spaceAware: { + statusCode: 200, + response: expectEmpty + }, + notSpaceAware: { + statusCode: 200, + response: expectEmpty + }, + inOtherSpace: { + statusCode: 404, + response: expectNotFound + } + } + }); + }); +} diff --git a/x-pack/test/spaces_api_integration/apis/saved_objects/find.js b/x-pack/test/spaces_api_integration/apis/saved_objects/find.js new file mode 100644 index 0000000000000..1daa2b2d04f4e --- /dev/null +++ b/x-pack/test/spaces_api_integration/apis/saved_objects/find.js @@ -0,0 +1,202 @@ +/* + * 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 { SPACES } from './lib/spaces'; +import { getIdPrefix, getUrlPrefix } from './lib/space_test_utils'; + +export default function ({ getService }) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('find', () => { + + const expectVisualizationResults = (spaceId) => (resp) => { + expect(resp.body).to.eql({ + page: 1, + per_page: 20, + total: 1, + saved_objects: [ + { + type: 'visualization', + id: `${getIdPrefix(spaceId)}dd7caf20-9efd-11e7-acb3-3dab96693fab`, + // no space id on the saved object because the field is not requested as part of a find operation + version: 1, + attributes: { + 'title': 'Count of requests' + } + } + ] + }); + }; + + const expectAllResults = (spaceId) => (resp) => { + // TODO(legrego): update once config is space-aware + + const sortById = ({ id: id1 }, { id: id2 }) => id1 < id2 ? -1 : 1; + + resp.body.saved_objects.sort(sortById); + + const expectedSavedObjects = [{ + id: '7.0.0-alpha1', + type: 'config', + updated_at: '2017-09-21T18:49:16.302Z', + version: 3, + }, { + id: `default`, + type: 'space', + updated_at: '2017-09-21T18:49:16.270Z', + version: 1, + }, + { + id: `space_1`, + type: 'space', + updated_at: '2017-09-21T18:49:16.270Z', + version: 1, + }, + { + id: `space_2`, + type: 'space', + updated_at: '2017-09-21T18:49:16.270Z', + version: 1, + }, + { + id: `${getIdPrefix(spaceId)}91200a00-9efd-11e7-acb3-3dab96693fab`, + type: 'index-pattern', + updated_at: '2017-09-21T18:49:16.270Z', + version: 1, + }, + + { + id: `${getIdPrefix(spaceId)}be3733a0-9efe-11e7-acb3-3dab96693fab`, + type: 'dashboard', + updated_at: '2017-09-21T18:57:40.826Z', + version: 1, + }, + { + id: `${getIdPrefix(spaceId)}dd7caf20-9efd-11e7-acb3-3dab96693fab`, + type: 'visualization', + updated_at: '2017-09-21T18:51:23.794Z', + version: 1, + }] + .sort(sortById); + + expectedSavedObjects.forEach((object, index) => { + object.attributes = resp.body.saved_objects[index].attributes; + }); + + + expect(resp.body).to.eql({ + page: 1, + per_page: 20, + total: expectedSavedObjects.length, + saved_objects: expectedSavedObjects, + }); + }; + + const createExpectEmpty = (page, perPage, total) => (resp) => { + expect(resp.body).to.eql({ + page: page, + per_page: perPage, + total: total, + saved_objects: [] + }); + }; + + const findTest = (description, { urlContext, tests }) => { + describe(description, () => { + before(() => esArchiver.load('saved_objects/spaces')); + after(() => esArchiver.unload('saved_objects/spaces')); + + it(`should return ${tests.normal.statusCode} with ${tests.normal.description}`, async () => ( + await supertest + .get(`${getUrlPrefix(urlContext)}/api/saved_objects/_find?type=visualization&fields=title`) + .expect(tests.normal.statusCode) + .then(tests.normal.response) + )); + + describe('page beyond total', () => { + it(`should return ${tests.pageBeyondTotal.statusCode} with ${tests.pageBeyondTotal.description}`, async () => ( + await supertest + .get(`${getUrlPrefix(urlContext)}/api/saved_objects/_find?type=visualization&page=100&per_page=100`) + .expect(tests.pageBeyondTotal.statusCode) + .then(tests.pageBeyondTotal.response) + )); + }); + + describe('unknown search field', () => { + it(`should return ${tests.unknownSearchField.statusCode} with ${tests.unknownSearchField.description}`, async () => ( + await supertest + .get(`${getUrlPrefix(urlContext)}/api/saved_objects/_find?type=wigwags&search_fields=a`) + .expect(tests.unknownSearchField.statusCode) + .then(tests.unknownSearchField.response) + )); + }); + + describe('no type', () => { + it(`should return ${tests.noType.statusCode} with ${tests.noType.description}`, async () => ( + await supertest + .get(`${getUrlPrefix(urlContext)}/api/saved_objects/_find`) + .expect(tests.noType.statusCode) + .then(tests.noType.response) + )); + }); + }); + }; + + findTest(`objects only within the current space (space_1)`, { + ...SPACES.SPACE_1, + tests: { + normal: { + description: 'only the visualization', + statusCode: 200, + response: expectVisualizationResults(SPACES.SPACE_1.spaceId), + }, + pageBeyondTotal: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(100, 100, 1), + }, + unknownSearchField: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(1, 20, 0), + }, + noType: { + description: 'all objects', + statusCode: 200, + response: expectAllResults(SPACES.SPACE_1.spaceId), + }, + } + }); + + findTest(`objects only within the current space (default)`, { + ...SPACES.DEFAULT, + tests: { + normal: { + description: 'only the visualization', + statusCode: 200, + response: expectVisualizationResults(SPACES.DEFAULT.spaceId), + }, + pageBeyondTotal: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(100, 100, 1), + }, + unknownSearchField: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(1, 20, 0), + }, + noType: { + description: 'all objects', + statusCode: 200, + response: expectAllResults(SPACES.DEFAULT.spaceId), + }, + } + }); + }); +} diff --git a/x-pack/test/spaces_api_integration/apis/saved_objects/get.js b/x-pack/test/spaces_api_integration/apis/saved_objects/get.js new file mode 100644 index 0000000000000..2516752cbff17 --- /dev/null +++ b/x-pack/test/spaces_api_integration/apis/saved_objects/get.js @@ -0,0 +1,110 @@ +/* + * 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 { getIdPrefix, getUrlPrefix } from './lib/space_test_utils'; +import { SPACES } from './lib/spaces'; + +export default function ({ getService }) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('get', () => { + + const expectResults = (spaceId) => (resp) => { + + // The default space does not assign a space id. + const expectedSpaceId = spaceId === 'default' ? undefined : spaceId; + + const expectedBody = { + id: `${getIdPrefix(spaceId)}dd7caf20-9efd-11e7-acb3-3dab96693fab`, + type: 'visualization', + updated_at: '2017-09-21T18:51:23.794Z', + version: resp.body.version, + + attributes: { + title: 'Count of requests', + description: '', + version: 1, + // cheat for some of the more complex attributes + visState: resp.body.attributes.visState, + uiStateJSON: resp.body.attributes.uiStateJSON, + kibanaSavedObjectMeta: resp.body.attributes.kibanaSavedObjectMeta + } + }; + + if (expectedSpaceId) { + expectedBody.spaceId = expectedSpaceId; + } + + expect(resp.body).to.eql(expectedBody); + }; + + const expectNotFound = (resp) => { + expect(resp.body).to.eql({ + error: 'Not Found', + message: 'Not Found', + statusCode: 404, + }); + }; + + const getTest = (description, { spaceId, urlContext = '', tests }) => { + describe(description, () => { + before(async () => esArchiver.load(`saved_objects/spaces`)); + after(async () => esArchiver.unload(`saved_objects/spaces`)); + + it(`should return ${tests.exists.statusCode}`, async () => { + return supertest + .get(`${getUrlPrefix(urlContext)}/api/saved_objects/visualization/${getIdPrefix(spaceId)}dd7caf20-9efd-11e7-acb3-3dab96693fab`) + .expect(tests.exists.statusCode) + .then(tests.exists.response); + }); + }); + }; + + getTest(`can access objects belonging to the current space (space_1)`, { + ...SPACES.SPACE_1, + tests: { + exists: { + statusCode: 200, + response: expectResults(SPACES.SPACE_1.spaceId), + }, + } + }); + + getTest(`cannot access objects belonging to a different space (space_1)`, { + ...SPACES.SPACE_1, + urlContext: 'space-2', + tests: { + exists: { + statusCode: 404, + response: expectNotFound + }, + } + }); + + getTest(`can access objects belonging to the current space (default)`, { + ...SPACES.DEFAULT, + tests: { + exists: { + statusCode: 200, + response: expectResults(SPACES.DEFAULT.spaceId), + }, + } + }); + + getTest(`cannot access objects belonging to a different space (default)`, { + ...SPACES.DEFAULT, + urlContext: 'space-1', + tests: { + exists: { + statusCode: 404, + response: expectNotFound + }, + } + }); + }); +} diff --git a/x-pack/test/spaces_api_integration/apis/saved_objects/index.js b/x-pack/test/spaces_api_integration/apis/saved_objects/index.js new file mode 100644 index 0000000000000..c74b03792ba03 --- /dev/null +++ b/x-pack/test/spaces_api_integration/apis/saved_objects/index.js @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + + +export default function ({ loadTestFile }) { + + describe('saved_objects', () => { + loadTestFile(require.resolve('./bulk_get')); + loadTestFile(require.resolve('./create')); + loadTestFile(require.resolve('./delete')); + loadTestFile(require.resolve('./find')); + loadTestFile(require.resolve('./get')); + loadTestFile(require.resolve('./update')); + }); +} diff --git a/x-pack/test/spaces_api_integration/apis/saved_objects/lib/authentication.js b/x-pack/test/spaces_api_integration/apis/saved_objects/lib/authentication.js new file mode 100644 index 0000000000000..8b140fd3b2a30 --- /dev/null +++ b/x-pack/test/spaces_api_integration/apis/saved_objects/lib/authentication.js @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const AUTHENTICATION = { + NOT_A_KIBANA_USER: { + USERNAME: 'not_a_kibana_user', + PASSWORD: 'password' + }, + SUPERUSER: { + USERNAME: 'elastic', + PASSWORD: 'changeme' + }, + KIBANA_LEGACY_USER: { + USERNAME: 'a_kibana_legacy_user', + PASSWORD: 'password' + }, + KIBANA_LEGACY_DASHBOARD_ONLY_USER: { + USERNAME: 'a_kibana_legacy_dashboard_only_user', + PASSWORD: 'password' + }, + KIBANA_RBAC_USER: { + USERNAME: 'a_kibana_rbac_user', + PASSWORD: 'password' + }, + KIBANA_RBAC_DASHBOARD_ONLY_USER: { + USERNAME: 'a_kibana_rbac_dashboard_only_user', + PASSWORD: 'password' + } +}; diff --git a/x-pack/test/spaces_api_integration/apis/saved_objects/lib/space_test_utils.js b/x-pack/test/spaces_api_integration/apis/saved_objects/lib/space_test_utils.js new file mode 100644 index 0000000000000..186b75ccacf88 --- /dev/null +++ b/x-pack/test/spaces_api_integration/apis/saved_objects/lib/space_test_utils.js @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export function getUrlPrefix(urlContext) { + return urlContext ? `/s/${urlContext}` : ``; +} + +// Spaces do not actually prefix the ID, but this simplifies testing positive and negative flows. +export function getIdPrefix(spaceId) { + return spaceId === 'default' ? '' : `${spaceId}-`; +} + +export function getExpectedSpaceIdProperty(spaceId) { + if (spaceId === 'default') { + return {}; + } + return { + spaceId + }; +} diff --git a/x-pack/test/spaces_api_integration/apis/saved_objects/lib/spaces.js b/x-pack/test/spaces_api_integration/apis/saved_objects/lib/spaces.js new file mode 100644 index 0000000000000..f5cd9109c9a9e --- /dev/null +++ b/x-pack/test/spaces_api_integration/apis/saved_objects/lib/spaces.js @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const SPACES = { + SPACE_1: { + spaceId: 'space_1', + urlContext: 'space-1' + }, + SPACE_2: { + spaceId: 'space_2', + urlContext: 'space-2' + }, + DEFAULT: { + spaceId: 'default', + urlContext: '' + } +}; diff --git a/x-pack/test/spaces_api_integration/apis/saved_objects/update.js b/x-pack/test/spaces_api_integration/apis/saved_objects/update.js new file mode 100644 index 0000000000000..d87d03d9b7250 --- /dev/null +++ b/x-pack/test/spaces_api_integration/apis/saved_objects/update.js @@ -0,0 +1,158 @@ +/* + * 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 { SPACES } from './lib/spaces'; +import { getUrlPrefix, getIdPrefix } from './lib/space_test_utils'; + +export default function ({ getService }) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('update', () => { + const expectSpaceAwareResults = resp => { + + // loose ISO8601 UTC time with milliseconds validation + expect(resp.body).to.have.property('updated_at').match(/^[\d-]{10}T[\d:\.]{12}Z$/); + + expect(resp.body).to.eql({ + id: resp.body.id, + type: 'visualization', + updated_at: resp.body.updated_at, + version: 2, + attributes: { + title: 'My second favorite vis' + } + }); + }; + + const expectNonSpaceAwareResults = resp => { + + // loose ISO8601 UTC time with milliseconds validation + expect(resp.body).to.have.property('updated_at').match(/^[\d-]{10}T[\d:\.]{12}Z$/); + + expect(resp.body).to.eql({ + id: resp.body.id, + type: 'space', + updated_at: resp.body.updated_at, + version: 2, + attributes: { + name: 'My second favorite space' + } + }); + }; + + const expectNotFound = resp => { + expect(resp.body).eql({ + statusCode: 404, + error: 'Not Found', + message: 'Not Found' + }); + }; + + const updateTest = (description, { urlContext, spaceId, tests }) => { + describe(description, () => { + before(() => esArchiver.load('saved_objects/spaces')); + after(() => esArchiver.unload('saved_objects/spaces')); + it(`should return ${tests.spaceAware.statusCode} for a space-aware doc`, async () => { + await supertest + .put(`${getUrlPrefix(urlContext)}/api/saved_objects/visualization/${getIdPrefix(spaceId)}dd7caf20-9efd-11e7-acb3-3dab96693fab`) + .send({ + attributes: { + title: 'My second favorite vis' + } + }) + .expect(tests.spaceAware.statusCode) + .then(tests.spaceAware.response); + }); + + it(`should return ${tests.notSpaceAware.statusCode} for a non space-aware doc`, async () => { + await supertest + .put(`${getUrlPrefix(urlContext)}/api/saved_objects/space/space_1`) + .send({ + attributes: { + name: 'My second favorite space' + } + }) + .expect(tests.notSpaceAware.statusCode) + .then(tests.notSpaceAware.response); + }); + + it(`should return ${tests.inOtherSpace.statusCode} for a doc in another space`, async () => { + const id = `${getIdPrefix('space_2')}dd7caf20-9efd-11e7-acb3-3dab96693fab`; + await supertest + .put(`${getUrlPrefix(urlContext)}/api/saved_objects/visualization/${id}`) + .send({ + attributes: { + title: 'My second favorite vis' + } + }) + .expect(tests.inOtherSpace.statusCode) + .then(tests.inOtherSpace.response); + }); + + describe('unknown id', () => { + it(`should return ${tests.doesntExist.statusCode}`, async () => { + await supertest + .put(`${getUrlPrefix(urlContext)}/api/saved_objects/visualization/not an id`) + .send({ + attributes: { + title: 'My second favorite vis' + } + }) + .expect(tests.doesntExist.statusCode) + .then(tests.doesntExist.response); + }); + }); + }); + }; + + updateTest(`in the default space`, { + ...SPACES.DEFAULT, + tests: { + spaceAware: { + statusCode: 200, + response: expectSpaceAwareResults, + }, + notSpaceAware: { + statusCode: 200, + response: expectNonSpaceAwareResults, + }, + inOtherSpace: { + statusCode: 404, + response: expectNotFound, + }, + doesntExist: { + statusCode: 404, + response: expectNotFound, + }, + } + }); + + updateTest('in the current space (space_1)', { + ...SPACES.SPACE_1, + tests: { + spaceAware: { + statusCode: 200, + response: expectSpaceAwareResults, + }, + notSpaceAware: { + statusCode: 200, + response: expectNonSpaceAwareResults, + }, + inOtherSpace: { + statusCode: 404, + response: expectNotFound, + }, + doesntExist: { + statusCode: 404, + response: expectNotFound, + }, + } + }); + + }); +} diff --git a/x-pack/test/spaces_api_integration/config.js b/x-pack/test/spaces_api_integration/config.js new file mode 100644 index 0000000000000..d93381cbd76db --- /dev/null +++ b/x-pack/test/spaces_api_integration/config.js @@ -0,0 +1,57 @@ +/* + * 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 path from 'path'; +import { resolveKibanaPath } from '@kbn/plugin-helpers'; + +export default async function ({ readConfigFile }) { + + const config = { + kibana: { + api: await readConfigFile(resolveKibanaPath('test/api_integration/config.js')), + common: await readConfigFile(require.resolve('../../../test/common/config.js')), + functional: await readConfigFile(require.resolve('../../../test/functional/config.js')) + }, + xpack: { + api: await readConfigFile(require.resolve('../api_integration/config.js')), + functional: await readConfigFile(require.resolve('../functional/config.js')) + } + }; + + return { + testFiles: [require.resolve('./apis')], + servers: config.xpack.api.get('servers'), + services: { + es: config.kibana.common.get('services.es'), + supertest: config.kibana.api.get('services.supertest'), + supertestWithoutAuth: config.xpack.api.get('services.supertestWithoutAuth'), + esArchiver: config.kibana.functional.get('services.esArchiver'), + }, + junit: { + reportName: 'X-Pack Spaces API Integration Tests', + }, + + esArchiver: { + directory: path.join(__dirname, 'fixtures', 'es_archiver') + }, + + esTestCluster: { + ...config.xpack.api.get('esTestCluster'), + serverArgs: [ + ...config.xpack.api.get('esTestCluster.serverArgs'), + ], + }, + + kbnTestServer: { + ...config.xpack.api.get('kbnTestServer'), + serverArgs: [ + ...config.xpack.api.get('kbnTestServer.serverArgs'), + '--optimize.enabled=false', + '--server.xsrf.disableProtection=true', + ], + }, + }; +} diff --git a/x-pack/test/spaces_api_integration/fixtures/es_archiver/saved_objects/spaces/data.json.gz b/x-pack/test/spaces_api_integration/fixtures/es_archiver/saved_objects/spaces/data.json.gz new file mode 100644 index 0000000000000..33586e3642245 Binary files /dev/null and b/x-pack/test/spaces_api_integration/fixtures/es_archiver/saved_objects/spaces/data.json.gz differ diff --git a/x-pack/test/spaces_api_integration/fixtures/es_archiver/saved_objects/spaces/mappings.json b/x-pack/test/spaces_api_integration/fixtures/es_archiver/saved_objects/spaces/mappings.json new file mode 100644 index 0000000000000..2e1a6d1f65fd9 --- /dev/null +++ b/x-pack/test/spaces_api_integration/fixtures/es_archiver/saved_objects/spaces/mappings.json @@ -0,0 +1,314 @@ +{ + "type": "index", + "value": { + "index": ".kibana", + "settings": { + "index": { + "number_of_shards": "1", + "auto_expand_replicas": "0-1", + "number_of_replicas": "0" + } + }, + "mappings": { + "doc": { + "dynamic": "strict", + "properties": { + "config": { + "dynamic": "true", + "properties": { + "buildNum": { + "type": "keyword" + }, + "defaultIndex": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + }, + "spaceId": { + "type": "keyword" + }, + "space": { + "properties": { + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + }, + "urlContext": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "initials": { + "type": "keyword" + }, + "color": { + "type": "keyword" + }, + "_reserved": { + "type": "boolean" + } + } + }, + "dashboard": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "optionsJSON": { + "type": "text" + }, + "panelsJSON": { + "type": "text" + }, + "refreshInterval": { + "properties": { + "display": { + "type": "keyword" + }, + "pause": { + "type": "boolean" + }, + "section": { + "type": "integer" + }, + "value": { + "type": "integer" + } + } + }, + "timeFrom": { + "type": "keyword" + }, + "timeRestore": { + "type": "boolean" + }, + "timeTo": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "graph-workspace": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "numLinks": { + "type": "integer" + }, + "numVertices": { + "type": "integer" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "wsState": { + "type": "text" + } + } + }, + "index-pattern": { + "properties": { + "fieldFormatMap": { + "type": "text" + }, + "fields": { + "type": "text" + }, + "intervalName": { + "type": "keyword" + }, + "notExpandable": { + "type": "boolean" + }, + "sourceFilters": { + "type": "text" + }, + "timeFieldName": { + "type": "keyword" + }, + "title": { + "type": "text" + } + } + }, + "search": { + "properties": { + "columns": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "sort": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "server": { + "properties": { + "uuid": { + "type": "keyword" + } + } + }, + "timelion-sheet": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "timelion_chart_height": { + "type": "integer" + }, + "timelion_columns": { + "type": "integer" + }, + "timelion_interval": { + "type": "keyword" + }, + "timelion_other_interval": { + "type": "keyword" + }, + "timelion_rows": { + "type": "integer" + }, + "timelion_sheet": { + "type": "text" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "url": { + "properties": { + "accessCount": { + "type": "long" + }, + "accessDate": { + "type": "date" + }, + "createDate": { + "type": "date" + }, + "url": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + } + } + }, + "visualization": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "savedSearchId": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "visState": { + "type": "text" + } + } + } + } + } + }, + "aliases": {} + } +}