[fix/UiSettings] ignore certain errors (#13079)

* [SavedObjectClient] emit detectable errors

* [uiSettingsService] consume new SavedObjectsClient errors

* [SavedObjectsClient] expose errorTypeHelpers as such

* [elasticsearch/tests] recreate error for each test

* [http] wait for elasticsearch plugin to be ready

* [shortUrl/tests] ensure that create request responds with 200

* [shortUrl] use errorTypeHelpers to filter errors

* [uiSettings/savedObjectsClientStub] stub errorTypeHelpers

* [SavedObjectsClient/errors] expose error module so tests can make errors

* [shortUrl/tests] use actual SavedObjectsClient errors

* [uiSettings/savedObjectsClientStub] use actual errors lib

* [SavedObjectsClient] use decorate instead of "wrap"

* [server/routes/uiSettings] refactor routes to forward Boom errors from uiSettings

* [uiSettings] colocate routes and service

* [testUtils/esTestCluster] use more standard api style

* [testUtils/es] add createCallCluster util

* [testUtils/esTestCluster] add getters for client/callCluster

* [es/healthcheck] ensure that healtcheck stops when server is stopped

* [uiSettings/routes] add param/payload validation

* [uiSettings/routes] add tests that verify error behaviors
This commit is contained in:
Spencer 2017-08-08 17:55:36 -07:00 committed by GitHub
parent 924548864d
commit 8a64872ecb
42 changed files with 1187 additions and 179 deletions

View file

@ -105,7 +105,7 @@
"babel-register": "6.18.0",
"bluebird": "2.9.34",
"body-parser": "1.12.0",
"boom": "2.8.0",
"boom": "5.2.0",
"brace": "0.5.1",
"bunyan": "1.7.1",
"check-hash": "1.0.1",

View file

@ -104,10 +104,12 @@ describe('plugins/elasticsearch', function () {
describe('wrap401Errors', () => {
let handler;
const error = new Error('Authentication required');
error.statusCode = 401;
let error;
beforeEach(() => {
error = new Error('Authentication required');
error.statusCode = 401;
handler = sinon.stub();
});

View file

@ -5,7 +5,6 @@ import { mkdirp as mkdirpNode } from 'mkdirp';
import manageUuid from './server/lib/manage_uuid';
import search from './server/routes/api/search';
import settings from './server/routes/api/settings';
import { scrollSearchApi } from './server/routes/api/scroll_search';
import { importApi } from './server/routes/api/import';
import { exportApi } from './server/routes/api/export';
@ -143,7 +142,6 @@ export default function (kibana) {
manageUuid(server);
// routes
search(server);
settings(server);
scripts(server);
scrollSearchApi(server);
importApi(server);

View file

@ -11,7 +11,7 @@ export default function handleESError(error) {
error instanceof esErrors.ServiceUnavailable ||
error instanceof esErrors.NoConnections ||
error instanceof esErrors.RequestTimeout) {
return Boom.serverTimeout(error);
return Boom.serverUnavailable(error);
} else if (error instanceof esErrors.Conflict || _.contains(error.message, 'index_template_already_exists')) {
return Boom.conflict(error);
} else if (error instanceof esErrors[403]) {

View file

@ -1,6 +0,0 @@
export default function (server) {
require('./register_get')(server);
require('./register_set')(server);
require('./register_set_many')(server);
require('./register_delete')(server);
}

View file

@ -1,20 +0,0 @@
import Boom from 'boom';
export default function registerDelete(server) {
server.route({
path: '/api/kibana/settings/{key}',
method: 'DELETE',
handler: function (req, reply) {
const { key } = req.params;
const uiSettings = req.getUiSettingsService();
uiSettings
.remove(key)
.then(() => uiSettings
.getUserProvided()
.then(settings => reply({ settings }).type('application/json'))
)
.catch(err => reply(Boom.wrap(err, err.statusCode)));
}
});
}

View file

@ -1,15 +0,0 @@
import Boom from 'boom';
export default function registerGet(server) {
server.route({
path: '/api/kibana/settings',
method: 'GET',
handler: function (req, reply) {
req
.getUiSettingsService()
.getUserProvided()
.then(settings => reply({ settings }).type('application/json'))
.catch(err => reply(Boom.wrap(err, err.statusCode)));
}
});
}

View file

@ -1,21 +0,0 @@
import Boom from 'boom';
export default function registerSet(server) {
server.route({
path: '/api/kibana/settings/{key}',
method: 'POST',
handler: function (req, reply) {
const { key } = req.params;
const { value } = req.payload;
const uiSettings = req.getUiSettingsService();
uiSettings
.set(key, value)
.then(() => uiSettings
.getUserProvided()
.then(settings => reply({ settings }).type('application/json'))
)
.catch(err => reply(Boom.wrap(err, err.statusCode)));
}
});
}

View file

@ -1,20 +0,0 @@
import Boom from 'boom';
export default function registerSet(server) {
server.route({
path: '/api/kibana/settings',
method: 'POST',
handler: function (req, reply) {
const { changes } = req.payload;
const uiSettings = req.getUiSettingsService();
uiSettings
.setMany(changes)
.then(() => uiSettings
.getUserProvided()
.then(settings => reply({ settings }).type('application/json'))
)
.catch(err => reply(Boom.wrap(err, err.statusCode)));
}
});
}

View file

@ -70,6 +70,8 @@ describe('routes', () => {
it('redirects shortened urls', (done) => {
kbnTestServer.makeRequest(kbnServer, shortenOptions, (res) => {
expect(res).to.have.property('statusCode', 200);
const gotoOptions = {
method: 'GET',
url: '/goto/' + res.payload

View file

@ -1,6 +1,7 @@
import expect from 'expect.js';
import sinon from 'sinon';
import shortUrlLookupProvider from '../short_url_lookup';
import { SavedObjectsClient } from '../../saved_objects/client';
describe('shortUrlLookupProvider', () => {
const ID = 'bf00ad16941fc51420f91a93428b27a0';
@ -17,7 +18,8 @@ describe('shortUrlLookupProvider', () => {
savedObjectsClient = {
get: sandbox.stub(),
create: sandbox.stub().returns(Promise.resolve({ id: ID })),
update: sandbox.stub()
update: sandbox.stub(),
errors: SavedObjectsClient.errors
};
req = { getSavedObjectsClient: () => savedObjectsClient };
@ -58,10 +60,8 @@ describe('shortUrlLookupProvider', () => {
});
it('gracefully handles version conflict', async () => {
const error = new Error('version conflict');
error.data = { type: 'version_conflict_engine_exception' };
const error = savedObjectsClient.errors.decorateConflictError(new Error());
savedObjectsClient.create.throws(error);
const id = await shortUrl.generateUrlId(URL, req);
expect(id).to.eql(ID);
});

View file

@ -17,9 +17,11 @@ export default function (server) {
return {
async generateUrlId(url, req) {
const id = crypto.createHash('md5').update(url).digest('hex');
const savedObjectsClient = req.getSavedObjectsClient();
const { isConflictError } = savedObjectsClient.errors;
try {
const doc = await req.getSavedObjectsClient().create('url', {
const doc = await savedObjectsClient.create('url', {
url,
accessCount: 0,
createDate: new Date(),
@ -27,12 +29,12 @@ export default function (server) {
}, { id });
return doc.id;
} catch(e) {
if (get(e, 'data.type') === 'version_conflict_engine_exception') {
} catch (error) {
if (isConflictError(error)) {
return id;
}
throw e;
throw error;
}
},

View file

@ -0,0 +1,89 @@
import expect from 'expect.js';
import { errors as esErrors } from 'elasticsearch';
import { decorateEsError } from '../decorate_es_error';
import {
isEsUnavailableError,
isConflictError,
isNotAuthorizedError,
isForbiddenError,
isNotFoundError,
isBadRequestError,
} from '../errors';
describe('savedObjectsClient/decorateEsError', () => {
it('always returns the same error it receives', () => {
const error = new Error();
expect(decorateEsError(error)).to.be(error);
});
it('makes es.ConnectionFault a SavedObjectsClient/EsUnavailable error', () => {
const error = new esErrors.ConnectionFault();
expect(isEsUnavailableError(error)).to.be(false);
expect(decorateEsError(error)).to.be(error);
expect(isEsUnavailableError(error)).to.be(true);
});
it('makes es.ServiceUnavailable a SavedObjectsClient/EsUnavailable error', () => {
const error = new esErrors.ServiceUnavailable();
expect(isEsUnavailableError(error)).to.be(false);
expect(decorateEsError(error)).to.be(error);
expect(isEsUnavailableError(error)).to.be(true);
});
it('makes es.NoConnections a SavedObjectsClient/EsUnavailable error', () => {
const error = new esErrors.NoConnections();
expect(isEsUnavailableError(error)).to.be(false);
expect(decorateEsError(error)).to.be(error);
expect(isEsUnavailableError(error)).to.be(true);
});
it('makes es.RequestTimeout a SavedObjectsClient/EsUnavailable error', () => {
const error = new esErrors.RequestTimeout();
expect(isEsUnavailableError(error)).to.be(false);
expect(decorateEsError(error)).to.be(error);
expect(isEsUnavailableError(error)).to.be(true);
});
it('makes es.Conflict a SavedObjectsClient/Conflict error', () => {
const error = new esErrors.Conflict();
expect(isConflictError(error)).to.be(false);
expect(decorateEsError(error)).to.be(error);
expect(isConflictError(error)).to.be(true);
});
it('makes es.AuthenticationException a SavedObjectsClient/NotAuthorized error', () => {
const error = new esErrors.AuthenticationException();
expect(isNotAuthorizedError(error)).to.be(false);
expect(decorateEsError(error)).to.be(error);
expect(isNotAuthorizedError(error)).to.be(true);
});
it('makes es.Forbidden a SavedObjectsClient/Forbidden error', () => {
const error = new esErrors.Forbidden();
expect(isForbiddenError(error)).to.be(false);
expect(decorateEsError(error)).to.be(error);
expect(isForbiddenError(error)).to.be(true);
});
it('makes es.NotFound a SavedObjectsClient/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);
});
it('makes es.BadRequest a SavedObjectsClient/BadRequest error', () => {
const error = new esErrors.BadRequest();
expect(isBadRequestError(error)).to.be(false);
expect(decorateEsError(error)).to.be(error);
expect(isBadRequestError(error)).to.be(true);
});
it('returns other errors as Boom errors', () => {
const error = new Error();
expect(error).to.not.have.property('isBoom');
expect(decorateEsError(error)).to.be(error);
expect(error).to.have.property('isBoom');
});
});

View file

@ -0,0 +1,304 @@
import expect from 'expect.js';
import Boom from 'boom';
import {
decorateBadRequestError,
isBadRequestError,
decorateNotAuthorizedError,
isNotAuthorizedError,
decorateForbiddenError,
isForbiddenError,
decorateNotFoundError,
isNotFoundError,
decorateConflictError,
isConflictError,
decorateEsUnavailableError,
isEsUnavailableError,
decorateGeneralError,
} from '../errors';
describe('savedObjectsClient/errorTypes', () => {
describe('BadRequest error', () => {
describe('decorateBadRequestError', () => {
it('returns original object', () => {
const error = new Error();
expect(decorateBadRequestError(error)).to.be(error);
});
it('makes the error identifiable as a BadRequest error', () => {
const error = new Error();
expect(isBadRequestError(error)).to.be(false);
decorateBadRequestError(error);
expect(isBadRequestError(error)).to.be(true);
});
it('adds boom properties', () => {
const error = decorateBadRequestError(new Error());
expect(error.output).to.be.an('object');
expect(error.output.statusCode).to.be(400);
});
it('preserves boom properties of input', () => {
const error = Boom.notFound();
decorateBadRequestError(error);
expect(error.output.statusCode).to.be(404);
});
describe('error.output', () => {
it('defaults to message of erorr', () => {
const error = decorateBadRequestError(new Error('foobar'));
expect(error.output.payload).to.have.property('message', 'foobar');
});
it('prefixes message with passed reason', () => {
const error = decorateBadRequestError(new Error('foobar'), 'biz');
expect(error.output.payload).to.have.property('message', 'biz: foobar');
});
it('sets statusCode to 400', () => {
const error = decorateBadRequestError(new Error('foo'));
expect(error.output).to.have.property('statusCode', 400);
});
});
});
});
describe('NotAuthorized error', () => {
describe('decorateNotAuthorizedError', () => {
it('returns original object', () => {
const error = new Error();
expect(decorateNotAuthorizedError(error)).to.be(error);
});
it('makes the error identifiable as a NotAuthorized error', () => {
const error = new Error();
expect(isNotAuthorizedError(error)).to.be(false);
decorateNotAuthorizedError(error);
expect(isNotAuthorizedError(error)).to.be(true);
});
it('adds boom properties', () => {
const error = decorateNotAuthorizedError(new Error());
expect(error.output).to.be.an('object');
expect(error.output.statusCode).to.be(401);
});
it('preserves boom properties of input', () => {
const error = Boom.notFound();
decorateNotAuthorizedError(error);
expect(error.output.statusCode).to.be(404);
});
describe('error.output', () => {
it('defaults to message of erorr', () => {
const error = decorateNotAuthorizedError(new Error('foobar'));
expect(error.output.payload).to.have.property('message', 'foobar');
});
it('prefixes message with passed reason', () => {
const error = decorateNotAuthorizedError(new Error('foobar'), 'biz');
expect(error.output.payload).to.have.property('message', 'biz: foobar');
});
it('sets statusCode to 401', () => {
const error = decorateNotAuthorizedError(new Error('foo'));
expect(error.output).to.have.property('statusCode', 401);
});
});
});
});
describe('Forbidden error', () => {
describe('decorateForbiddenError', () => {
it('returns original object', () => {
const error = new Error();
expect(decorateForbiddenError(error)).to.be(error);
});
it('makes the error identifiable as a Forbidden error', () => {
const error = new Error();
expect(isForbiddenError(error)).to.be(false);
decorateForbiddenError(error);
expect(isForbiddenError(error)).to.be(true);
});
it('adds boom properties', () => {
const error = decorateForbiddenError(new Error());
expect(error.output).to.be.an('object');
expect(error.output.statusCode).to.be(403);
});
it('preserves boom properties of input', () => {
const error = Boom.notFound();
decorateForbiddenError(error);
expect(error.output.statusCode).to.be(404);
});
describe('error.output', () => {
it('defaults to message of erorr', () => {
const error = decorateForbiddenError(new Error('foobar'));
expect(error.output.payload).to.have.property('message', 'foobar');
});
it('prefixes message with passed reason', () => {
const error = decorateForbiddenError(new Error('foobar'), 'biz');
expect(error.output.payload).to.have.property('message', 'biz: foobar');
});
it('sets statusCode to 403', () => {
const error = decorateForbiddenError(new Error('foo'));
expect(error.output).to.have.property('statusCode', 403);
});
});
});
});
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);
expect(isNotFoundError(error)).to.be(true);
});
it('adds boom properties', () => {
const error = decorateNotFoundError(new Error());
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('sets statusCode to 404', () => {
const error = decorateNotFoundError(new Error('foo'));
expect(error.output).to.have.property('statusCode', 404);
});
});
});
});
describe('Conflict error', () => {
describe('decorateConflictError', () => {
it('returns original object', () => {
const error = new Error();
expect(decorateConflictError(error)).to.be(error);
});
it('makes the error identifiable as a Conflict error', () => {
const error = new Error();
expect(isConflictError(error)).to.be(false);
decorateConflictError(error);
expect(isConflictError(error)).to.be(true);
});
it('adds boom properties', () => {
const error = decorateConflictError(new Error());
expect(error.output).to.be.an('object');
expect(error.output.statusCode).to.be(409);
});
it('preserves boom properties of input', () => {
const error = Boom.notFound();
decorateConflictError(error);
expect(error.output.statusCode).to.be(404);
});
describe('error.output', () => {
it('defaults to message of erorr', () => {
const error = decorateConflictError(new Error('foobar'));
expect(error.output.payload).to.have.property('message', 'foobar');
});
it('prefixes message with passed reason', () => {
const error = decorateConflictError(new Error('foobar'), 'biz');
expect(error.output.payload).to.have.property('message', 'biz: foobar');
});
it('sets statusCode to 409', () => {
const error = decorateConflictError(new Error('foo'));
expect(error.output).to.have.property('statusCode', 409);
});
});
});
});
describe('EsUnavailable error', () => {
describe('decorateEsUnavailableError', () => {
it('returns original object', () => {
const error = new Error();
expect(decorateEsUnavailableError(error)).to.be(error);
});
it('makes the error identifiable as a EsUnavailable error', () => {
const error = new Error();
expect(isEsUnavailableError(error)).to.be(false);
decorateEsUnavailableError(error);
expect(isEsUnavailableError(error)).to.be(true);
});
it('adds boom properties', () => {
const error = decorateEsUnavailableError(new Error());
expect(error.output).to.be.an('object');
expect(error.output.statusCode).to.be(503);
});
it('preserves boom properties of input', () => {
const error = Boom.notFound();
decorateEsUnavailableError(error);
expect(error.output.statusCode).to.be(404);
});
describe('error.output', () => {
it('defaults to message of erorr', () => {
const error = decorateEsUnavailableError(new Error('foobar'));
expect(error.output.payload).to.have.property('message', 'foobar');
});
it('prefixes message with passed reason', () => {
const error = decorateEsUnavailableError(new Error('foobar'), 'biz');
expect(error.output.payload).to.have.property('message', 'biz: foobar');
});
it('sets statusCode to 503', () => {
const error = decorateEsUnavailableError(new Error('foo'));
expect(error.output).to.have.property('statusCode', 503);
});
});
});
});
describe('General error', () => {
describe('decorateGeneralError', () => {
it('returns original object', () => {
const error = new Error();
expect(decorateGeneralError(error)).to.be(error);
});
it('adds boom properties', () => {
const error = decorateGeneralError(new Error());
expect(error.output).to.be.an('object');
expect(error.output.statusCode).to.be(500);
});
it('preserves boom properties of input', () => {
const error = Boom.notFound();
decorateGeneralError(error);
expect(error.output.statusCode).to.be(404);
});
describe('error.output', () => {
it('ignores error message', () => {
const error = decorateGeneralError(new Error('foobar'));
expect(error.output.payload).to.have.property('message').match(/internal server error/i);
});
it('sets statusCode to 500', () => {
const error = decorateGeneralError(new Error('foo'));
expect(error.output).to.have.property('statusCode', 500);
});
});
});
});
});

View file

@ -0,0 +1,62 @@
import elasticsearch from 'elasticsearch';
import { get } from 'lodash';
const {
ConnectionFault,
ServiceUnavailable,
NoConnections,
RequestTimeout,
Conflict,
401: NotAuthorized,
403: Forbidden,
NotFound,
BadRequest
} = elasticsearch.errors;
import {
decorateBadRequestError,
decorateNotAuthorizedError,
decorateForbiddenError,
decorateNotFoundError,
decorateConflictError,
decorateEsUnavailableError,
decorateGeneralError,
} from './errors';
export function decorateEsError(error) {
if (!(error instanceof Error)) {
throw new Error('Expected an instance of Error');
}
const { reason } = get(error, 'body.error', {});
if (
error instanceof ConnectionFault ||
error instanceof ServiceUnavailable ||
error instanceof NoConnections ||
error instanceof RequestTimeout
) {
return decorateEsUnavailableError(error, reason);
}
if (error instanceof Conflict) {
return decorateConflictError(error, reason);
}
if (error instanceof NotAuthorized) {
return decorateNotAuthorizedError(error, reason);
}
if (error instanceof Forbidden) {
return decorateForbiddenError(error, reason);
}
if (error instanceof NotFound) {
return decorateNotFoundError(error, reason);
}
if (error instanceof BadRequest) {
return decorateBadRequestError(error, reason);
}
return decorateGeneralError(error, reason);
}

View file

@ -0,0 +1,81 @@
import Boom from 'boom';
const code = Symbol('SavedObjectsClientErrorCode');
function decorate(error, errorCode, statusCode, message) {
const boom = Boom.boomify(error, {
statusCode,
message,
override: false,
});
boom[code] = errorCode;
return boom;
}
// 400 - badRequest
const CODE_BAD_REQUEST = 'SavedObjectsClient/badRequest';
export function decorateBadRequestError(error, reason) {
return decorate(error, CODE_BAD_REQUEST, 400, reason);
}
export function isBadRequestError(error) {
return error && error[code] === CODE_BAD_REQUEST;
}
// 401 - Not Authorized
const CODE_NOT_AUTHORIZED = 'SavedObjectsClient/notAuthorized';
export function decorateNotAuthorizedError(error, reason) {
return decorate(error, CODE_NOT_AUTHORIZED, 401, reason);
}
export function isNotAuthorizedError(error) {
return error && error[code] === CODE_NOT_AUTHORIZED;
}
// 403 - Forbidden
const CODE_FORBIDDEN = 'SavedObjectsClient/forbidden';
export function decorateForbiddenError(error, reason) {
return decorate(error, CODE_FORBIDDEN, 403, reason);
}
export function isForbiddenError(error) {
return error && error[code] === CODE_FORBIDDEN;
}
// 404 - Not Found
const CODE_NOT_FOUND = 'SavedObjectsClient/notFound';
export function decorateNotFoundError(error, reason) {
return decorate(error, CODE_NOT_FOUND, 404, reason);
}
export function isNotFoundError(error) {
return error && error[code] === CODE_NOT_FOUND;
}
// 409 - Conflict
const CODE_CONFLICT = 'SavedObjectsClient/conflict';
export function decorateConflictError(error, reason) {
return decorate(error, CODE_CONFLICT, 409, reason);
}
export function isConflictError(error) {
return error && error[code] === CODE_CONFLICT;
}
// 500 - Es Unavailable
const CODE_ES_UNAVAILABLE = 'SavedObjectsClient/esUnavailable';
export function decorateEsUnavailableError(error, reason) {
return decorate(error, CODE_ES_UNAVAILABLE, 503, reason);
}
export function isEsUnavailableError(error) {
return error && error[code] === CODE_ES_UNAVAILABLE;
}
// 500 - General Error
const CODE_GENERAL_ERROR = 'SavedObjectsClient/generalError';
export function decorateGeneralError(error, reason) {
return decorate(error, CODE_GENERAL_ERROR, 500, reason);
}

View file

@ -1,50 +0,0 @@
import elasticsearch from 'elasticsearch';
import Boom from 'boom';
import { get } from 'lodash';
const {
ConnectionFault,
ServiceUnavailable,
NoConnections,
RequestTimeout,
Conflict,
403: Forbidden,
NotFound,
BadRequest
} = elasticsearch.errors;
export function handleEsError(error) {
if (!(error instanceof Error)) {
throw new Error('Expected an instance of Error');
}
const { reason, type } = get(error, 'body.error', {});
const details = { type };
if (
error instanceof ConnectionFault ||
error instanceof ServiceUnavailable ||
error instanceof NoConnections ||
error instanceof RequestTimeout
) {
throw Boom.serverTimeout();
}
if (error instanceof Conflict) {
throw Boom.conflict(reason, details);
}
if (error instanceof Forbidden) {
throw Boom.forbidden(reason, details);
}
if (error instanceof NotFound) {
throw Boom.notFound(reason, details);
}
if (error instanceof BadRequest) {
throw Boom.badRequest(reason, details);
}
throw error;
}

View file

@ -1,4 +1,7 @@
export { getSearchDsl } from './search_dsl';
export { handleEsError } from './handle_es_error';
export { trimIdPrefix } from './trim_id_prefix';
export { includedFields } from './included_fields';
export { decorateEsError } from './decorate_es_error';
import * as errors from './errors';
export { errors };

View file

@ -5,9 +5,10 @@ import { getRootType } from '../../mappings';
import {
getSearchDsl,
handleEsError,
trimIdPrefix,
includedFields
includedFields,
decorateEsError,
errors,
} from './lib';
export class SavedObjectsClient {
@ -18,6 +19,9 @@ export class SavedObjectsClient {
this._callAdminCluster = callAdminCluster;
}
static errors = errors
errors = errors
/**
* Persists an object
*
@ -316,7 +320,7 @@ export class SavedObjectsClient {
index: this._kibanaIndex,
});
} catch (err) {
throw handleEsError(err);
throw decorateEsError(err);
}
}

View file

@ -0,0 +1,21 @@
import { get } from 'lodash';
import toPath from 'lodash/internal/toPath';
/**
* Create a callCluster function that properly executes methods on an
* elasticsearch-js client
*
* @param {elasticsearch.Client} esClient
* @return {Function}
*/
export function createCallCluster(esClient) {
return function callCluster(method, params) {
const path = toPath(method);
const contextPath = path.slice(0, -1);
const action = get(esClient, path);
const context = contextPath.length ? get(esClient, contextPath) : esClient;
return action.call(context, params);
};
}

View file

@ -1,8 +1,10 @@
import { resolve } from 'path';
import libesvm from 'libesvm';
import elasticsearch from 'elasticsearch';
import { esTestConfig } from './es_test_config';
import { createCallCluster } from './create_call_cluster';
const ESVM_DIR = resolve(__dirname, '../../../esvm/test_utils/es_test_cluster');
const BRANCHES_DOWNLOADED = [];
@ -33,12 +35,27 @@ export function createEsTestCluster(options = {}) {
// assigned in use.start(), reassigned in use.stop()
let cluster;
let client;
return new class EsTestCluster {
getStartTimeout() {
return esTestConfig.getLibesvmStartTimeout();
}
getClient() {
if (!client) {
client = new elasticsearch.Client({
host: esTestConfig.getUrl()
});
}
return client;
}
getCallCluster() {
return createCallCluster(this.getClient());
}
async start() {
const download = isDownloadNeeded(branch);
@ -89,6 +106,12 @@ export function createEsTestCluster(options = {}) {
}
async stop() {
if (client) {
const c = client;
client = null;
await c.close();
}
if (cluster) {
const c = cluster;
cluster = null;

View file

@ -1,2 +1,3 @@
export { esTestConfig } from './es_test_config';
export { createEsTestCluster } from './es_test_cluster';
export { createCallCluster } from './create_call_cluster';

View file

@ -1,10 +1,14 @@
import sinon from 'sinon';
import expect from 'expect.js';
import { SavedObjectsClient } from '../../../../server/saved_objects/client';
export const savedObjectsClientErrors = SavedObjectsClient.errors;
export function createObjectsClientStub(type, id, esDocSource = {}) {
const savedObjectsClient = {
update: sinon.stub().returns(Promise.resolve()),
get: sinon.stub().returns({ attributes: esDocSource })
get: sinon.stub().returns({ attributes: esDocSource }),
errors: savedObjectsClientErrors
};
savedObjectsClient.assertGetQuery = () => {

View file

@ -1 +1,4 @@
export { createObjectsClientStub } from './create_objects_client_stub';
export {
createObjectsClientStub,
savedObjectsClientErrors,
} from './create_objects_client_stub';

View file

@ -37,6 +37,7 @@ describe('uiSettingsMixin()', () => {
// mock hapi server
const server = {
log: sinon.stub(),
route: sinon.stub(),
config: () => config,
addMemoizedFactoryToRequest(name, factory) {
this.decorate('request', name, function () {

View file

@ -5,7 +5,10 @@ import Chance from 'chance';
import { UiSettingsService } from '../ui_settings_service';
import { createObjectsClientStub } from './lib';
import {
createObjectsClientStub,
savedObjectsClientErrors,
} from './lib';
const TYPE = 'config';
const ID = 'kibana-version';
@ -197,9 +200,12 @@ describe('ui settings', () => {
it('throws 401 errors', async () => {
const { uiSettings } = setup({
savedObjectsClient: { async get() {
throw new esErrors[401]();
} }
savedObjectsClient: {
errors: savedObjectsClientErrors,
async get() {
throw new esErrors[401]();
}
}
});
try {
@ -214,9 +220,12 @@ describe('ui settings', () => {
const expectedUnexpectedError = new Error('unexpected');
const { uiSettings } = setup({
savedObjectsClient: { async get() {
throw expectedUnexpectedError;
} }
savedObjectsClient: {
errors: savedObjectsClientErrors,
async get() {
throw expectedUnexpectedError;
}
}
});
try {

View file

@ -0,0 +1,134 @@
import expect from 'expect.js';
import sinon from 'sinon';
import {
getServices,
chance,
assertSinonMatch,
} from './lib';
export function docExistsSuite() {
async function setup(options = {}) {
const {
initialSettings
} = options;
const { kbnServer, uiSettings } = getServices();
if (initialSettings) {
await uiSettings.setMany(initialSettings);
}
return { kbnServer, uiSettings };
}
describe('get route', () => {
it('returns a 200 and includes userValues', async () => {
const defaultIndex = chance.word({ length: 10 });
const { kbnServer } = await setup({
initialSettings: {
defaultIndex
}
});
const { statusCode, result } = await kbnServer.inject({
method: 'GET',
url: '/api/kibana/settings'
});
expect(statusCode).to.be(200);
assertSinonMatch(result, {
settings: {
buildNum: {
userValue: sinon.match.number
},
defaultIndex: {
userValue: defaultIndex
}
}
});
});
});
describe('set route', () => {
it('returns a 200 and all values including update', async () => {
const { kbnServer } = await setup();
const defaultIndex = chance.word();
const { statusCode, result } = await kbnServer.inject({
method: 'POST',
url: '/api/kibana/settings/defaultIndex',
payload: {
value: defaultIndex
}
});
expect(statusCode).to.be(200);
assertSinonMatch(result, {
settings: {
buildNum: {
userValue: sinon.match.number
},
defaultIndex: {
userValue: defaultIndex
}
}
});
});
});
describe('setMany route', () => {
it('returns a 200 and all values including updates', async () => {
const { kbnServer } = await setup();
const defaultIndex = chance.word();
const { statusCode, result } = await kbnServer.inject({
method: 'POST',
url: '/api/kibana/settings',
payload: {
changes: {
defaultIndex
}
}
});
expect(statusCode).to.be(200);
assertSinonMatch(result, {
settings: {
buildNum: {
userValue: sinon.match.number
},
defaultIndex: {
userValue: defaultIndex
}
}
});
});
});
describe('delete route', () => {
it('returns a 200 and deletes the setting', async () => {
const defaultIndex = chance.word({ length: 10 });
const { kbnServer, uiSettings } = await setup({
initialSettings: { defaultIndex }
});
expect(await uiSettings.get('defaultIndex')).to.be(defaultIndex);
const { statusCode, result } = await kbnServer.inject({
method: 'DELETE',
url: '/api/kibana/settings/defaultIndex'
});
expect(statusCode).to.be(200);
assertSinonMatch(result, {
settings: {
buildNum: {
userValue: sinon.match.number
}
}
});
});
});
}

View file

@ -0,0 +1,77 @@
import expect from 'expect.js';
import {
getServices,
chance,
assertDocMissingResponse
} from './lib';
export function docMissingSuite() {
async function setup() {
const { kbnServer, savedObjectsClient } = getServices();
// delete all config docs
const { saved_objects: objs } = await savedObjectsClient.find({ type: 'config' });
for (const obj of objs) {
await savedObjectsClient.delete(obj.type, obj.id);
}
return { kbnServer };
}
describe('get route', () => {
it('returns a 200 with empty values', async () => {
const { kbnServer } = await setup();
const { statusCode, result } = await kbnServer.inject({
method: 'GET',
url: '/api/kibana/settings'
});
expect(statusCode).to.be(200);
expect(result).to.eql({ settings: {} });
});
});
describe('set route', () => {
it('returns a 404', async () => {
const { kbnServer } = await setup();
assertDocMissingResponse(await kbnServer.inject({
method: 'POST',
url: '/api/kibana/settings/defaultIndex',
payload: {
value: chance.word()
}
}));
});
});
describe('setMany route', () => {
it('returns a 404', async () => {
const { kbnServer } = await setup();
assertDocMissingResponse(await kbnServer.inject({
method: 'POST',
url: '/api/kibana/settings',
payload: {
changes: {
defaultIndex: chance.word()
}
}
}));
});
});
describe('delete route', () => {
it('returns a 404', async () => {
const { kbnServer } = await setup();
assertDocMissingResponse(await kbnServer.inject({
method: 'DELETE',
url: '/api/kibana/settings/defaultIndex'
}));
});
});
}

View file

@ -0,0 +1,23 @@
import {
startServers,
stopServers,
} from './lib';
import { docExistsSuite } from './doc_exists';
import { docMissingSuite } from './doc_missing';
import { indexMissingSuite } from './index_missing';
describe('uiSettings/routes', function () {
this.slow(2000);
this.timeout(10000);
// these tests rely on getting sort of lucky with
// the healthcheck, so we retry if they fail
this.retries(3);
before(startServers);
describe('doc exists', docExistsSuite);
describe('doc missing', docMissingSuite);
describe('index missing', indexMissingSuite);
after(stopServers);
});

View file

@ -0,0 +1,116 @@
import expect from 'expect.js';
import {
getServices,
chance,
assertDocMissingResponse
} from './lib';
export function indexMissingSuite() {
beforeEach(async function () {
const { kbnServer } = getServices();
await kbnServer.server.plugins.elasticsearch.waitUntilReady();
});
function getNumberOfShards(index) {
return parseInt(Object.values(index)[0].settings.index.number_of_shards, 10);
}
async function getIndex(callCluster, indexName) {
return await callCluster('indices.get', {
index: indexName,
});
}
async function setup() {
const { callCluster, kbnServer } = getServices();
const indexName = kbnServer.config.get('kibana.index');
const initialIndex = await getIndex(callCluster, indexName);
await callCluster('indices.delete', {
index: indexName,
});
return {
kbnServer,
// an incorrect number of shards is how we determine when the index was not created by Kibana,
// but automatically by writing to es when index didn't exist
async assertInvalidKibanaIndex() {
const index = await getIndex(callCluster, indexName);
expect(getNumberOfShards(index))
.to.not.be(getNumberOfShards(initialIndex));
}
};
}
afterEach(async () => {
const { kbnServer, callCluster } = getServices();
await callCluster('indices.delete', {
index: kbnServer.config.get('kibana.index'),
ignore: 404
});
});
describe('get route', () => {
it('returns a 200 and with empty values', async () => {
const { kbnServer } = await setup();
const { statusCode, result } = await kbnServer.inject({
method: 'GET',
url: '/api/kibana/settings'
});
expect(statusCode).to.be(200);
expect(result).to.eql({ settings: {} });
});
});
describe('set route', () => {
it('creates an invalid Kibana index and returns a 404 document missing error', async () => {
const { kbnServer, assertInvalidKibanaIndex } = await setup();
assertDocMissingResponse(await kbnServer.inject({
method: 'POST',
url: '/api/kibana/settings/defaultIndex',
payload: {
value: chance.word()
}
}));
await assertInvalidKibanaIndex();
});
});
describe('setMany route', () => {
it('creates an invalid Kibana index and returns a 404 document missing error', async () => {
const { kbnServer, assertInvalidKibanaIndex } = await setup();
assertDocMissingResponse(await kbnServer.inject({
method: 'POST',
url: '/api/kibana/settings',
payload: {
changes: {
defaultIndex: chance.word()
}
}
}));
await assertInvalidKibanaIndex();
});
});
describe('delete route', () => {
it('creates an invalid Kibana index and returns a 404 document missing error', async () => {
const { kbnServer, assertInvalidKibanaIndex } = await setup();
assertDocMissingResponse(await kbnServer.inject({
method: 'DELETE',
url: '/api/kibana/settings/defaultIndex'
}));
await assertInvalidKibanaIndex();
});
});
}

View file

@ -0,0 +1,16 @@
import sinon from 'sinon';
export function assertSinonMatch(value, match) {
const stub = sinon.stub();
stub(value);
sinon.assert.calledWithExactly(stub, match);
}
export function assertDocMissingResponse({ result }) {
assertSinonMatch(result, {
statusCode: 404,
error: 'Not Found',
message: sinon.match('document_missing_exception')
.and(sinon.match('document missing'))
});
}

View file

@ -0,0 +1,3 @@
import Chance from 'chance';
export const chance = new Chance();

View file

@ -0,0 +1,14 @@
export {
startServers,
getServices,
stopServers
} from './servers';
export {
chance
} from './chance';
export {
assertSinonMatch,
assertDocMissingResponse
} from './assert';

View file

@ -0,0 +1,53 @@
import { createEsTestCluster } from '../../../../../test_utils/es';
import * as kbnTestServer from '../../../../../test_utils/kbn_server';
let kbnServer;
let services;
const es = createEsTestCluster({
name: 'ui_settings/routes'
});
export async function startServers() {
this.timeout(es.getStartTimeout());
await es.start();
kbnServer = kbnTestServer.createServerWithCorePlugins();
await kbnServer.ready();
await kbnServer.server.plugins.elasticsearch.waitUntilReady();
}
export function getServices() {
if (services) {
return services;
}
const callCluster = es.getCallCluster();
const savedObjectsClient = kbnServer.server.savedObjectsClientFactory({
callCluster,
});
const uiSettings = kbnServer.server.uiSettingsServiceFactory({
savedObjectsClient,
});
services = {
kbnServer,
callCluster,
savedObjectsClient,
uiSettings
};
return services;
}
export async function stopServers() {
services = null;
if (kbnServer) {
await kbnServer.close();
kbnServer = null;
}
await es.stop();
}

View file

@ -0,0 +1,17 @@
async function handleRequest(request) {
const { key } = request.params;
const uiSettings = request.getUiSettingsService();
await uiSettings.remove(key);
return {
settings: await uiSettings.getUserProvided()
};
}
export const deleteRoute = {
path: '/api/kibana/settings/{key}',
method: 'DELETE',
handler(request, reply) {
reply(handleRequest(request));
}
};

View file

@ -0,0 +1,14 @@
async function handleRequest(request) {
const uiSettings = request.getUiSettingsService();
return {
settings: await uiSettings.getUserProvided()
};
}
export const getRoute = {
path: '/api/kibana/settings',
method: 'GET',
handler: function (request, reply) {
reply(handleRequest(request));
}
};

View file

@ -0,0 +1,4 @@
export { deleteRoute } from './delete';
export { getRoute } from './get';
export { setManyRoute } from './set_many';
export { setRoute } from './set';

View file

@ -0,0 +1,31 @@
import Joi from 'joi';
async function handleRequest(request) {
const { key } = request.params;
const { value } = request.payload;
const uiSettings = request.getUiSettingsService();
await uiSettings.set(key, value);
return {
settings: await uiSettings.getUserProvided()
};
}
export const setRoute = {
path: '/api/kibana/settings/{key}',
method: 'POST',
config: {
validate: {
params: Joi.object().keys({
key: Joi.string().required(),
}).default(),
payload: Joi.object().keys({
value: Joi.any().required()
}).required()
},
handler(request, reply) {
reply(handleRequest(request));
}
}
};

View file

@ -0,0 +1,26 @@
import Joi from 'joi';
async function handleRequest(request) {
const { changes } = request.payload;
const uiSettings = request.getUiSettingsService();
await uiSettings.setMany(changes);
return {
settings: await uiSettings.getUserProvided()
};
}
export const setManyRoute = {
path: '/api/kibana/settings',
method: 'POST',
config: {
validate: {
payload: Joi.object().keys({
changes: Joi.object().unknown(true).required()
}).required()
},
handler(request, reply) {
reply(handleRequest(request));
}
}
};

View file

@ -2,6 +2,12 @@ import { uiSettingsServiceFactory } from './ui_settings_service_factory';
import { getUiSettingsServiceForRequest } from './ui_settings_service_for_request';
import { mirrorStatus } from './mirror_status';
import { UiExportsConsumer } from './ui_exports_consumer';
import {
deleteRoute,
getRoute,
setManyRoute,
setRoute,
} from './routes';
export function uiSettingsMixin(kbnServer, server, config) {
const status = kbnServer.status.create('ui settings');
@ -56,4 +62,9 @@ export function uiSettingsMixin(kbnServer, server, config) {
server.uiSettings has been removed, see https://github.com/elastic/kibana/pull/12243.
`);
});
server.route(deleteRoute);
server.route(getRoute);
server.route(setManyRoute);
server.route(setRoute);
}

View file

@ -1,5 +1,4 @@
import { defaultsDeep, noop } from 'lodash';
import { errors as esErrors } from 'elasticsearch';
function hydrateUserSettings(userSettings) {
return Object.keys(userSettings)
@ -106,11 +105,18 @@ export class UiSettingsService {
ignore401Errors = false
} = options;
const {
isNotFoundError,
isForbiddenError,
isEsUnavailableError,
isNotAuthorizedError
} = this._savedObjectsClient.errors;
const isIgnorableError = error => (
error instanceof esErrors[404] ||
error instanceof esErrors[403] ||
error instanceof esErrors.NoConnections ||
(ignore401Errors && error instanceof esErrors[401])
isNotFoundError(error) ||
isForbiddenError(error) ||
isEsUnavailableError(error) ||
(ignore401Errors && isNotAuthorizedError(error))
);
try {

View file

@ -1,20 +1,6 @@
import { get } from 'lodash';
import toPath from 'lodash/internal/toPath';
import { createCallCluster } from '../../../../src/test_utils/es';
import { SavedObjectsClient } from '../../../../src/server/saved_objects';
function createCallCluster(es) {
return function callCluster(method, params) {
const path = toPath(method);
const contextPath = path.slice(0, -1);
const action = get(es, path);
const context = contextPath.length ? get(es, contextPath) : es;
return action.call(context, params);
};
}
export class KibanaServerUiSettings {
constructor(log, es, kibanaIndex, kibanaVersion) {
this._log = log;