[savedObjects] fix error handling when Kibana index is missing (#14141)

* [savedObjects/delete+bulk_get] add failing tests

* [savedObjects/delete+bulk_get] improve 404 handling

* [savedObjects/client] fix mocha tests

* [savedObjects/tests] remove extra test wrapper

* [apiIntegration/kbnServer] basically disable es healthcheck

* [savedObjects/create] add integration test

* [savedObjects/find] add failing integration tests

* [savedObjects/find] fix failing test

* [savedObjects/client] explain reason for generic 404s

* [savedObjects/get] add integration tests

* [savedObjects/find] test request with unkown type

* [savedObjects/find] add some more weird param tests

* [savedObjects/find] test that weird params pass when no index

* [savedObjects/update] use generic 404

* fix typos

* [savedObjects/update] add integration tests

* remove debugging uncomment

* [savedObjects/tests] move backup kibana index delete out of tests

* [savedObjects/tests/esArchives] remove logstash data

* [savedObjects] update test

* [uiSettings] remove detailed previously leaked from API

* [functional/dashboard] wrap check that is only failing on Jenkins

* [savedObjects/error] replace decorateNotFound with createGenericNotFound

* fix typo

* [savedObjectsClient/errors] fix decorateEsError() test

* [savedObjectsClient] fix typos

* [savedObjects/tests/functional] delete document that would normally exist

* [savedObjectsClient/tests] use sinon assertions

* [savedObjects/apiTests] create without index responds with 503 after #14202
This commit is contained in:
Spencer 2017-10-02 18:51:58 -07:00 committed by GitHub
parent 0a4a2a1219
commit e84761217e
20 changed files with 1076 additions and 131 deletions

View file

@ -127,11 +127,9 @@ describe('SavedObjectsClient', () => {
title: 'Logstash'
});
expect(callAdminCluster.calledOnce).to.be(true);
sinon.assert.calledOnce(callAdminCluster);
sinon.assert.calledWith(callAdminCluster, 'index');
sinon.assert.calledOnce(onBeforeWrite);
const args = callAdminCluster.getCall(0).args;
expect(args[0]).to.be('index');
});
it('should use create action if ID defined and overwrite=false', async () => {
@ -141,11 +139,9 @@ describe('SavedObjectsClient', () => {
id: 'logstash-*',
});
expect(callAdminCluster.calledOnce).to.be(true);
sinon.assert.calledOnce(callAdminCluster);
sinon.assert.calledWith(callAdminCluster, 'create');
sinon.assert.calledOnce(onBeforeWrite);
const args = callAdminCluster.getCall(0).args;
expect(args[0]).to.be('create');
});
it('allows for id to be provided', async () => {
@ -153,11 +149,12 @@ describe('SavedObjectsClient', () => {
title: 'Logstash'
}, { id: 'logstash-*' });
expect(callAdminCluster.calledOnce).to.be(true);
sinon.assert.calledOnce(onBeforeWrite);
sinon.assert.calledOnce(callAdminCluster);
sinon.assert.calledWithExactly(callAdminCluster, sinon.match.string, sinon.match({
id: 'index-pattern:logstash-*'
}));
const args = callAdminCluster.getCall(0).args;
expect(args[1].id).to.be('index-pattern:logstash-*');
sinon.assert.calledOnce(onBeforeWrite);
});
it('self-generates an ID', async () => {
@ -165,11 +162,12 @@ describe('SavedObjectsClient', () => {
title: 'Logstash'
});
expect(callAdminCluster.calledOnce).to.be(true);
sinon.assert.calledOnce(onBeforeWrite);
sinon.assert.calledOnce(callAdminCluster);
sinon.assert.calledWithExactly(callAdminCluster, sinon.match.string, sinon.match({
id: sinon.match(/index-pattern:[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}/)
}));
const args = callAdminCluster.getCall(0).args;
expect(args[1].id).to.match(/index-pattern:[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}/);
sinon.assert.calledOnce(onBeforeWrite);
});
});
@ -182,18 +180,17 @@ describe('SavedObjectsClient', () => {
{ type: 'index-pattern', id: 'two', attributes: { title: 'Test Two' } }
]);
expect(callAdminCluster.calledOnce).to.be(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' } },
{ create: { _type: 'doc', _id: 'index-pattern:two' } },
{ type: 'index-pattern', ...mockTimestampFields, 'index-pattern': { title: 'Test Two' } }
]
}));
sinon.assert.calledOnce(onBeforeWrite);
const args = callAdminCluster.getCall(0).args;
expect(args[0]).to.be('bulk');
expect(args[1].body).to.eql([
{ create: { _type: 'doc', _id: 'config:one' } },
{ type: 'config', ...mockTimestampFields, config: { title: 'Test One' } },
{ create: { _type: 'doc', _id: 'index-pattern:two' } },
{ type: 'index-pattern', ...mockTimestampFields, 'index-pattern': { title: 'Test Two' } }
]);
});
it('should overwrite objects if overwrite is truthy', async () => {
@ -201,7 +198,6 @@ describe('SavedObjectsClient', () => {
await savedObjectsClient.bulkCreate([{ type: 'foo', id: 'bar', attributes: {} }], { overwrite: false });
sinon.assert.calledOnce(callAdminCluster);
sinon.assert.calledOnce(onBeforeWrite);
sinon.assert.calledWithExactly(callAdminCluster, 'bulk', sinon.match({
body: [
// uses create because overwriting is not allowed
@ -210,12 +206,13 @@ describe('SavedObjectsClient', () => {
]
}));
sinon.assert.calledOnce(onBeforeWrite);
callAdminCluster.reset();
onBeforeWrite.reset();
await savedObjectsClient.bulkCreate([{ type: 'foo', id: 'bar', attributes: {} }], { overwrite: true });
sinon.assert.calledOnce(callAdminCluster);
sinon.assert.calledOnce(onBeforeWrite);
sinon.assert.calledWithExactly(callAdminCluster, 'bulk', sinon.match({
body: [
// uses index because overwriting is allowed
@ -224,7 +221,7 @@ describe('SavedObjectsClient', () => {
]
}));
sinon.assert.calledOnce(onBeforeWrite);
});
it('returns document errors', async () => {
@ -310,7 +307,9 @@ describe('SavedObjectsClient', () => {
describe('#delete', () => {
it('throws notFound when ES is unable to find the document', async () => {
callAdminCluster.returns(Promise.resolve({ found: false }));
callAdminCluster.returns(Promise.resolve({
result: 'not_found'
}));
try {
await savedObjectsClient.delete('index-pattern', 'logstash-*');
@ -323,20 +322,21 @@ describe('SavedObjectsClient', () => {
});
it('passes the parameters to callAdminCluster', async () => {
callAdminCluster.returns({});
callAdminCluster.returns({
result: 'deleted'
});
await savedObjectsClient.delete('index-pattern', 'logstash-*');
expect(callAdminCluster.calledOnce).to.be(true);
sinon.assert.calledOnce(onBeforeWrite);
const [method, args] = callAdminCluster.getCall(0).args;
expect(method).to.be('delete');
expect(args).to.eql({
sinon.assert.calledOnce(callAdminCluster);
sinon.assert.calledWithExactly(callAdminCluster, 'delete', {
type: 'doc',
id: 'index-pattern:logstash-*',
refresh: 'wait_for',
index: '.kibana-test'
index: '.kibana-test',
ignore: [404],
});
sinon.assert.calledOnce(onBeforeWrite);
});
});
@ -416,24 +416,26 @@ describe('SavedObjectsClient', () => {
it('accepts per_page/page', async () => {
await savedObjectsClient.find({ perPage: 10, page: 6 });
expect(callAdminCluster.calledOnce).to.be(true);
sinon.assert.notCalled(onBeforeWrite);
sinon.assert.calledOnce(callAdminCluster);
sinon.assert.calledWithExactly(callAdminCluster, sinon.match.string, sinon.match({
size: 10,
from: 50
}));
const options = callAdminCluster.getCall(0).args[1];
expect(options.size).to.be(10);
expect(options.from).to.be(50);
sinon.assert.notCalled(onBeforeWrite);
});
it('can filter by fields', async () => {
await savedObjectsClient.find({ fields: ['title'] });
expect(callAdminCluster.calledOnce).to.be(true);
sinon.assert.notCalled(onBeforeWrite);
sinon.assert.calledOnce(callAdminCluster);
sinon.assert.calledWithExactly(callAdminCluster, sinon.match.string, sinon.match({
_source: [
'*.title', 'type', 'title'
]
}));
const options = callAdminCluster.getCall(0).args[1];
expect(options._source).to.eql([
'*.title', 'type', 'title'
]);
sinon.assert.notCalled(onBeforeWrite);
});
});
@ -471,9 +473,11 @@ describe('SavedObjectsClient', () => {
await savedObjectsClient.get('index-pattern', 'logstash-*');
sinon.assert.notCalled(onBeforeWrite);
const [, args] = callAdminCluster.getCall(0).args;
expect(args.id).to.eql('index-pattern:logstash-*');
expect(args.type).to.eql('doc');
sinon.assert.calledOnce(callAdminCluster);
sinon.assert.calledWithExactly(callAdminCluster, sinon.match.string, sinon.match({
id: 'index-pattern:logstash-*',
type: 'doc'
}));
});
});
@ -486,14 +490,17 @@ describe('SavedObjectsClient', () => {
{ id: 'two', type: 'index-pattern' }
]);
expect(callAdminCluster.calledOnce).to.be(true);
sinon.assert.notCalled(onBeforeWrite);
sinon.assert.calledOnce(callAdminCluster);
sinon.assert.calledWithExactly(callAdminCluster, sinon.match.string, sinon.match({
body: {
docs: [
{ _type: 'doc', _id: 'config:one' },
{ _type: 'doc', _id: 'index-pattern:two' }
]
}
}));
const options = callAdminCluster.getCall(0).args[1];
expect(options.body.docs).to.eql([
{ _type: 'doc', _id: 'config:one' },
{ _type: 'doc', _id: 'index-pattern:two' }
]);
sinon.assert.notCalled(onBeforeWrite);
});
it('returns early for empty objects argument', async () => {
@ -502,7 +509,7 @@ describe('SavedObjectsClient', () => {
const response = await savedObjectsClient.bulkGet([]);
expect(response.saved_objects).to.have.length(0);
expect(callAdminCluster.notCalled).to.be(true);
sinon.assert.notCalled(callAdminCluster);
sinon.assert.notCalled(onBeforeWrite);
});
@ -578,29 +585,29 @@ describe('SavedObjectsClient', () => {
{ version: newVersion - 1 }
);
const esParams = callAdminCluster.getCall(0).args[1];
expect(esParams.version).to.be(newVersion - 1);
sinon.assert.calledOnce(callAdminCluster);
sinon.assert.calledWithExactly(callAdminCluster, sinon.match.string, sinon.match({
version: newVersion - 1
}));
});
it('passes the parameters to callAdminCluster', async () => {
await savedObjectsClient.update('index-pattern', 'logstash-*', { title: 'Testing' });
expect(callAdminCluster.calledOnce).to.be(true);
sinon.assert.calledOnce(onBeforeWrite);
const args = callAdminCluster.getCall(0).args;
expect(args[0]).to.be('update');
expect(args[1]).to.eql({
sinon.assert.calledOnce(callAdminCluster);
sinon.assert.calledWithExactly(callAdminCluster, 'update', {
type: 'doc',
id: 'index-pattern:logstash-*',
version: undefined,
body: {
doc: { updated_at: mockTimestamp, 'index-pattern': { title: 'Testing' } }
},
ignore: [404],
refresh: 'wait_for',
index: '.kibana-test'
});
sinon.assert.calledOnce(onBeforeWrite);
});
});

View file

@ -66,11 +66,13 @@ describe('savedObjectsClient/decorateEsError', () => {
expect(isForbiddenError(error)).to.be(true);
});
it('makes es.NotFound a SavedObjectsClient/NotFound error', () => {
it('discards es.NotFound errors and returns a generic NotFound error', () => {
const error = new esErrors.NotFound();
expect(isNotFoundError(error)).to.be(false);
expect(decorateEsError(error)).to.be(error);
expect(isNotFoundError(error)).to.be(true);
const genericError = decorateEsError(error);
expect(genericError).to.not.be(error);
expect(isNotFoundError(error)).to.be(false);
expect(isNotFoundError(genericError)).to.be(true);
});
it('makes es.BadRequest a SavedObjectsClient/BadRequest error', () => {

View file

@ -8,7 +8,7 @@ import {
isNotAuthorizedError,
decorateForbiddenError,
isForbiddenError,
decorateNotFoundError,
createGenericNotFoundError,
isNotFoundError,
decorateConflictError,
isConflictError,
@ -145,42 +145,26 @@ describe('savedObjectsClient/errorTypes', () => {
});
});
describe('NotFound error', () => {
describe('decorateNotFoundError', () => {
it('returns original object', () => {
const error = new Error();
expect(decorateNotFoundError(error)).to.be(error);
});
it('makes the error identifiable as a NotFound error', () => {
const error = new Error();
expect(isNotFoundError(error)).to.be(false);
decorateNotFoundError(error);
describe('createGenericNotFoundError', () => {
it('makes an error identifiable as a NotFound error', () => {
const error = createGenericNotFoundError();
expect(isNotFoundError(error)).to.be(true);
});
it('adds boom properties', () => {
const error = decorateNotFoundError(new Error());
it('is a boom error, has boom properties', () => {
const error = createGenericNotFoundError();
expect(error).to.have.property('isBoom', true);
expect(error.output).to.be.an('object');
expect(error.output.statusCode).to.be(404);
});
it('preserves boom properties of input', () => {
const error = Boom.forbidden();
decorateNotFoundError(error);
expect(error.output.statusCode).to.be(403);
});
describe('error.output', () => {
it('defaults to message of erorr', () => {
const error = decorateNotFoundError(new Error('foobar'));
expect(error.output.payload).to.have.property('message', 'foobar');
});
it('prefixes message with passed reason', () => {
const error = decorateNotFoundError(new Error('foobar'), 'biz');
expect(error.output.payload).to.have.property('message', 'biz: foobar');
it('Uses "Not Found" message', () => {
const error = createGenericNotFoundError();
expect(error.output.payload).to.have.property('message', 'Not Found');
});
it('sets statusCode to 404', () => {
const error = decorateNotFoundError(new Error('foo'));
const error = createGenericNotFoundError();
expect(error.output).to.have.property('statusCode', 404);
});
});

View file

@ -17,7 +17,7 @@ import {
decorateBadRequestError,
decorateNotAuthorizedError,
decorateForbiddenError,
decorateNotFoundError,
createGenericNotFoundError,
decorateConflictError,
decorateEsUnavailableError,
decorateGeneralError,
@ -51,7 +51,7 @@ export function decorateEsError(error) {
}
if (error instanceof NotFound) {
return decorateNotFoundError(error, reason);
return createGenericNotFoundError();
}
if (error instanceof BadRequest) {

View file

@ -54,8 +54,8 @@ export function isForbiddenError(error) {
// 404 - Not Found
const CODE_NOT_FOUND = 'SavedObjectsClient/notFound';
export function decorateNotFoundError(error, reason) {
return decorate(error, CODE_NOT_FOUND, 404, reason);
export function createGenericNotFoundError() {
return decorate(Boom.notFound(), CODE_NOT_FOUND, 404);
}
export function isNotFoundError(error) {
return error && error[code] === CODE_NOT_FOUND;

View file

@ -1,5 +1,5 @@
import Boom from 'boom';
import uuid from 'uuid';
import Boom from 'boom';
import { getRootType } from '../../mappings';
@ -27,6 +27,62 @@ export class SavedObjectsClient {
this._unwrappedCallCluster = callCluster;
}
/**
* ## SavedObjectsClient errors
*
* Since the SavedObjectsClient has its hands in everything we
* are a little paranoid about the way we present errors back to
* to application code. Ideally, all errors will be either:
*
* 1. Caused by bad implementation (ie. undefined is not a function) and
* as such unpredictable
* 2. An error that has been classified and decorated appropriately
* by the decorators in `./lib/errors`
*
* Type 1 errors are inevitable, but since all expected/handle-able errors
* should be Type 2 the `isXYZError()` helpers exposed at
* `savedObjectsClient.errors` should be used to understand and manage error
* responses from the `SavedObjectsClient`.
*
* Type 2 errors are decorated versions of the source error, so if
* the elasticsearch client threw an error it will be decorated based
* on its type. That means that rather than looking for `error.body.error.type` or
* doing substring checks on `error.body.error.reason`, just use the helpers to
* understand the meaning of the error:
*
* ```js
* if (savedObjectsClient.errors.isNotFoundError(error)) {
* // handle 404
* }
*
* if (savedObjectsClient.errors.isNotAuthorizedError(error)) {
* // 401 handling should be automatic, but in case you wanted to know
* }
*
* // always rethrow the error unless you handle it
* throw error;
* ```
*
* ### 404s from missing index
*
* From the perspective of application code and APIs the SavedObjectsClient is
* a black box that persists objects. One of the internal details that users have
* no control over is that we use an elasticsearch index for persistance and that
* index might be missing.
*
* At the time of writing we are in the process of transitioning away from the
* operating assumption that the SavedObjects index is always available. Part of
* this transition is handling errors resulting from an index missing. These used
* to trigger a 500 error in most cases, and in others cause 404s with different
* error messages.
*
* From my (Spencer) perspective, a 404 from the SavedObjectsApi is a 404; The
* object the request/call was targetting could not be found. This is why #14141
* takes special care to ensure that 404 errors are generic and don't distinguish
* between index missing or document missing.
*
* @type {ErrorHelpers} see ./lib/errors
*/
static errors = errors
errors = errors
@ -168,11 +224,24 @@ export class SavedObjectsClient {
type: this._type,
index: this._index,
refresh: 'wait_for',
ignore: [404],
});
if (response.found === false) {
throw errors.decorateNotFoundError(Boom.notFound());
const deleted = response.result === 'deleted';
if (deleted) {
return {};
}
const docNotFound = response.result === 'not_found';
const indexNotFound = response.error && response.error.type === 'index_not_found_exception';
if (docNotFound || indexNotFound) {
// see "404s from missing index" above
throw errors.createGenericNotFoundError();
}
throw new Error(
`Unexpected Elasticsearch DELETE response: ${JSON.stringify({ type, id, response, })}`
);
}
/**
@ -213,6 +282,7 @@ export class SavedObjectsClient {
size: perPage,
from: perPage * (page - 1),
_source: includedFields(type, fields),
ignore: [404],
body: {
version: true,
...getSearchDsl(this._mappings, {
@ -227,6 +297,17 @@ export class SavedObjectsClient {
const response = await this._callCluster('search', esOptions);
if (response.status === 404) {
// 404 is only possible here if the index is missing, which
// we don't want to leak, see "404s from missing index" above
return {
page,
per_page: perPage,
total: 0,
saved_objects: []
};
}
return {
page,
per_page: perPage,
@ -275,13 +356,14 @@ export class SavedObjectsClient {
saved_objects: response.docs.map((doc, i) => {
const { id, type } = objects[i];
if (doc.found === false) {
if (!doc.found) {
return {
id,
type,
error: { statusCode: 404, message: 'Not found' }
};
}
const time = doc._source.updated_at;
return {
id,
@ -306,7 +388,16 @@ export class SavedObjectsClient {
id: this._generateEsId(type, id),
type: this._type,
index: this._index,
ignore: [404]
});
const docNotFound = response.found === false;
const indexNotFound = response.status === 404;
if (docNotFound || indexNotFound) {
// see "404s from missing index" above
throw errors.createGenericNotFoundError();
}
const { updated_at: updatedAt } = response._source;
return {
@ -335,6 +426,7 @@ export class SavedObjectsClient {
index: this._index,
version: options.version,
refresh: 'wait_for',
ignore: [404],
body: {
doc: {
updated_at: time,
@ -343,6 +435,11 @@ export class SavedObjectsClient {
},
});
if (response.status === 404) {
// see "404s from missing index" above
throw errors.createGenericNotFoundError();
}
return {
id,
type,

View file

@ -60,7 +60,7 @@ describe('SavedObjectsClient', () => {
savedObjectsClient._request('POST', '/api/path', params);
expect($http.calledOnce).to.be(true);
sinon.assert.calledOnce($http);
});
it('throws error when body is provided for GET', async () => {
@ -227,8 +227,9 @@ describe('SavedObjectsClient', () => {
savedObjectsClient.update('index-pattern', 'logstash-*', attributes, options);
sinon.assert.calledOnce($http);
expect($http.getCall(0).args[0].data).to.eql(body);
sinon.assert.calledWithExactly($http, sinon.match({
data: body
}));
});
});
@ -268,7 +269,9 @@ describe('SavedObjectsClient', () => {
savedObjectsClient.create('index-pattern', attributes, { id: 'myId' });
sinon.assert.calledOnce($http);
expect($http.getCall(0).args[0].url).to.eql(url);
sinon.assert.calledWithExactly($http, sinon.match({
url
}));
});
it('makes HTTP call', () => {
@ -276,7 +279,12 @@ describe('SavedObjectsClient', () => {
savedObjectsClient.create('index-pattern', attributes);
sinon.assert.calledOnce($http);
expect($http.getCall(0).args[0].data.attributes).to.eql(attributes);
sinon.assert.calledWithExactly($http, sinon.match({
url: sinon.match.string,
data: {
attributes
}
}));
});
});
@ -295,31 +303,30 @@ describe('SavedObjectsClient', () => {
const body = { type: 'index-pattern', invalid: true };
savedObjectsClient.find(body);
expect($http.calledOnce).to.be(true);
const options = $http.getCall(0).args[0];
expect(options.url).to.eql(`${basePath}/api/saved_objects/?type=index-pattern&invalid=true`);
sinon.assert.calledOnce($http);
sinon.assert.calledWithExactly($http, sinon.match({
url: `${basePath}/api/saved_objects/?type=index-pattern&invalid=true`
}));
});
it('accepts fields', () => {
const body = { fields: ['title', 'description'] };
savedObjectsClient.find(body);
expect($http.calledOnce).to.be(true);
const options = $http.getCall(0).args[0];
expect(options.url).to.eql(`${basePath}/api/saved_objects/?fields=title&fields=description`);
sinon.assert.calledOnce($http);
sinon.assert.calledWithExactly($http, sinon.match({
url: `${basePath}/api/saved_objects/?fields=title&fields=description`
}));
});
it('accepts from/size', () => {
const body = { from: 50, size: 10 };
savedObjectsClient.find(body);
expect($http.calledOnce).to.be(true);
const options = $http.getCall(0).args[0];
expect(options.url).to.eql(`${basePath}/api/saved_objects/?from=50&size=10`);
sinon.assert.calledOnce($http);
sinon.assert.alwaysCalledWith($http, sinon.match({
url: `${basePath}/api/saved_objects/?from=50&size=10`
}));
});
});
});

View file

@ -18,7 +18,6 @@ export function assertDocMissingResponse({ result }) {
assertSinonMatch(result, {
statusCode: 404,
error: 'Not Found',
message: sinon.match('document_missing_exception')
.and(sinon.match('document missing'))
message: 'Not Found'
});
}

View file

@ -1,6 +1,10 @@
import { esTestConfig } from '../../src/test_utils/es';
import { kibanaTestServerUrlParts } from '../../test/kibana_test_server_url_parts';
const SECOND = 1000;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
module.exports = function (grunt) {
const platform = require('os').platform();
const binScript = /^win/.test(platform) ? '.\\bin\\kibana.bat' : './bin/kibana';
@ -55,6 +59,7 @@ module.exports = function (grunt) {
...stdDevArgs,
'--optimize.enabled=false',
'--elasticsearch.url=' + esTestConfig.getUrl(),
'--elasticsearch.healthCheck.delay=' + HOUR,
'--server.port=' + kibanaTestServerUrlParts.port,
'--server.xsrf.disableProtection=true',
...kbnServerFlags,

View file

@ -1,6 +1,7 @@
export default function ({ loadTestFile }) {
describe('apis', () => {
loadTestFile(require.resolve('./index_patterns'));
loadTestFile(require.resolve('./saved_objects'));
loadTestFile(require.resolve('./scripts'));
loadTestFile(require.resolve('./search'));
loadTestFile(require.resolve('./suggestions'));

View file

@ -0,0 +1,122 @@
import expect from 'expect.js';
export default function ({ getService }) {
const supertest = getService('supertest');
const es = getService('es');
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',
},
];
describe('bulk_get', () => {
describe('with kibana index', () => {
before(() => esArchiver.load('saved_objects/basic'));
after(() => esArchiver.unload('saved_objects/basic'));
it('should return 200 with individual responses', async () => (
await supertest
.post(`/api/saved_objects/bulk_get`)
.send(BULK_REQUESTS)
.expect(200)
.then(resp => {
expect(resp.body).to.eql({
saved_objects: [
{
id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab',
type: 'visualization',
updated_at: '2017-09-21T18:51:23.794Z',
version: resp.body.saved_objects[0].version,
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: 'does not exist',
type: 'dashboard',
error: {
statusCode: 404,
message: 'Not found'
}
},
{
id: '7.0.0-alpha1',
type: 'config',
updated_at: '2017-09-21T18:49:16.302Z',
version: resp.body.saved_objects[2].version,
attributes: {
buildNum: 8467,
defaultIndex: '91200a00-9efd-11e7-acb3-3dab96693fab'
}
}
]
});
})
));
});
describe('without kibana index', () => {
before(async () => (
// just in case the kibana server has recreated it
await es.indices.delete({
index: '.kibana',
ignore: [404],
})
));
it('should return 200 with individual responses', async () => (
await supertest
.post('/api/saved_objects/bulk_get')
.send(BULK_REQUESTS)
.expect(200)
.then(resp => {
expect(resp.body).to.eql({
saved_objects: [
{
id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab',
type: 'visualization',
error: {
statusCode: 404,
message: 'Not found'
}
},
{
id: 'does not exist',
type: 'dashboard',
error: {
statusCode: 404,
message: 'Not found'
}
},
{
id: '7.0.0-alpha1',
type: 'config',
error: {
statusCode: 404,
message: 'Not found'
}
}
]
});
})
));
});
});
}

View file

@ -0,0 +1,77 @@
import expect from 'expect.js';
export default function ({ getService }) {
const supertest = getService('supertest');
const es = getService('es');
const esArchiver = getService('esArchiver');
describe('create', () => {
describe('with kibana index', () => {
before(() => esArchiver.load('saved_objects/basic'));
after(() => esArchiver.unload('saved_objects/basic'));
it('should return 200', async () => {
await supertest
.post(`/api/saved_objects/visualization`)
.send({
attributes: {
title: 'My favorite vis'
}
})
.expect(200)
.then(resp => {
// loose uuid validation
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'
}
});
});
});
});
describe('without kibana index', () => {
before(async () => (
// just in case the kibana server has recreated it
await es.indices.delete({
index: '.kibana',
ignore: [404],
})
));
it('should return 503 and not create kibana index', async () => {
await supertest
.post(`/api/saved_objects/visualization`)
.send({
attributes: {
title: 'My favorite vis'
}
})
.expect(503)
.then(resp => {
// loose uuid validation
expect(resp.body).to.eql({
error: 'Service Unavailable',
statusCode: 503,
message: 'Service Unavailable'
});
});
const index = await es.indices.get({
index: '.kibana',
ignore: [404]
});
expect(index).to.have.property('status', 404);
});
});
});
}

View file

@ -0,0 +1,59 @@
import expect from 'expect.js';
export default function ({ getService }) {
const supertest = getService('supertest');
const es = getService('es');
const esArchiver = getService('esArchiver');
describe('delete', () => {
describe('with kibana index', () => {
before(() => esArchiver.load('saved_objects/basic'));
after(() => esArchiver.unload('saved_objects/basic'));
it('should return 200 when deleting a doc', async () => (
await supertest
.delete(`/api/saved_objects/dashboard/be3733a0-9efe-11e7-acb3-3dab96693fab`)
.expect(200)
.then(resp => {
expect(resp.body).to.eql({});
})
));
it('should return generic 404 when deleting an unknown doc', async () => (
await supertest
.delete(`/api/saved_objects/dashboard/not-a-real-id`)
.expect(404)
.then(resp => {
expect(resp.body).to.eql({
statusCode: 404,
error: 'Not Found',
message: 'Not Found'
});
})
));
});
describe('without kibana index', () => {
before(async () => (
// just in case the kibana server has recreated it
await es.indices.delete({
index: '.kibana',
ignore: [404],
})
));
it('returns generic 404 when kibana index is missing', async () => (
await supertest
.delete(`/api/saved_objects/dashboard/be3733a0-9efe-11e7-acb3-3dab96693fab`)
.expect(404)
.then(resp => {
expect(resp.body).to.eql({
statusCode: 404,
error: 'Not Found',
message: 'Not Found'
});
})
));
});
});
}

View file

@ -0,0 +1,157 @@
import expect from 'expect.js';
export default function ({ getService }) {
const supertest = getService('supertest');
const es = getService('es');
const esArchiver = getService('esArchiver');
describe('find', () => {
describe('with kibana index', () => {
before(() => esArchiver.load('saved_objects/basic'));
after(() => esArchiver.unload('saved_objects/basic'));
it('should return 200 with individual responses', async () => (
await supertest
.get('/api/saved_objects/visualization?fields=title')
.expect(200)
.then(resp => {
expect(resp.body).to.eql({
page: 1,
per_page: 20,
total: 1,
saved_objects: [
{
type: 'visualization',
id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab',
version: 1,
attributes: {
'title': 'Count of requests'
}
}
]
});
})
));
describe('unknown type', () => {
it('should return 200 with empty response', async () => (
await supertest
.get('/api/saved_objects/wigwags')
.expect(200)
.then(resp => {
expect(resp.body).to.eql({
page: 1,
per_page: 20,
total: 0,
saved_objects: []
});
})
));
});
describe('page beyond total', () => {
it('should return 200 with empty response', async () => (
await supertest
.get('/api/saved_objects/visualization?page=100&per_page=100')
.expect(200)
.then(resp => {
expect(resp.body).to.eql({
page: 100,
per_page: 100,
total: 1,
saved_objects: []
});
})
));
});
describe('unknown search field', () => {
it('should return 200 with empty response', async () => (
await supertest
.get('/api/saved_objects/wigwags?search_fields=a')
.expect(200)
.then(resp => {
expect(resp.body).to.eql({
page: 1,
per_page: 20,
total: 0,
saved_objects: []
});
})
));
});
});
describe('without kibana index', () => {
before(async () => (
// just in case the kibana server has recreated it
await es.indices.delete({
index: '.kibana',
ignore: [404],
})
));
it('should return 200 with empty response', async () => (
await supertest
.get('/api/saved_objects/visualization')
.expect(200)
.then(resp => {
expect(resp.body).to.eql({
page: 1,
per_page: 20,
total: 0,
saved_objects: []
});
})
));
describe('unknown type', () => {
it('should return 200 with empty response', async () => (
await supertest
.get('/api/saved_objects/wigwags')
.expect(200)
.then(resp => {
expect(resp.body).to.eql({
page: 1,
per_page: 20,
total: 0,
saved_objects: []
});
})
));
});
describe('page beyond total', () => {
it('should return 200 with empty response', async () => (
await supertest
.get('/api/saved_objects/visualization?page=100&per_page=100')
.expect(200)
.then(resp => {
expect(resp.body).to.eql({
page: 100,
per_page: 100,
total: 0,
saved_objects: []
});
})
));
});
describe('unknown search field', () => {
it('should return 200 with empty response', async () => (
await supertest
.get('/api/saved_objects/wigwags?search_fields=a')
.expect(200)
.then(resp => {
expect(resp.body).to.eql({
page: 1,
per_page: 20,
total: 0,
saved_objects: []
});
})
));
});
});
});
}

View file

@ -0,0 +1,75 @@
import expect from 'expect.js';
export default function ({ getService }) {
const supertest = getService('supertest');
const es = getService('es');
const esArchiver = getService('esArchiver');
describe('get', () => {
describe('with kibana index', () => {
before(() => esArchiver.load('saved_objects/basic'));
after(() => esArchiver.unload('saved_objects/basic'));
it('should return 200', async () => (
await supertest
.get(`/api/saved_objects/visualization/dd7caf20-9efd-11e7-acb3-3dab96693fab`)
.expect(200)
.then(resp => {
expect(resp.body).to.eql({
id: '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
}
});
})
));
describe('doc does not exist', () => {
it('should return same generic error as when index does not exist', async () => (
await supertest
.get(`/api/saved_objects/visualization/foobar`)
.expect(404)
.then(resp => {
expect(resp.body).to.eql({
error: 'Not Found',
message: 'Not Found',
statusCode: 404,
});
})
));
});
});
describe('without kibana index', () => {
before(async () => (
// just in case the kibana server has recreated it
await es.indices.delete({
index: '.kibana',
ignore: [404],
})
));
it('should return basic 404 without mentioning index', async () => (
await supertest
.get('/api/saved_objects/visualization/dd7caf20-9efd-11e7-acb3-3dab96693fab')
.expect(404)
.then(resp => {
expect(resp.body).to.eql({
error: 'Not Found',
message: 'Not Found',
statusCode: 404,
});
})
));
});
});
}

View file

@ -0,0 +1,10 @@
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'));
});
}

View file

@ -0,0 +1,89 @@
import expect from 'expect.js';
export default function ({ getService }) {
const supertest = getService('supertest');
const es = getService('es');
const esArchiver = getService('esArchiver');
describe('update', () => {
describe('with kibana index', () => {
before(() => esArchiver.load('saved_objects/basic'));
after(() => esArchiver.unload('saved_objects/basic'));
it('should return 200', async () => {
await supertest
.put(`/api/saved_objects/visualization/dd7caf20-9efd-11e7-acb3-3dab96693fab`)
.send({
attributes: {
title: 'My second favorite vis'
}
})
.expect(200)
.then(resp => {
// loose uuid validation
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: 2,
attributes: {
title: 'My second favorite vis'
}
});
});
});
describe('unknown id', () => {
it('should return a generic 404', async () => {
await supertest
.put(`/api/saved_objects/visualization/not an id`)
.send({
attributes: {
title: 'My second favorite vis'
}
})
.expect(404)
.then(resp => {
expect(resp.body).eql({
statusCode: 404,
error: 'Not Found',
message: 'Not Found'
});
});
});
});
});
describe('without kibana index', () => {
before(async () => (
// just in case the kibana server has recreated it
await es.indices.delete({
index: '.kibana',
ignore: [404],
})
));
it('should return generic 404', async () => (
await supertest
.put(`/api/saved_objects/visualization/dd7caf20-9efd-11e7-acb3-3dab96693fab`)
.send({
attributes: {
title: 'My second favorite vis'
}
})
.expect(404)
.then(resp => {
expect(resp.body).eql({
statusCode: 404,
error: 'Not Found',
message: 'Not Found'
});
})
));
});
});
}

View file

@ -0,0 +1,252 @@
{
"type": "index",
"value": {
"index": ".kibana",
"settings": {
"index": {
"number_of_shards": "1",
"number_of_replicas": "1"
}
},
"mappings": {
"doc": {
"dynamic": "strict",
"properties": {
"config": {
"dynamic": "true",
"properties": {
"buildNum": {
"type": "keyword"
},
"defaultIndex": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
}
}
},
"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"
}
}
},
"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"
}
}
}
}
}
}
}
}

View file

@ -45,9 +45,11 @@ export default function ({ getService, getPageObjects }) {
await PageObjects.common.clickCancelOnModal();
await PageObjects.dashboard.confirmClone();
// Should see the same confirmation if the title is the same.
const isConfirmOpen = await PageObjects.common.isConfirmModalOpen();
expect(isConfirmOpen).to.equal(true);
await retry.try(async () => {
// Should see the same confirmation if the title is the same.
const isConfirmOpen = await PageObjects.common.isConfirmModalOpen();
expect(isConfirmOpen).to.equal(true);
});
});
it('and doesn\'t save', async() => {