[Search] Session server side functional tests (#86152) (#87210)

* OSS search functional tests

* x-pack tests

* Session tests

* Update search.ts

* Update session.ts

* Update test/api_integration/apis/search/search.ts

Co-authored-by: Lukas Olson <olson.lukas@gmail.com>

* reportServerError

* Improve response error codes

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Lukas Olson <olson.lukas@gmail.com>

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Lukas Olson <olson.lukas@gmail.com>
This commit is contained in:
Liza Katz 2021-01-05 02:45:28 +02:00 committed by GitHub
parent 19e96960b6
commit 485667511e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 656 additions and 121 deletions

View file

@ -25,6 +25,7 @@ import type { SearchUsage } from '../collectors';
import { getDefaultSearchParams, getShardTimeout, shimAbortSignal } from './request_utils';
import { toKibanaSearchResponse } from './response_utils';
import { searchUsageObserver } from '../collectors/usage';
import { KbnServerError } from '../../../../kibana_utils/server';
export const esSearchStrategyProvider = (
config$: Observable<SharedGlobalConfig>,
@ -35,7 +36,7 @@ export const esSearchStrategyProvider = (
// Only default index pattern type is supported here.
// See data_enhanced for other type support.
if (request.indexType) {
throw new Error(`Unsupported index pattern type ${request.indexType}`);
throw new KbnServerError(`Unsupported index pattern type ${request.indexType}`, 400);
}
const search = async () => {

View file

@ -23,6 +23,7 @@ import { IRouter } from 'src/core/server';
import { SearchRouteDependencies } from '../search_service';
import { getCallMsearch } from './call_msearch';
import { reportServerError } from '../../../../kibana_utils/server';
/**
* The msearch route takes in an array of searches, each consisting of header
@ -69,15 +70,7 @@ export function registerMsearchRoute(router: IRouter, deps: SearchRouteDependenc
const response = await callMsearch({ body: request.body });
return res.ok(response);
} catch (err) {
return res.customError({
statusCode: err.statusCode || 500,
body: {
message: err.message,
attributes: {
error: err.body?.error || err.message,
},
},
});
return reportServerError(res, err);
}
}
);

View file

@ -22,6 +22,7 @@ import { schema } from '@kbn/config-schema';
import type { IRouter } from 'src/core/server';
import { getRequestAbortedSignal } from '../../lib';
import { shimHitsTotal } from './shim_hits_total';
import { reportServerError } from '../../../../kibana_utils/server';
export function registerSearchRoute(router: IRouter): void {
router.post(
@ -74,15 +75,7 @@ export function registerSearchRoute(router: IRouter): void {
},
});
} catch (err) {
return res.customError({
statusCode: err.statusCode || 500,
body: {
message: err.message,
attributes: {
error: err.body?.error || err.message,
},
},
});
return reportServerError(res, err);
}
}
);
@ -106,15 +99,7 @@ export function registerSearchRoute(router: IRouter): void {
await context.search!.cancel(id, { strategy });
return res.ok();
} catch (err) {
return res.customError({
statusCode: err.statusCode,
body: {
message: err.message,
attributes: {
error: err.body.error,
},
},
});
return reportServerError(res, err);
}
}
);

View file

@ -73,6 +73,7 @@ import {
import { aggShardDelay } from '../../common/search/aggs/buckets/shard_delay_fn';
import { ConfigSchema } from '../../config';
import { SessionService, IScopedSessionService, ISessionService } from './session';
import { KbnServerError } from '../../../kibana_utils/server';
declare module 'src/core/server' {
interface RequestHandlerContext {
@ -305,7 +306,13 @@ export class SearchService implements Plugin<ISearchSetup, ISearchStart> {
private cancel = (id: string, options: ISearchOptions, deps: SearchStrategyDependencies) => {
const strategy = this.getSearchStrategy(options.strategy);
return strategy.cancel ? strategy.cancel(id, options, deps) : Promise.resolve();
if (!strategy.cancel) {
throw new KbnServerError(
`Search strategy ${options.strategy} doesn't support cancellations`,
400
);
}
return strategy.cancel(id, options, deps);
};
private getSearchStrategy = <
@ -317,7 +324,7 @@ export class SearchService implements Plugin<ISearchSetup, ISearchStart> {
this.logger.debug(`Get strategy ${name}`);
const strategy = this.searchStrategies[name];
if (!strategy) {
throw new Error(`Search strategy ${name} not found`);
throw new KbnServerError(`Search strategy ${name} not found`, 404);
}
return strategy;
};

View file

@ -28,3 +28,5 @@ export {
Set,
url,
} from '../common';
export { KbnServerError, reportServerError } from './report_server_error';

View file

@ -0,0 +1,39 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { KibanaResponseFactory } from 'kibana/server';
import { KbnError } from '../common';
export class KbnServerError extends KbnError {
constructor(message: string, public readonly statusCode: number) {
super(message);
}
}
export function reportServerError(res: KibanaResponseFactory, err: any) {
return res.customError({
statusCode: err.statusCode ?? 500,
body: {
message: err.message,
attributes: {
error: err.body?.error || err.message,
},
},
});
}

View file

@ -22,5 +22,6 @@ import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ loadTestFile }: FtrProviderContext) {
describe('search', () => {
loadTestFile(require.resolve('./search'));
loadTestFile(require.resolve('./msearch'));
});
}

View file

@ -0,0 +1,88 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
describe('msearch', () => {
describe('post', () => {
it('should return 200 when correctly formatted searches are provided', async () =>
await supertest
.post(`/internal/_msearch`)
.send({
searches: [
{
header: { index: 'foo' },
body: {
query: {
match_all: {},
},
},
},
],
})
.expect(200));
it('should return 400 if you provide malformed content', async () =>
await supertest
.post(`/internal/_msearch`)
.send({
foo: false,
})
.expect(400));
it('should require you to provide an index for each request', async () =>
await supertest
.post(`/internal/_msearch`)
.send({
searches: [
{ header: { index: 'foo' }, body: {} },
{ header: {}, body: {} },
],
})
.expect(400));
it('should not require optional params', async () =>
await supertest
.post(`/internal/_msearch`)
.send({
searches: [{ header: { index: 'foo' }, body: {} }],
})
.expect(200));
it('should allow passing preference as a string', async () =>
await supertest
.post(`/internal/_msearch`)
.send({
searches: [{ header: { index: 'foo', preference: '_custom' }, body: {} }],
})
.expect(200));
it('should allow passing preference as a number', async () =>
await supertest
.post(`/internal/_msearch`)
.send({
searches: [{ header: { index: 'foo', preference: 123 }, body: {} }],
})
.expect(200));
});
});
}

View file

@ -17,72 +17,101 @@
* under the License.
*/
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
describe('msearch', () => {
describe('search', () => {
describe('post', () => {
it('should return 200 when correctly formatted searches are provided', async () =>
await supertest
.post(`/internal/_msearch`)
it('should return 200 when correctly formatted searches are provided', async () => {
const resp = await supertest
.post(`/internal/search/es`)
.send({
searches: [
{
header: { index: 'foo' },
body: {
query: {
match_all: {},
},
params: {
body: {
query: {
match_all: {},
},
},
],
},
})
.expect(200));
.expect(200);
it('should return 400 if you provide malformed content', async () =>
await supertest
.post(`/internal/_msearch`)
.send({
foo: false,
})
.expect(400));
expect(resp.body.isPartial).to.be(false);
expect(resp.body.isRunning).to.be(false);
expect(resp.body).to.have.property('rawResponse');
});
it('should require you to provide an index for each request', async () =>
it('should return 404 when if no strategy is provided', async () =>
await supertest
.post(`/internal/_msearch`)
.post(`/internal/search`)
.send({
searches: [
{ header: { index: 'foo' }, body: {} },
{ header: {}, body: {} },
],
body: {
query: {
match_all: {},
},
},
})
.expect(400));
.expect(404));
it('should not require optional params', async () =>
await supertest
.post(`/internal/_msearch`)
it('should return 404 when if unknown strategy is provided', async () => {
const resp = await supertest
.post(`/internal/search/banana`)
.send({
searches: [{ header: { index: 'foo' }, body: {} }],
body: {
query: {
match_all: {},
},
},
})
.expect(200));
.expect(404);
expect(resp.body.message).to.contain('banana not found');
});
it('should allow passing preference as a string', async () =>
await supertest
.post(`/internal/_msearch`)
it('should return 400 when index type is provided in OSS', async () => {
const resp = await supertest
.post(`/internal/search/es`)
.send({
searches: [{ header: { index: 'foo', preference: '_custom' }, body: {} }],
indexType: 'baad',
params: {
body: {
query: {
match_all: {},
},
},
},
})
.expect(200));
.expect(400);
it('should allow passing preference as a number', async () =>
expect(resp.body.message).to.contain('Unsupported index pattern');
});
it('should return 400 with a bad body', async () => {
await supertest
.post(`/internal/_msearch`)
.post(`/internal/search/es`)
.send({
searches: [{ header: { index: 'foo', preference: 123 }, body: {} }],
params: {
body: {
index: 'nope nope',
bad_query: [],
},
},
})
.expect(200));
.expect(400);
});
});
describe('delete', () => {
it('should return 404 when no search id provided', async () => {
await supertest.delete(`/internal/search/es`).send().expect(404);
});
it('should return 400 when trying a delete on a non supporting strategy', async () => {
const resp = await supertest.delete(`/internal/search/es/123`).send().expect(400);
expect(resp.body.message).to.contain("Search strategy es doesn't support cancellations");
});
});
});
}

View file

@ -6,6 +6,7 @@
import { schema } from '@kbn/config-schema';
import { IRouter } from 'src/core/server';
import { reportServerError } from '../../../../../src/plugins/kibana_utils/server';
export function registerSessionRoutes(router: IRouter): void {
router.post(
@ -48,15 +49,7 @@ export function registerSessionRoutes(router: IRouter): void {
body: response,
});
} catch (err) {
return res.customError({
statusCode: err.statusCode || 500,
body: {
message: err.message,
attributes: {
error: err.body?.error || err.message,
},
},
});
return reportServerError(res, err);
}
}
);
@ -78,16 +71,9 @@ export function registerSessionRoutes(router: IRouter): void {
return res.ok({
body: response,
});
} catch (err) {
return res.customError({
statusCode: err.statusCode || 500,
body: {
message: err.message,
attributes: {
error: err.body?.error || err.message,
},
},
});
} catch (e) {
const err = e.output?.payload || e;
return reportServerError(res, err);
}
}
);
@ -120,15 +106,7 @@ export function registerSessionRoutes(router: IRouter): void {
body: response,
});
} catch (err) {
return res.customError({
statusCode: err.statusCode || 500,
body: {
message: err.message,
attributes: {
error: err.body?.error || err.message,
},
},
});
return reportServerError(res, err);
}
}
);
@ -148,16 +126,9 @@ export function registerSessionRoutes(router: IRouter): void {
await context.search!.session.delete(id);
return res.ok();
} catch (err) {
return res.customError({
statusCode: err.statusCode || 500,
body: {
message: err.message,
attributes: {
error: err.body?.error || err.message,
},
},
});
} catch (e) {
const err = e.output?.payload || e;
return reportServerError(res, err);
}
}
);
@ -185,15 +156,7 @@ export function registerSessionRoutes(router: IRouter): void {
body: response,
});
} catch (err) {
return res.customError({
statusCode: err.statusCode || 500,
body: {
message: err.message,
attributes: {
error: err.body?.error || err.message,
},
},
});
return reportServerError(res, err);
}
}
);

View file

@ -33,6 +33,7 @@ import {
} from './request_utils';
import { toAsyncKibanaSearchResponse } from './response_utils';
import { AsyncSearchResponse } from './types';
import { KbnServerError } from '../../../../../src/plugins/kibana_utils/server';
export const enhancedEsSearchStrategyProvider = (
config$: Observable<SharedGlobalConfig>,
@ -98,9 +99,13 @@ export const enhancedEsSearchStrategyProvider = (
search: (request, options: IAsyncSearchOptions, deps) => {
logger.debug(`search ${JSON.stringify(request.params) || request.id}`);
return request.indexType !== 'rollup'
? asyncSearch(request, options, deps)
: from(rollupSearch(request, options, deps));
if (request.indexType === undefined) {
return asyncSearch(request, options, deps);
} else if (request.indexType === 'rollup') {
return from(rollupSearch(request, options, deps));
} else {
throw new KbnServerError('Unknown indexType', 400);
}
},
cancel: async (id, options, { esClient }) => {
logger.debug(`cancel ${id}`);

View file

@ -10,6 +10,7 @@ export default function ({ loadTestFile }: FtrProviderContext) {
describe('apis', function () {
this.tags('ciGroup6');
loadTestFile(require.resolve('./search'));
loadTestFile(require.resolve('./es'));
loadTestFile(require.resolve('./security'));
loadTestFile(require.resolve('./spaces'));

View file

@ -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 { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ loadTestFile }: FtrProviderContext) {
describe('search', () => {
loadTestFile(require.resolve('./search'));
loadTestFile(require.resolve('./session'));
});
}

View file

@ -0,0 +1,279 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const esArchiver = getService('esArchiver');
describe('search', () => {
describe('post', () => {
it('should return 200 with final response if wait_for_completion_timeout is long enough', async () => {
const resp = await supertest
.post(`/internal/search/ese`)
.set('kbn-xsrf', 'foo')
.send({
params: {
body: {
query: {
match_all: {},
},
},
wait_for_completion_timeout: '1000s',
},
})
.expect(200);
const { id } = resp.body;
expect(id).to.be(undefined);
expect(resp.body.isPartial).to.be(false);
expect(resp.body.isRunning).to.be(false);
expect(resp.body).to.have.property('rawResponse');
});
it('should return 200 with partial response if wait_for_completion_timeout is not long enough', async () => {
const resp = await supertest
.post(`/internal/search/ese`)
.set('kbn-xsrf', 'foo')
.send({
params: {
body: {
query: {
match_all: {},
},
},
wait_for_completion_timeout: '1ms',
},
})
.expect(200);
const { id } = resp.body;
expect(id).not.to.be(undefined);
expect(resp.body.isPartial).to.be(true);
expect(resp.body.isRunning).to.be(true);
expect(resp.body).to.have.property('rawResponse');
});
it('should retrieve results with id', async () => {
const resp = await supertest
.post(`/internal/search/ese`)
.set('kbn-xsrf', 'foo')
.send({
params: {
body: {
query: {
match_all: {},
},
},
wait_for_completion_timeout: '1ms',
},
})
.expect(200);
const { id } = resp.body;
await new Promise((resolve) => setTimeout(resolve, 2000));
const resp2 = await supertest
.post(`/internal/search/ese/${id}`)
.set('kbn-xsrf', 'foo')
.send({})
.expect(200);
expect(resp2.body.id).not.to.be(undefined);
expect(resp2.body.isPartial).to.be(false);
expect(resp2.body.isRunning).to.be(false);
});
it('should return 400 when unknown index type is provided', async () => {
const resp = await supertest
.post(`/internal/search/ese`)
.set('kbn-xsrf', 'foo')
.send({
indexType: 'baad',
params: {
body: {
query: {
match_all: {},
},
},
},
})
.expect(400);
expect(resp.body.message).to.contain('Unknown indexType');
});
it('should return 400 if invalid id is provided', async () => {
const resp = await supertest
.post(`/internal/search/ese/123`)
.set('kbn-xsrf', 'foo')
.send({
params: {
body: {
query: {
match_all: {},
},
},
},
})
.expect(400);
expect(resp.body.message).to.contain('illegal_argument_exception');
});
it('should return 404 if unkown id is provided', async () => {
const resp = await supertest
.post(
`/internal/search/ese/FkxOb21iV1g2VGR1S2QzaWVtRU9fMVEbc3JWeWc1VHlUdDZ6MENxcXlYVG1Fdzo2NDg4`
)
.set('kbn-xsrf', 'foo')
.send({
params: {
body: {
query: {
match_all: {},
},
},
},
})
.expect(404);
expect(resp.body.message).to.contain('resource_not_found_exception');
});
it('should return 400 with a bad body', async () => {
await supertest
.post(`/internal/search/ese`)
.set('kbn-xsrf', 'foo')
.send({
params: {
body: {
index: 'nope nope',
bad_query: [],
},
},
})
.expect(400);
});
});
describe('rollup', () => {
before(async () => {
await esArchiver.load('hybrid/rollup');
});
after(async () => {
await esArchiver.unload('hybrid/rollup');
});
it('should return 400 if rollup search is called without index', async () => {
const resp = await supertest
.post(`/internal/search/ese`)
.set('kbn-xsrf', 'foo')
.send({
indexType: 'rollup',
params: {
body: {
query: {
match_all: {},
},
},
},
})
.expect(400);
expect(resp.body.message).to.contain('illegal_argument_exception');
});
it('should return 400 if rollup search is without non-existent index', async () => {
const resp = await supertest
.post(`/internal/search/ese`)
.set('kbn-xsrf', 'foo')
.send({
indexType: 'rollup',
params: {
index: 'banana',
body: {
query: {
match_all: {},
},
},
},
})
.expect(400);
expect(resp.body.message).to.contain('illegal_argument_exception');
});
it('should rollup search', async () => {
await supertest
.post(`/internal/search/ese`)
.set('kbn-xsrf', 'foo')
.send({
indexType: 'rollup',
params: {
index: 'rollup_logstash',
size: 0,
body: {
query: {
match_all: {},
},
},
},
})
.expect(200);
});
});
describe('delete', () => {
it('should return 404 when no search id provided', async () => {
await supertest.delete(`/internal/search/ese`).set('kbn-xsrf', 'foo').send().expect(404);
});
it('should return 400 when trying a delete a bad id', async () => {
const resp = await supertest
.delete(`/internal/search/ese/123`)
.set('kbn-xsrf', 'foo')
.send()
.expect(400);
expect(resp.body.message).to.contain('illegal_argument_exception');
});
it('should delete a search', async () => {
const resp = await supertest
.post(`/internal/search/ese`)
.set('kbn-xsrf', 'foo')
.send({
params: {
body: {
query: {
match_all: {},
},
},
wait_for_completion_timeout: '1ms',
},
})
.expect(200);
const { id } = resp.body;
await supertest
.delete(`/internal/search/ese/${id}`)
.set('kbn-xsrf', 'foo')
.send()
.expect(200);
// try to re-fetch
await supertest
.post(`/internal/search/ese/${id}`)
.set('kbn-xsrf', 'foo')
.send({})
.expect(404);
});
});
});
}

View file

@ -0,0 +1,127 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
describe('search session', () => {
describe('session management', () => {
it('should create and get a session', async () => {
const sessionId = `my-session-${Math.random()}`;
await supertest
.post(`/internal/session`)
.set('kbn-xsrf', 'foo')
.send({
sessionId,
name: 'My Session',
appId: 'discover',
expires: '123',
urlGeneratorId: 'discover',
})
.expect(200);
await supertest.get(`/internal/session/${sessionId}`).set('kbn-xsrf', 'foo').expect(200);
});
it('should fail to delete an unknown session', async () => {
await supertest.delete(`/internal/session/123`).set('kbn-xsrf', 'foo').expect(404);
});
it('should create and delete a session', async () => {
const sessionId = `my-session-${Math.random()}`;
await supertest
.post(`/internal/session`)
.set('kbn-xsrf', 'foo')
.send({
sessionId,
name: 'My Session',
appId: 'discover',
expires: '123',
urlGeneratorId: 'discover',
})
.expect(200);
await supertest.delete(`/internal/session/${sessionId}`).set('kbn-xsrf', 'foo').expect(200);
await supertest.get(`/internal/session/${sessionId}`).set('kbn-xsrf', 'foo').expect(404);
});
it('should sync search ids into session', async () => {
const sessionId = `my-session-${Math.random()}`;
// run search
const searchRes1 = await supertest
.post(`/internal/search/ese`)
.set('kbn-xsrf', 'foo')
.send({
sessionId,
params: {
body: {
query: {
term: {
agent: '1',
},
},
},
wait_for_completion_timeout: '1ms',
},
})
.expect(200);
const { id: id1 } = searchRes1.body;
// create session
await supertest
.post(`/internal/session`)
.set('kbn-xsrf', 'foo')
.send({
sessionId,
name: 'My Session',
appId: 'discover',
expires: '123',
urlGeneratorId: 'discover',
})
.expect(200);
// run search
const searchRes2 = await supertest
.post(`/internal/search/ese`)
.set('kbn-xsrf', 'foo')
.send({
sessionId,
params: {
body: {
query: {
match_all: {},
},
},
wait_for_completion_timeout: '1ms',
},
})
.expect(200);
const { id: id2 } = searchRes2.body;
// wait 10 seconds for ids to be synced
// TODO: make the refresh interval dynamic, so we can speed it up!
await new Promise((resolve) => setTimeout(resolve, 10000));
const resp = await supertest
.get(`/internal/session/${sessionId}`)
.set('kbn-xsrf', 'foo')
.expect(200);
const { idMapping } = resp.body.attributes;
expect(Object.values(idMapping)).to.contain(id1);
expect(Object.values(idMapping)).to.contain(id2);
});
});
});
}

View file

@ -30,6 +30,7 @@ export async function getApiIntegrationConfig({ readConfigFile }: FtrConfigProvi
'--telemetry.optIn=true',
'--xpack.fleet.enabled=true',
'--xpack.fleet.agents.pollingRequestTimeout=5000', // 5 seconds
'--xpack.data_enhanced.search.sendToBackground.enabled=true', // enable WIP send to background UI
],
},
esTestCluster: {