mirror of
https://github.com/elastic/kibana.git
synced 2025-06-27 18:51:07 -04:00
[Playground][Backend] Saving Playground CRUD (#217761)
## Summary This PR creates CRUD endpoints for search playgrounds. This will enable us to make new pages for saved playgrounds that are shared in a space. ## Notes Usages of `ALL_SAVED_OBJECT_INDICES` had to be updated to include `ignore_unavailable` since a new index was added for search solution saved objects, but these are not always registered when search plugins are disabled. Because of this refresh and other calls using `ALL_SAVED_OBJECT_INDICES` were failing when the new `.kibana_search_solution` index did not exist. ### Checklist - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md) - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [x] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Gerard Soldevila <gerard.soldevila@elastic.co> Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
parent
bea20769c9
commit
f7b28d7b5f
45 changed files with 2725 additions and 38 deletions
|
@ -168,6 +168,7 @@ enabled:
|
||||||
- x-pack/test/api_integration/apis/monitoring_collection/config.ts
|
- x-pack/test/api_integration/apis/monitoring_collection/config.ts
|
||||||
- x-pack/test/api_integration/apis/search/config.ts
|
- x-pack/test/api_integration/apis/search/config.ts
|
||||||
- x-pack/test/api_integration/apis/searchprofiler/config.ts
|
- x-pack/test/api_integration/apis/searchprofiler/config.ts
|
||||||
|
- x-pack/test/api_integration/apis/search_playground/config.ts
|
||||||
- x-pack/test/api_integration/apis/security/config.ts
|
- x-pack/test/api_integration/apis/security/config.ts
|
||||||
- x-pack/test/api_integration/apis/spaces/config.ts
|
- x-pack/test/api_integration/apis/spaces/config.ts
|
||||||
- x-pack/test/api_integration/apis/stats/config.ts
|
- x-pack/test/api_integration/apis/stats/config.ts
|
||||||
|
|
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
|
@ -2103,6 +2103,7 @@ x-pack/test/api_integration/apis/management/index_management/inference_endpoints
|
||||||
/x-pack/test_serverless/functional/page_objects/svl_api_keys.ts @elastic/search-kibana
|
/x-pack/test_serverless/functional/page_objects/svl_api_keys.ts @elastic/search-kibana
|
||||||
/x-pack/test_serverless/functional/page_objects/svl_search_* @elastic/search-kibana
|
/x-pack/test_serverless/functional/page_objects/svl_search_* @elastic/search-kibana
|
||||||
/x-pack/test/functional_search/ @elastic/search-kibana
|
/x-pack/test/functional_search/ @elastic/search-kibana
|
||||||
|
/x-pack/test/api_integration/apis/search_playground/ @elastic/search-kibana
|
||||||
|
|
||||||
# workchat
|
# workchat
|
||||||
/x-pack/test_serverless/api_integration/test_suites/chat @elastic/search-kibana @elastic/workchat-eng
|
/x-pack/test_serverless/api_integration/test_suites/chat @elastic/search-kibana @elastic/workchat-eng
|
||||||
|
|
|
@ -927,6 +927,9 @@
|
||||||
"username"
|
"username"
|
||||||
],
|
],
|
||||||
"search-telemetry": [],
|
"search-telemetry": [],
|
||||||
|
"search_playground": [
|
||||||
|
"name"
|
||||||
|
],
|
||||||
"security-ai-prompt": [
|
"security-ai-prompt": [
|
||||||
"description",
|
"description",
|
||||||
"model",
|
"model",
|
||||||
|
|
|
@ -3069,6 +3069,19 @@
|
||||||
"dynamic": false,
|
"dynamic": false,
|
||||||
"properties": {}
|
"properties": {}
|
||||||
},
|
},
|
||||||
|
"search_playground": {
|
||||||
|
"dynamic": false,
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"fields": {
|
||||||
|
"keyword": {
|
||||||
|
"type": "keyword"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"security-ai-prompt": {
|
"security-ai-prompt": {
|
||||||
"dynamic": false,
|
"dynamic": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|
|
@ -11,4 +11,4 @@ export { registerCoreObjectTypes } from './registration';
|
||||||
|
|
||||||
// set minimum number of registered saved objects to ensure no object types are removed after 8.8
|
// set minimum number of registered saved objects to ensure no object types are removed after 8.8
|
||||||
// declared in internal implementation exclicilty to prevent unintended changes.
|
// declared in internal implementation exclicilty to prevent unintended changes.
|
||||||
export const SAVED_OBJECT_TYPES_COUNT = 129 as const;
|
export const SAVED_OBJECT_TYPES_COUNT = 130 as const;
|
||||||
|
|
|
@ -64,6 +64,7 @@ export {
|
||||||
ANALYTICS_SAVED_OBJECT_INDEX,
|
ANALYTICS_SAVED_OBJECT_INDEX,
|
||||||
USAGE_COUNTERS_SAVED_OBJECT_INDEX,
|
USAGE_COUNTERS_SAVED_OBJECT_INDEX,
|
||||||
ALL_SAVED_OBJECT_INDICES,
|
ALL_SAVED_OBJECT_INDICES,
|
||||||
|
SEARCH_SOLUTION_SAVED_OBJECT_INDEX,
|
||||||
} from './src/saved_objects_index_pattern';
|
} from './src/saved_objects_index_pattern';
|
||||||
export type {
|
export type {
|
||||||
SavedObjectsType,
|
SavedObjectsType,
|
||||||
|
|
|
@ -21,6 +21,7 @@ export const ALERTING_CASES_SAVED_OBJECT_INDEX = `${MAIN_SAVED_OBJECT_INDEX}_ale
|
||||||
export const SECURITY_SOLUTION_SAVED_OBJECT_INDEX = `${MAIN_SAVED_OBJECT_INDEX}_security_solution`;
|
export const SECURITY_SOLUTION_SAVED_OBJECT_INDEX = `${MAIN_SAVED_OBJECT_INDEX}_security_solution`;
|
||||||
export const ANALYTICS_SAVED_OBJECT_INDEX = `${MAIN_SAVED_OBJECT_INDEX}_analytics`;
|
export const ANALYTICS_SAVED_OBJECT_INDEX = `${MAIN_SAVED_OBJECT_INDEX}_analytics`;
|
||||||
export const USAGE_COUNTERS_SAVED_OBJECT_INDEX = `${MAIN_SAVED_OBJECT_INDEX}_usage_counters`;
|
export const USAGE_COUNTERS_SAVED_OBJECT_INDEX = `${MAIN_SAVED_OBJECT_INDEX}_usage_counters`;
|
||||||
|
export const SEARCH_SOLUTION_SAVED_OBJECT_INDEX = `${MAIN_SAVED_OBJECT_INDEX}_search_solution`;
|
||||||
|
|
||||||
export const ALL_SAVED_OBJECT_INDICES = [
|
export const ALL_SAVED_OBJECT_INDICES = [
|
||||||
MAIN_SAVED_OBJECT_INDEX,
|
MAIN_SAVED_OBJECT_INDEX,
|
||||||
|
@ -30,4 +31,5 @@ export const ALL_SAVED_OBJECT_INDICES = [
|
||||||
SECURITY_SOLUTION_SAVED_OBJECT_INDEX,
|
SECURITY_SOLUTION_SAVED_OBJECT_INDEX,
|
||||||
ANALYTICS_SAVED_OBJECT_INDEX,
|
ANALYTICS_SAVED_OBJECT_INDEX,
|
||||||
USAGE_COUNTERS_SAVED_OBJECT_INDEX,
|
USAGE_COUNTERS_SAVED_OBJECT_INDEX,
|
||||||
|
SEARCH_SOLUTION_SAVED_OBJECT_INDEX,
|
||||||
];
|
];
|
||||||
|
|
|
@ -158,6 +158,7 @@ describe('checking migration metadata changes on all registered SO types', () =>
|
||||||
"search": "0aa6eefb37edd3145be340a8b67779c2ca578b22",
|
"search": "0aa6eefb37edd3145be340a8b67779c2ca578b22",
|
||||||
"search-session": "b2fcd840e12a45039ada50b1355faeafa39876d1",
|
"search-session": "b2fcd840e12a45039ada50b1355faeafa39876d1",
|
||||||
"search-telemetry": "b568601618744720b5662946d3103e3fb75fe8ee",
|
"search-telemetry": "b568601618744720b5662946d3103e3fb75fe8ee",
|
||||||
|
"search_playground": "9e06ddbaad7c9eeb24b24c871b6b3df484d6c1ed",
|
||||||
"security-ai-prompt": "cc8ee5aaa9d001e89c131bbd5af6bc80bc271046",
|
"security-ai-prompt": "cc8ee5aaa9d001e89c131bbd5af6bc80bc271046",
|
||||||
"security-rule": "07abb4d7e707d91675ec0495c73816394c7b521f",
|
"security-rule": "07abb4d7e707d91675ec0495c73816394c7b521f",
|
||||||
"security-solution-signals-migration": "9d99715fe5246f19de2273ba77debd2446c36bb1",
|
"security-solution-signals-migration": "9d99715fe5246f19de2273ba77debd2446c36bb1",
|
||||||
|
|
|
@ -339,9 +339,13 @@ export const deleteSavedObjectIndices = async (
|
||||||
client: ElasticsearchClient,
|
client: ElasticsearchClient,
|
||||||
index: string[] = ALL_SAVED_OBJECT_INDICES
|
index: string[] = ALL_SAVED_OBJECT_INDICES
|
||||||
) => {
|
) => {
|
||||||
const indices = await client.indices.get({ index, allow_no_indices: true }, { ignore: [404] });
|
const res = await client.indices.get({ index, ignore_unavailable: true }, { ignore: [404] });
|
||||||
|
const indices = Object.keys(res);
|
||||||
|
if (!indices.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
return await client.indices.delete(
|
return await client.indices.delete(
|
||||||
{ index: Object.keys(indices), allow_no_indices: true },
|
{ index: indices, ignore_unavailable: true },
|
||||||
{ ignore: [404] }
|
{ ignore: [404] }
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -125,6 +125,7 @@ const previouslyRegisteredTypes = [
|
||||||
'search',
|
'search',
|
||||||
'search-session',
|
'search-session',
|
||||||
'search-telemetry',
|
'search-telemetry',
|
||||||
|
'search_playground',
|
||||||
'security-ai-prompt',
|
'security-ai-prompt',
|
||||||
'security-rule',
|
'security-rule',
|
||||||
'security-solution-signals-migration',
|
'security-solution-signals-migration',
|
||||||
|
|
|
@ -17,7 +17,13 @@ export async function emptyKibanaIndexAction({ client, log }: { client: Client;
|
||||||
const stats = createStats('emptyKibanaIndex', log);
|
const stats = createStats('emptyKibanaIndex', log);
|
||||||
|
|
||||||
await cleanSavedObjectIndices({ client, stats, log });
|
await cleanSavedObjectIndices({ client, stats, log });
|
||||||
await client.indices.refresh({ index: ALL_SAVED_OBJECT_INDICES });
|
// Refresh indices to prevent a race condition between a write and subsequent read operation. To
|
||||||
|
// fix it deterministically we have to refresh saved object indices and wait until it's done.
|
||||||
|
await client.indices.refresh({ index: ALL_SAVED_OBJECT_INDICES, ignore_unavailable: true });
|
||||||
|
|
||||||
|
// Additionally, we need to clear the cache to ensure that the next read operation will
|
||||||
|
// not return stale data.
|
||||||
|
await client.indices.clearCache({ index: ALL_SAVED_OBJECT_INDICES, ignore_unavailable: true });
|
||||||
|
|
||||||
return stats.toJSON();
|
return stats.toJSON();
|
||||||
}
|
}
|
||||||
|
|
|
@ -122,6 +122,7 @@ export async function cleanSavedObjectIndices({
|
||||||
const resp = await client.deleteByQuery(
|
const resp = await client.deleteByQuery(
|
||||||
{
|
{
|
||||||
index,
|
index,
|
||||||
|
ignore_unavailable: true,
|
||||||
refresh: true,
|
refresh: true,
|
||||||
query: {
|
query: {
|
||||||
bool: {
|
bool: {
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
* 2.0.
|
* 2.0.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { MockedVersionedRouter } from '@kbn/core-http-router-server-mocks';
|
||||||
import {
|
import {
|
||||||
IRouter,
|
IRouter,
|
||||||
KibanaRequest,
|
KibanaRequest,
|
||||||
|
@ -23,6 +24,7 @@ type PayloadType = 'params' | 'query' | 'body';
|
||||||
interface IMockRouter {
|
interface IMockRouter {
|
||||||
method: MethodType;
|
method: MethodType;
|
||||||
path: string;
|
path: string;
|
||||||
|
version?: string;
|
||||||
context?: jest.Mocked<RequestHandlerContext>;
|
context?: jest.Mocked<RequestHandlerContext>;
|
||||||
}
|
}
|
||||||
interface IMockRouterRequest {
|
interface IMockRouterRequest {
|
||||||
|
@ -36,14 +38,21 @@ export class MockRouter {
|
||||||
public router!: jest.Mocked<IRouter>;
|
public router!: jest.Mocked<IRouter>;
|
||||||
public method: MethodType;
|
public method: MethodType;
|
||||||
public path: string;
|
public path: string;
|
||||||
|
public version?: string;
|
||||||
public context: jest.Mocked<RequestHandlerContext>;
|
public context: jest.Mocked<RequestHandlerContext>;
|
||||||
public payload?: PayloadType;
|
public payload?: PayloadType;
|
||||||
public response = httpServerMock.createResponseFactory();
|
public response = httpServerMock.createResponseFactory();
|
||||||
|
|
||||||
constructor({ method, path, context = {} as jest.Mocked<RequestHandlerContext> }: IMockRouter) {
|
constructor({
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
version,
|
||||||
|
context = {} as jest.Mocked<RequestHandlerContext>,
|
||||||
|
}: IMockRouter) {
|
||||||
this.createRouter();
|
this.createRouter();
|
||||||
this.method = method;
|
this.method = method;
|
||||||
this.path = path;
|
this.path = path;
|
||||||
|
this.version = version;
|
||||||
this.context = context;
|
this.context = context;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -84,6 +93,20 @@ export class MockRouter {
|
||||||
};
|
};
|
||||||
|
|
||||||
private findRouteRegistration = () => {
|
private findRouteRegistration = () => {
|
||||||
|
if (this.version) {
|
||||||
|
const mockedRoute = (this.router.versioned as MockedVersionedRouter).getRoute(
|
||||||
|
this.method,
|
||||||
|
this.path
|
||||||
|
);
|
||||||
|
if (mockedRoute.versions[this.version]) {
|
||||||
|
return [
|
||||||
|
mockedRoute.versions[this.version].config,
|
||||||
|
mockedRoute.versions[this.version].handler,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
throw new Error('No matching version routes registered.');
|
||||||
|
}
|
||||||
|
|
||||||
const routerCalls = this.router[this.method].mock.calls as any[];
|
const routerCalls = this.router[this.method].mock.calls as any[];
|
||||||
if (!routerCalls.length) throw new Error('No routes registered.');
|
if (!routerCalls.length) throw new Error('No routes registered.');
|
||||||
|
|
||||||
|
|
|
@ -21,3 +21,9 @@ export const DEFAULT_PAGINATION: Pagination = {
|
||||||
size: 10,
|
size: 10,
|
||||||
total: 0,
|
total: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export enum ROUTE_VERSIONS {
|
||||||
|
v1 = '1',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PLAYGROUND_SAVED_OBJECT_TYPE = 'search_playground';
|
||||||
|
|
|
@ -46,14 +46,22 @@ export interface QuerySourceFields {
|
||||||
skipped_fields: number;
|
skipped_fields: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BASE_API_PATH = '/internal/search_playground';
|
||||||
|
|
||||||
export enum APIRoutes {
|
export enum APIRoutes {
|
||||||
POST_API_KEY = '/internal/search_playground/api_key',
|
BASE_API = BASE_API_PATH,
|
||||||
POST_CHAT_MESSAGE = '/internal/search_playground/chat',
|
POST_API_KEY = `${BASE_API_PATH}/api_key`,
|
||||||
POST_QUERY_SOURCE_FIELDS = '/internal/search_playground/query_source_fields',
|
POST_CHAT_MESSAGE = `${BASE_API_PATH}/chat`,
|
||||||
GET_INDICES = '/internal/search_playground/indices',
|
POST_QUERY_SOURCE_FIELDS = `${BASE_API_PATH}/query_source_fields`,
|
||||||
POST_SEARCH_QUERY = '/internal/search_playground/search',
|
GET_INDICES = `${BASE_API_PATH}/indices`,
|
||||||
GET_INDEX_MAPPINGS = '/internal/search_playground/mappings',
|
POST_SEARCH_QUERY = `${BASE_API_PATH}/search`,
|
||||||
POST_QUERY_TEST = '/internal/search_playground/query_test',
|
GET_INDEX_MAPPINGS = `${BASE_API_PATH}/mappings`,
|
||||||
|
POST_QUERY_TEST = `${BASE_API_PATH}/query_test`,
|
||||||
|
PUT_PLAYGROUND_CREATE = `${BASE_API_PATH}/playgrounds`,
|
||||||
|
PUT_PLAYGROUND_UPDATE = `${BASE_API_PATH}/playgrounds/{id}`,
|
||||||
|
GET_PLAYGROUND = `${BASE_API_PATH}/playgrounds/{id}`,
|
||||||
|
GET_PLAYGROUNDS = `${BASE_API_PATH}/playgrounds`,
|
||||||
|
DELETE_PLAYGROUND = `${BASE_API_PATH}/playgrounds/{id}`,
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum LLMs {
|
export enum LLMs {
|
||||||
|
@ -99,3 +107,49 @@ export interface QueryTestResponse {
|
||||||
documents?: Document[];
|
documents?: Document[];
|
||||||
searchResponse: SearchResponse;
|
searchResponse: SearchResponse;
|
||||||
}
|
}
|
||||||
|
export interface PlaygroundMetadata {
|
||||||
|
id: string;
|
||||||
|
createdAt?: string;
|
||||||
|
createdBy?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
updatedBy?: string;
|
||||||
|
}
|
||||||
|
export interface PlaygroundSavedObject {
|
||||||
|
name: string;
|
||||||
|
indices: string[];
|
||||||
|
queryFields: Record<string, string[] | undefined>;
|
||||||
|
elasticsearchQueryJSON: string;
|
||||||
|
userElasticsearchQueryJSON?: string;
|
||||||
|
prompt?: string;
|
||||||
|
citations?: boolean;
|
||||||
|
context?: {
|
||||||
|
sourceFields: Record<string, string[] | undefined>;
|
||||||
|
docSize: number;
|
||||||
|
};
|
||||||
|
summarizationModel?: {
|
||||||
|
connectorId: string;
|
||||||
|
modelId?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaygroundResponse {
|
||||||
|
_meta: PlaygroundMetadata;
|
||||||
|
data: PlaygroundSavedObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaygroundListObject {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
createdAt?: string;
|
||||||
|
createdBy?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
updatedBy?: string;
|
||||||
|
}
|
||||||
|
export interface PlaygroundListResponse {
|
||||||
|
_meta: {
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
items: PlaygroundListObject[];
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,40 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { SavedObjectsType } from '@kbn/core/server';
|
||||||
|
import { SEARCH_SOLUTION_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server';
|
||||||
|
import { PLAYGROUND_SAVED_OBJECT_TYPE } from '../../common';
|
||||||
|
import { playgroundAttributesSchema } from './schema/v1/v1';
|
||||||
|
|
||||||
|
export const createPlaygroundSavedObjectType = (): SavedObjectsType => ({
|
||||||
|
name: PLAYGROUND_SAVED_OBJECT_TYPE,
|
||||||
|
indexPattern: SEARCH_SOLUTION_SAVED_OBJECT_INDEX,
|
||||||
|
hidden: false,
|
||||||
|
namespaceType: 'multiple-isolated',
|
||||||
|
mappings: {
|
||||||
|
dynamic: false,
|
||||||
|
properties: {
|
||||||
|
name: {
|
||||||
|
type: 'text',
|
||||||
|
fields: {
|
||||||
|
keyword: {
|
||||||
|
type: 'keyword',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
modelVersions: {
|
||||||
|
1: {
|
||||||
|
changes: [],
|
||||||
|
schemas: {
|
||||||
|
forwardCompatibility: playgroundAttributesSchema.extends({}, { unknowns: 'ignore' }),
|
||||||
|
create: playgroundAttributesSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
|
@ -0,0 +1,35 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { schema } from '@kbn/config-schema';
|
||||||
|
|
||||||
|
export const playgroundAttributesSchema = schema.object({
|
||||||
|
name: schema.string(),
|
||||||
|
// Common fields
|
||||||
|
indices: schema.arrayOf(schema.string(), { minSize: 1 }),
|
||||||
|
queryFields: schema.recordOf(schema.string(), schema.arrayOf(schema.string(), { minSize: 1 })),
|
||||||
|
elasticsearchQueryJSON: schema.string(),
|
||||||
|
userElasticsearchQueryJSON: schema.maybe(schema.string()),
|
||||||
|
// Chat fields
|
||||||
|
prompt: schema.maybe(schema.string()),
|
||||||
|
citations: schema.maybe(schema.boolean()),
|
||||||
|
context: schema.maybe(
|
||||||
|
schema.object({
|
||||||
|
sourceFields: schema.recordOf(
|
||||||
|
schema.string(),
|
||||||
|
schema.arrayOf(schema.string(), { minSize: 1 })
|
||||||
|
),
|
||||||
|
docSize: schema.number({ defaultValue: 3, min: 1 }),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
summarizationModel: schema.maybe(
|
||||||
|
schema.object({
|
||||||
|
connectorId: schema.string(),
|
||||||
|
modelId: schema.maybe(schema.string()),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
});
|
|
@ -23,7 +23,8 @@ import {
|
||||||
SearchPlaygroundPluginStartDependencies,
|
SearchPlaygroundPluginStartDependencies,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { defineRoutes } from './routes';
|
import { defineRoutes } from './routes';
|
||||||
import { PLUGIN_ID, PLUGIN_NAME } from '../common';
|
import { PLUGIN_ID, PLUGIN_NAME, PLAYGROUND_SAVED_OBJECT_TYPE } from '../common';
|
||||||
|
import { createPlaygroundSavedObjectType } from './playground_saved_object/playground_saved_object';
|
||||||
|
|
||||||
export class SearchPlaygroundPlugin
|
export class SearchPlaygroundPlugin
|
||||||
implements
|
implements
|
||||||
|
@ -45,6 +46,9 @@ export class SearchPlaygroundPlugin
|
||||||
{ features }: SearchPlaygroundPluginSetupDependencies
|
{ features }: SearchPlaygroundPluginSetupDependencies
|
||||||
) {
|
) {
|
||||||
this.logger.debug('searchPlayground: Setup');
|
this.logger.debug('searchPlayground: Setup');
|
||||||
|
|
||||||
|
core.savedObjects.registerType(createPlaygroundSavedObjectType());
|
||||||
|
|
||||||
const router = core.http.createRouter();
|
const router = core.http.createRouter();
|
||||||
|
|
||||||
defineRoutes({ router, logger: this.logger, getStartServices: core.getStartServices });
|
defineRoutes({ router, logger: this.logger, getStartServices: core.getStartServices });
|
||||||
|
@ -66,16 +70,17 @@ export class SearchPlaygroundPlugin
|
||||||
api: [PLUGIN_ID],
|
api: [PLUGIN_ID],
|
||||||
catalogue: [PLUGIN_ID],
|
catalogue: [PLUGIN_ID],
|
||||||
savedObject: {
|
savedObject: {
|
||||||
all: [],
|
all: [PLAYGROUND_SAVED_OBJECT_TYPE],
|
||||||
read: [],
|
read: [PLAYGROUND_SAVED_OBJECT_TYPE],
|
||||||
},
|
},
|
||||||
ui: [],
|
ui: [],
|
||||||
},
|
},
|
||||||
read: {
|
read: {
|
||||||
disabled: true,
|
disabled: true,
|
||||||
|
api: [PLUGIN_ID],
|
||||||
savedObject: {
|
savedObject: {
|
||||||
all: [],
|
all: [],
|
||||||
read: [],
|
read: [PLAYGROUND_SAVED_OBJECT_TYPE],
|
||||||
},
|
},
|
||||||
ui: [],
|
ui: [],
|
||||||
},
|
},
|
||||||
|
|
|
@ -6,9 +6,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { schema } from '@kbn/config-schema';
|
import { schema } from '@kbn/config-schema';
|
||||||
import type { Logger } from '@kbn/logging';
|
|
||||||
import type { SearchRequest } from '@elastic/elasticsearch/lib/api/types';
|
import type { SearchRequest } from '@elastic/elasticsearch/lib/api/types';
|
||||||
import { IRouter, StartServicesAccessor } from '@kbn/core/server';
|
|
||||||
import { i18n } from '@kbn/i18n';
|
import { i18n } from '@kbn/i18n';
|
||||||
import { PLUGIN_ID } from '../common';
|
import { PLUGIN_ID } from '../common';
|
||||||
import { sendMessageEvent, SendMessageEventData } from './analytics/events';
|
import { sendMessageEvent, SendMessageEventData } from './analytics/events';
|
||||||
|
@ -19,10 +17,9 @@ import { errorHandler } from './utils/error_handler';
|
||||||
import { handleStreamResponse } from './utils/handle_stream_response';
|
import { handleStreamResponse } from './utils/handle_stream_response';
|
||||||
import {
|
import {
|
||||||
APIRoutes,
|
APIRoutes,
|
||||||
|
DefineRoutesOptions,
|
||||||
ElasticsearchRetrieverContentField,
|
ElasticsearchRetrieverContentField,
|
||||||
QueryTestResponse,
|
QueryTestResponse,
|
||||||
SearchPlaygroundPluginStart,
|
|
||||||
SearchPlaygroundPluginStartDependencies,
|
|
||||||
} from './types';
|
} from './types';
|
||||||
import { getChatParams } from './lib/get_chat_params';
|
import { getChatParams } from './lib/get_chat_params';
|
||||||
import { fetchIndices } from './lib/fetch_indices';
|
import { fetchIndices } from './lib/fetch_indices';
|
||||||
|
@ -32,6 +29,7 @@ import { ContextLimitError } from './lib/errors';
|
||||||
import { contextDocumentHitMapper } from './utils/context_document_mapper';
|
import { contextDocumentHitMapper } from './utils/context_document_mapper';
|
||||||
import { parseSourceFields } from './utils/parse_source_fields';
|
import { parseSourceFields } from './utils/parse_source_fields';
|
||||||
import { getErrorMessage } from '../common/errors';
|
import { getErrorMessage } from '../common/errors';
|
||||||
|
import { defineSavedPlaygroundRoutes } from './routes/saved_playgrounds';
|
||||||
|
|
||||||
const EMPTY_INDICES_ERROR_MESSAGE = i18n.translate(
|
const EMPTY_INDICES_ERROR_MESSAGE = i18n.translate(
|
||||||
'xpack.searchPlayground.serverErrors.emptyIndices',
|
'xpack.searchPlayground.serverErrors.emptyIndices',
|
||||||
|
@ -60,18 +58,9 @@ export function parseElasticsearchQuery(esQuery: string) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function defineRoutes({
|
export function defineRoutes(routeOptions: DefineRoutesOptions) {
|
||||||
logger,
|
const { logger, router, getStartServices } = routeOptions;
|
||||||
router,
|
|
||||||
getStartServices,
|
|
||||||
}: {
|
|
||||||
logger: Logger;
|
|
||||||
router: IRouter;
|
|
||||||
getStartServices: StartServicesAccessor<
|
|
||||||
SearchPlaygroundPluginStartDependencies,
|
|
||||||
SearchPlaygroundPluginStart
|
|
||||||
>;
|
|
||||||
}) {
|
|
||||||
router.post(
|
router.post(
|
||||||
{
|
{
|
||||||
path: APIRoutes.POST_QUERY_SOURCE_FIELDS,
|
path: APIRoutes.POST_QUERY_SOURCE_FIELDS,
|
||||||
|
@ -496,4 +485,6 @@ export function defineRoutes({
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
defineSavedPlaygroundRoutes(routeOptions);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,680 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { loggingSystemMock } from '@kbn/core-logging-server-mocks';
|
||||||
|
import {
|
||||||
|
RequestHandlerContext,
|
||||||
|
SavedObjectsErrorHelpers,
|
||||||
|
StartServicesAccessor,
|
||||||
|
} from '@kbn/core/server';
|
||||||
|
import { coreMock } from '@kbn/core/server/mocks';
|
||||||
|
import { MockRouter } from '../../__mocks__/router.mock';
|
||||||
|
import {
|
||||||
|
APIRoutes,
|
||||||
|
SearchPlaygroundPluginStart,
|
||||||
|
SearchPlaygroundPluginStartDependencies,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
import { ROUTE_VERSIONS } from '../../common';
|
||||||
|
import { defineSavedPlaygroundRoutes } from './saved_playgrounds';
|
||||||
|
|
||||||
|
describe('Search Playground - Playgrounds API', () => {
|
||||||
|
const mockLogger = loggingSystemMock.createLogger().get();
|
||||||
|
let mockRouter: MockRouter;
|
||||||
|
const mockSOClient = {
|
||||||
|
create: jest.fn(),
|
||||||
|
find: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
get: jest.fn(),
|
||||||
|
};
|
||||||
|
const mockCore = {
|
||||||
|
savedObjects: { client: mockSOClient },
|
||||||
|
};
|
||||||
|
|
||||||
|
let context: jest.Mocked<RequestHandlerContext>;
|
||||||
|
let mockGetStartServices: jest.Mocked<
|
||||||
|
StartServicesAccessor<SearchPlaygroundPluginStartDependencies, SearchPlaygroundPluginStart>
|
||||||
|
>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
const coreStart = coreMock.createStart();
|
||||||
|
mockGetStartServices = jest.fn().mockResolvedValue([coreStart, {}, {}]);
|
||||||
|
|
||||||
|
context = {
|
||||||
|
core: Promise.resolve(mockCore),
|
||||||
|
} as unknown as jest.Mocked<RequestHandlerContext>;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /internal/search_playground/playgrounds', () => {
|
||||||
|
describe('v1', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockRouter = new MockRouter({
|
||||||
|
context,
|
||||||
|
method: 'get',
|
||||||
|
path: APIRoutes.GET_PLAYGROUNDS,
|
||||||
|
version: ROUTE_VERSIONS.v1,
|
||||||
|
});
|
||||||
|
defineSavedPlaygroundRoutes({
|
||||||
|
logger: mockLogger,
|
||||||
|
router: mockRouter.router,
|
||||||
|
getStartServices: mockGetStartServices,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call the find method of the saved objects client', async () => {
|
||||||
|
mockSOClient.find.mockResolvedValue({
|
||||||
|
total: 1,
|
||||||
|
page: 1,
|
||||||
|
per_page: 10,
|
||||||
|
saved_objects: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
type: 'search_playground',
|
||||||
|
created_at: '2023-10-01T00:00:00Z',
|
||||||
|
updated_at: '2023-10-01T00:00:00Z',
|
||||||
|
attributes: {
|
||||||
|
name: 'Playground 1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
mockRouter.callRoute({
|
||||||
|
query: {
|
||||||
|
page: 1,
|
||||||
|
size: 10,
|
||||||
|
sortField: 'created_at',
|
||||||
|
sortOrder: 'desc',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).resolves.toEqual(undefined);
|
||||||
|
|
||||||
|
expect(mockSOClient.find).toHaveBeenCalledWith({
|
||||||
|
type: 'search_playground',
|
||||||
|
perPage: 10,
|
||||||
|
page: 1,
|
||||||
|
sortField: 'created_at',
|
||||||
|
sortOrder: 'desc',
|
||||||
|
});
|
||||||
|
expect(mockRouter.response.ok).toHaveBeenCalledWith({
|
||||||
|
body: {
|
||||||
|
_meta: {
|
||||||
|
page: 1,
|
||||||
|
size: 10,
|
||||||
|
total: 1,
|
||||||
|
},
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Playground 1',
|
||||||
|
createdAt: '2023-10-01T00:00:00Z',
|
||||||
|
createdBy: undefined,
|
||||||
|
updatedAt: '2023-10-01T00:00:00Z',
|
||||||
|
updatedBy: undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('uses query parameters to call the saved objects search', async () => {
|
||||||
|
mockSOClient.find.mockResolvedValue({
|
||||||
|
total: 1,
|
||||||
|
page: 1,
|
||||||
|
per_page: 10,
|
||||||
|
saved_objects: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
type: 'search_playground',
|
||||||
|
created_at: '2023-10-01T00:00:00Z',
|
||||||
|
updated_at: '2023-10-01T00:00:00Z',
|
||||||
|
attributes: {
|
||||||
|
name: 'Playground 1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
mockRouter.callRoute({
|
||||||
|
query: {
|
||||||
|
page: 2,
|
||||||
|
size: 15,
|
||||||
|
sortField: 'updated_at',
|
||||||
|
sortOrder: 'asc',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).resolves.toEqual(undefined);
|
||||||
|
|
||||||
|
expect(mockSOClient.find).toHaveBeenCalledWith({
|
||||||
|
type: 'search_playground',
|
||||||
|
perPage: 15,
|
||||||
|
page: 2,
|
||||||
|
sortField: 'updated_at',
|
||||||
|
sortOrder: 'asc',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('handles saved object client errors', async () => {
|
||||||
|
mockSOClient.find.mockRejectedValue(
|
||||||
|
SavedObjectsErrorHelpers.decorateForbiddenError(new Error('Forbidden'))
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
mockRouter.callRoute({
|
||||||
|
query: {
|
||||||
|
page: 1,
|
||||||
|
size: 10,
|
||||||
|
sortField: 'created_at',
|
||||||
|
sortOrder: 'desc',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).resolves.toEqual(undefined);
|
||||||
|
expect(mockRouter.response.customError).toHaveBeenCalledWith({
|
||||||
|
statusCode: 403,
|
||||||
|
body: {
|
||||||
|
message: 'Forbidden',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('re-raises errors from the saved objects client', async () => {
|
||||||
|
const error = new Error('Saved object error');
|
||||||
|
mockSOClient.find.mockRejectedValue(error);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
mockRouter.callRoute({
|
||||||
|
query: {
|
||||||
|
page: 1,
|
||||||
|
size: 10,
|
||||||
|
sortField: 'created_at',
|
||||||
|
sortOrder: 'desc',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).rejects.toThrowError(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('GET /internal/search_playground/playgrounds/{id}', () => {
|
||||||
|
describe('v1', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockRouter = new MockRouter({
|
||||||
|
context,
|
||||||
|
method: 'get',
|
||||||
|
path: APIRoutes.GET_PLAYGROUND,
|
||||||
|
version: ROUTE_VERSIONS.v1,
|
||||||
|
});
|
||||||
|
defineSavedPlaygroundRoutes({
|
||||||
|
logger: mockLogger,
|
||||||
|
router: mockRouter.router,
|
||||||
|
getStartServices: mockGetStartServices,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('should parse and return SO response', async () => {
|
||||||
|
mockSOClient.get.mockResolvedValue({
|
||||||
|
id: '1',
|
||||||
|
type: 'search_playground',
|
||||||
|
created_at: '2023-10-01T00:00:00Z',
|
||||||
|
updated_at: '2023-10-01T00:00:00Z',
|
||||||
|
attributes: {
|
||||||
|
name: 'Playground 1',
|
||||||
|
indices: ['index1'],
|
||||||
|
queryFields: { index1: ['field1'] },
|
||||||
|
elasticsearchQueryJSON: `{"retriever":{"standard":{"query":{"multi_match":{"query":"{query}","fields":["field1"]}}}}}`,
|
||||||
|
},
|
||||||
|
references: [],
|
||||||
|
version: '1',
|
||||||
|
namespaces: ['default'],
|
||||||
|
migrationVersion: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
mockRouter.callRoute({
|
||||||
|
params: {
|
||||||
|
id: '1',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).resolves.toEqual(undefined);
|
||||||
|
|
||||||
|
expect(mockSOClient.get).toHaveBeenCalledWith('search_playground', '1');
|
||||||
|
expect(mockRouter.response.ok).toHaveBeenCalledWith({
|
||||||
|
body: {
|
||||||
|
_meta: {
|
||||||
|
id: '1',
|
||||||
|
createdAt: '2023-10-01T00:00:00Z',
|
||||||
|
updatedAt: '2023-10-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
name: 'Playground 1',
|
||||||
|
indices: ['index1'],
|
||||||
|
queryFields: { index1: ['field1'] },
|
||||||
|
elasticsearchQueryJSON: `{"retriever":{"standard":{"query":{"multi_match":{"query":"{query}","fields":["field1"]}}}}}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('should handle 404s from so client', async () => {
|
||||||
|
mockSOClient.get.mockResolvedValue({
|
||||||
|
error: {
|
||||||
|
statusCode: 404,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
mockRouter.callRoute({
|
||||||
|
params: {
|
||||||
|
id: '1',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).resolves.toEqual(undefined);
|
||||||
|
|
||||||
|
expect(mockRouter.response.notFound).toHaveBeenCalledWith({
|
||||||
|
body: {
|
||||||
|
message: '1 playground not found',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('should reformat other errors from so client', async () => {
|
||||||
|
mockSOClient.get.mockResolvedValue({
|
||||||
|
error: {
|
||||||
|
statusCode: 401,
|
||||||
|
message: 'Unauthorized',
|
||||||
|
error: 'some error message',
|
||||||
|
metadata: {
|
||||||
|
foo: 'bar',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
mockRouter.callRoute({
|
||||||
|
params: {
|
||||||
|
id: '1',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).resolves.toEqual(undefined);
|
||||||
|
|
||||||
|
expect(mockRouter.response.customError).toHaveBeenCalledWith({
|
||||||
|
statusCode: 401,
|
||||||
|
body: {
|
||||||
|
message: 'Unauthorized',
|
||||||
|
attributes: {
|
||||||
|
error: 'some error message',
|
||||||
|
foo: 'bar',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('should handle thrown errors from so client', async () => {
|
||||||
|
mockSOClient.get.mockRejectedValue(
|
||||||
|
SavedObjectsErrorHelpers.decorateForbiddenError(new Error('Unauthorized'))
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
mockRouter.callRoute({
|
||||||
|
params: {
|
||||||
|
id: '1',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).resolves.toEqual(undefined);
|
||||||
|
|
||||||
|
expect(mockRouter.response.customError).toHaveBeenCalledWith({
|
||||||
|
statusCode: 403,
|
||||||
|
body: {
|
||||||
|
message: 'Unauthorized',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('should re-throw exceptions from so client', async () => {
|
||||||
|
const error = new Error('Saved object error');
|
||||||
|
mockSOClient.get.mockRejectedValue(error);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
mockRouter.callRoute({
|
||||||
|
params: {
|
||||||
|
id: '1',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).rejects.toThrowError(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('PUT /internal/search_playground/playgrounds', () => {
|
||||||
|
describe('v1', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockRouter = new MockRouter({
|
||||||
|
context,
|
||||||
|
method: 'put',
|
||||||
|
path: APIRoutes.PUT_PLAYGROUND_CREATE,
|
||||||
|
version: ROUTE_VERSIONS.v1,
|
||||||
|
});
|
||||||
|
defineSavedPlaygroundRoutes({
|
||||||
|
logger: mockLogger,
|
||||||
|
router: mockRouter.router,
|
||||||
|
getStartServices: mockGetStartServices,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('returns full response with ID for success', async () => {
|
||||||
|
mockSOClient.create.mockResolvedValue({
|
||||||
|
id: '1',
|
||||||
|
type: 'search_playground',
|
||||||
|
created_at: '2023-10-01T00:00:00Z',
|
||||||
|
updated_at: '2023-10-01T00:00:00Z',
|
||||||
|
attributes: {
|
||||||
|
name: 'Playground 1',
|
||||||
|
indices: ['index1'],
|
||||||
|
queryFields: { index1: ['field1'] },
|
||||||
|
elasticsearchQueryJSON: `{"retriever":{"standard":{"query":{"multi_match":{"query":"{query}","fields":["field1"]}}}}}`,
|
||||||
|
},
|
||||||
|
references: [],
|
||||||
|
version: '1',
|
||||||
|
namespaces: ['default'],
|
||||||
|
migrationVersion: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
mockRouter.callRoute({
|
||||||
|
body: {
|
||||||
|
name: 'Playground 1',
|
||||||
|
indices: ['index1'],
|
||||||
|
queryFields: { index1: ['field1'] },
|
||||||
|
elasticsearchQueryJSON: `{"retriever":{"standard":{"query":{"multi_match":{"query":"{query}","fields":["field1"]}}}}}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).resolves.toEqual(undefined);
|
||||||
|
|
||||||
|
expect(mockSOClient.create).toHaveBeenCalledWith('search_playground', {
|
||||||
|
name: 'Playground 1',
|
||||||
|
indices: ['index1'],
|
||||||
|
queryFields: { index1: ['field1'] },
|
||||||
|
elasticsearchQueryJSON: `{"retriever":{"standard":{"query":{"multi_match":{"query":"{query}","fields":["field1"]}}}}}`,
|
||||||
|
});
|
||||||
|
expect(mockRouter.response.ok).toHaveBeenCalledWith({
|
||||||
|
body: {
|
||||||
|
_meta: {
|
||||||
|
id: '1',
|
||||||
|
createdAt: '2023-10-01T00:00:00Z',
|
||||||
|
updatedAt: '2023-10-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
name: 'Playground 1',
|
||||||
|
indices: ['index1'],
|
||||||
|
queryFields: { index1: ['field1'] },
|
||||||
|
elasticsearchQueryJSON: `{"retriever":{"standard":{"query":{"multi_match":{"query":"{query}","fields":["field1"]}}}}}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('handles errors from the saved objects client', async () => {
|
||||||
|
mockSOClient.create.mockResolvedValue({
|
||||||
|
error: {
|
||||||
|
statusCode: 401,
|
||||||
|
message: 'Unauthorized',
|
||||||
|
error: 'some error message',
|
||||||
|
metadata: {
|
||||||
|
foo: 'bar',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
mockRouter.callRoute({
|
||||||
|
body: {
|
||||||
|
name: 'Playground 1',
|
||||||
|
indices: ['index1'],
|
||||||
|
queryFields: { index1: ['field1'] },
|
||||||
|
elasticsearchQueryJSON: `{"retriever":{"standard":{"query":{"multi_match":{"query":"{query}","fields":["field1"]}}}}}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).resolves.toEqual(undefined);
|
||||||
|
|
||||||
|
expect(mockRouter.response.customError).toHaveBeenCalledWith({
|
||||||
|
statusCode: 401,
|
||||||
|
body: {
|
||||||
|
message: 'Unauthorized',
|
||||||
|
attributes: {
|
||||||
|
error: 'some error message',
|
||||||
|
foo: 'bar',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('handles thrown errors from the saved objects client', async () => {
|
||||||
|
mockSOClient.create.mockRejectedValue(
|
||||||
|
SavedObjectsErrorHelpers.decorateForbiddenError(new Error('Forbidden'))
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
mockRouter.callRoute({
|
||||||
|
body: {
|
||||||
|
name: 'Playground 1',
|
||||||
|
indices: ['index1'],
|
||||||
|
queryFields: { index1: ['field1'] },
|
||||||
|
elasticsearchQueryJSON: `{"retriever":{"standard":{"query":{"multi_match":{"query":"{query}","fields":["field1"]}}}}}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).resolves.toEqual(undefined);
|
||||||
|
|
||||||
|
expect(mockRouter.response.customError).toHaveBeenCalledWith({
|
||||||
|
statusCode: 403,
|
||||||
|
body: {
|
||||||
|
message: 'Forbidden',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('re-throws exceptions from the saved objects client', async () => {
|
||||||
|
const error = new Error('Saved object error');
|
||||||
|
mockSOClient.create.mockRejectedValue(error);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
mockRouter.callRoute({
|
||||||
|
body: {
|
||||||
|
name: 'Playground 1',
|
||||||
|
indices: ['index1'],
|
||||||
|
queryFields: { index1: ['field1'] },
|
||||||
|
elasticsearchQueryJSON: `{"retriever":{"standard":{"query":{"multi_match":{"query":"{query}","fields":["field1"]}}}}}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).rejects.toThrowError(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('PUT /internal/search_playground/playgrounds/{id}', () => {
|
||||||
|
describe('v1', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockRouter = new MockRouter({
|
||||||
|
context,
|
||||||
|
method: 'put',
|
||||||
|
path: APIRoutes.PUT_PLAYGROUND_UPDATE,
|
||||||
|
version: ROUTE_VERSIONS.v1,
|
||||||
|
});
|
||||||
|
defineSavedPlaygroundRoutes({
|
||||||
|
logger: mockLogger,
|
||||||
|
router: mockRouter.router,
|
||||||
|
getStartServices: mockGetStartServices,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('returns empty response on success', async () => {
|
||||||
|
mockSOClient.update.mockResolvedValue({});
|
||||||
|
await expect(
|
||||||
|
mockRouter.callRoute({
|
||||||
|
params: {
|
||||||
|
id: '1',
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
name: 'Updated Playground',
|
||||||
|
indices: ['index1'],
|
||||||
|
queryFields: { index1: ['field1'] },
|
||||||
|
elasticsearchQueryJSON: `{"retriever":{"standard":{"query":{"multi_match":{"query":"{query}","fields":["field1"]}}}}}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).resolves.toEqual(undefined);
|
||||||
|
|
||||||
|
expect(mockSOClient.update).toHaveBeenCalledWith('search_playground', '1', {
|
||||||
|
name: 'Updated Playground',
|
||||||
|
indices: ['index1'],
|
||||||
|
queryFields: { index1: ['field1'] },
|
||||||
|
elasticsearchQueryJSON: `{"retriever":{"standard":{"query":{"multi_match":{"query":"{query}","fields":["field1"]}}}}}`,
|
||||||
|
});
|
||||||
|
expect(mockRouter.response.ok).toHaveBeenCalledWith();
|
||||||
|
});
|
||||||
|
it('handles errors from the saved objects client', async () => {
|
||||||
|
mockSOClient.update.mockResolvedValue({
|
||||||
|
error: {
|
||||||
|
statusCode: 401,
|
||||||
|
message: 'Unauthorized',
|
||||||
|
error: 'some error message',
|
||||||
|
metadata: {
|
||||||
|
foo: 'bar',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
mockRouter.callRoute({
|
||||||
|
params: {
|
||||||
|
id: '1',
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
name: 'Updated Playground',
|
||||||
|
indices: ['index1'],
|
||||||
|
queryFields: { index1: ['field1'] },
|
||||||
|
elasticsearchQueryJSON: `{"retriever":{"standard":{"query":{"multi_match":{"query":"{query}","fields":["field1"]}}}}}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).resolves.toEqual(undefined);
|
||||||
|
expect(mockRouter.response.customError).toHaveBeenCalledWith({
|
||||||
|
statusCode: 401,
|
||||||
|
body: {
|
||||||
|
message: 'Unauthorized',
|
||||||
|
attributes: {
|
||||||
|
error: 'some error message',
|
||||||
|
foo: 'bar',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('handles saved object client errors', async () => {
|
||||||
|
mockSOClient.update.mockRejectedValue(
|
||||||
|
SavedObjectsErrorHelpers.createGenericNotFoundError('1', 'search_playground')
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
mockRouter.callRoute({
|
||||||
|
params: {
|
||||||
|
id: '1',
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
name: 'Updated Playground',
|
||||||
|
indices: ['index1'],
|
||||||
|
queryFields: { index1: ['field1'] },
|
||||||
|
elasticsearchQueryJSON: `{"retriever":{"standard":{"query":{"multi_match":{"query":"{query}","fields":["field1"]}}}}}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).resolves.toEqual(undefined);
|
||||||
|
|
||||||
|
expect(mockRouter.response.customError).toHaveBeenCalledWith({
|
||||||
|
statusCode: 404,
|
||||||
|
body: {
|
||||||
|
message: 'Saved object [1/search_playground] not found',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('re-throws exceptions from the saved objects client', async () => {
|
||||||
|
const error = new Error('Saved object error');
|
||||||
|
mockSOClient.update.mockRejectedValue(error);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
mockRouter.callRoute({
|
||||||
|
params: {
|
||||||
|
id: '1',
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
name: 'Updated Playground',
|
||||||
|
indices: ['index1'],
|
||||||
|
queryFields: { index1: ['field1'] },
|
||||||
|
elasticsearchQueryJSON: `{"retriever":{"standard":{"query":{"multi_match":{"query":"{query}","fields":["field1"]}}}}}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).rejects.toThrowError(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('DELETE /internal/search_playground/playgrounds/{id}', () => {
|
||||||
|
describe('v1', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockRouter = new MockRouter({
|
||||||
|
context,
|
||||||
|
method: 'delete',
|
||||||
|
path: APIRoutes.DELETE_PLAYGROUND,
|
||||||
|
version: ROUTE_VERSIONS.v1,
|
||||||
|
});
|
||||||
|
defineSavedPlaygroundRoutes({
|
||||||
|
logger: mockLogger,
|
||||||
|
router: mockRouter.router,
|
||||||
|
getStartServices: mockGetStartServices,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('returns empty response on success', async () => {
|
||||||
|
mockSOClient.delete.mockResolvedValue({});
|
||||||
|
await expect(
|
||||||
|
mockRouter.callRoute({
|
||||||
|
params: {
|
||||||
|
id: '1',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).resolves.toEqual(undefined);
|
||||||
|
|
||||||
|
expect(mockSOClient.delete).toHaveBeenCalledWith('search_playground', '1');
|
||||||
|
expect(mockRouter.response.ok).toHaveBeenCalledWith();
|
||||||
|
});
|
||||||
|
it('handles 404s from the saved objects client', async () => {
|
||||||
|
mockSOClient.delete.mockRejectedValue(
|
||||||
|
SavedObjectsErrorHelpers.createGenericNotFoundError('1', 'search_playground')
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
mockRouter.callRoute({
|
||||||
|
params: {
|
||||||
|
id: '1',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).resolves.toEqual(undefined);
|
||||||
|
|
||||||
|
expect(mockRouter.response.customError).toHaveBeenCalledWith({
|
||||||
|
statusCode: 404,
|
||||||
|
body: {
|
||||||
|
message: 'Saved object [1/search_playground] not found',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('handles errors from the saved objects client', async () => {
|
||||||
|
mockSOClient.delete.mockRejectedValue(
|
||||||
|
SavedObjectsErrorHelpers.decorateForbiddenError(new Error('Forbidden'))
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
mockRouter.callRoute({
|
||||||
|
params: {
|
||||||
|
id: '1',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).resolves.toEqual(undefined);
|
||||||
|
|
||||||
|
expect(mockRouter.response.customError).toHaveBeenCalledWith({
|
||||||
|
statusCode: 403,
|
||||||
|
body: {
|
||||||
|
message: 'Forbidden',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,305 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { schema } from '@kbn/config-schema';
|
||||||
|
import { i18n } from '@kbn/i18n';
|
||||||
|
import { PLUGIN_ID, ROUTE_VERSIONS, PLAYGROUND_SAVED_OBJECT_TYPE } from '../../common';
|
||||||
|
|
||||||
|
import {
|
||||||
|
APIRoutes,
|
||||||
|
DefineRoutesOptions,
|
||||||
|
PlaygroundListResponse,
|
||||||
|
PlaygroundResponse,
|
||||||
|
PlaygroundSavedObject,
|
||||||
|
} from '../types';
|
||||||
|
import { errorHandler } from '../utils/error_handler';
|
||||||
|
import { parsePlaygroundSO, parsePlaygroundSOList, validatePlayground } from '../utils/playgrounds';
|
||||||
|
import { playgroundAttributesSchema } from '../playground_saved_object/schema/v1/v1';
|
||||||
|
|
||||||
|
export const defineSavedPlaygroundRoutes = ({ logger, router }: DefineRoutesOptions) => {
|
||||||
|
router.versioned
|
||||||
|
.get({
|
||||||
|
access: 'internal',
|
||||||
|
path: APIRoutes.GET_PLAYGROUNDS,
|
||||||
|
security: {
|
||||||
|
authz: {
|
||||||
|
requiredPrivileges: [PLUGIN_ID],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.addVersion(
|
||||||
|
{
|
||||||
|
security: {
|
||||||
|
authz: {
|
||||||
|
requiredPrivileges: [PLUGIN_ID],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
validate: {
|
||||||
|
request: {
|
||||||
|
query: schema.object({
|
||||||
|
page: schema.number({ defaultValue: 1, min: 1 }),
|
||||||
|
size: schema.number({ defaultValue: 10, min: 1, max: 1000 }),
|
||||||
|
sortField: schema.string({
|
||||||
|
defaultValue: 'created_at',
|
||||||
|
}),
|
||||||
|
sortOrder: schema.oneOf([schema.literal('desc'), schema.literal('asc')], {
|
||||||
|
defaultValue: 'desc',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
version: ROUTE_VERSIONS.v1,
|
||||||
|
},
|
||||||
|
errorHandler(logger)(async (context, request, response) => {
|
||||||
|
const soClient = (await context.core).savedObjects.client;
|
||||||
|
const soPlaygrounds = await soClient.find<PlaygroundSavedObject>({
|
||||||
|
type: PLAYGROUND_SAVED_OBJECT_TYPE,
|
||||||
|
perPage: request.query.size,
|
||||||
|
page: request.query.page,
|
||||||
|
sortField: request.query.sortField,
|
||||||
|
sortOrder: request.query.sortOrder,
|
||||||
|
});
|
||||||
|
const body: PlaygroundListResponse = parsePlaygroundSOList(soPlaygrounds);
|
||||||
|
return response.ok({
|
||||||
|
body,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
router.versioned
|
||||||
|
.get({
|
||||||
|
access: 'internal',
|
||||||
|
path: APIRoutes.GET_PLAYGROUND,
|
||||||
|
security: {
|
||||||
|
authz: {
|
||||||
|
requiredPrivileges: [PLUGIN_ID],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.addVersion(
|
||||||
|
{
|
||||||
|
security: {
|
||||||
|
authz: {
|
||||||
|
requiredPrivileges: [PLUGIN_ID],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
validate: {
|
||||||
|
request: {
|
||||||
|
params: schema.object({
|
||||||
|
id: schema.string(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
version: ROUTE_VERSIONS.v1,
|
||||||
|
},
|
||||||
|
errorHandler(logger)(async (context, request, response) => {
|
||||||
|
const soClient = (await context.core).savedObjects.client;
|
||||||
|
const soPlayground = await soClient.get<PlaygroundSavedObject>(
|
||||||
|
PLAYGROUND_SAVED_OBJECT_TYPE,
|
||||||
|
request.params.id
|
||||||
|
);
|
||||||
|
if (soPlayground.error) {
|
||||||
|
if (soPlayground.error.statusCode === 404) {
|
||||||
|
return response.notFound({
|
||||||
|
body: {
|
||||||
|
message: i18n.translate('xpack.searchPlayground.savedPlaygrounds.notFoundError', {
|
||||||
|
defaultMessage: '{id} playground not found',
|
||||||
|
values: { id: request.params.id },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
logger.error(
|
||||||
|
i18n.translate('xpack.searchPlayground.savedPlaygrounds.getSOError', {
|
||||||
|
defaultMessage: 'SavedObject error getting search playground {id}',
|
||||||
|
values: { id: request.params.id },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return response.customError({
|
||||||
|
statusCode: soPlayground.error.statusCode,
|
||||||
|
body: {
|
||||||
|
message: soPlayground.error.message,
|
||||||
|
attributes: {
|
||||||
|
error: soPlayground.error.error,
|
||||||
|
...(soPlayground.error.metadata ?? {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const responseBody: PlaygroundResponse = parsePlaygroundSO(soPlayground);
|
||||||
|
return response.ok({
|
||||||
|
body: responseBody,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
// Create
|
||||||
|
router.versioned
|
||||||
|
.put({
|
||||||
|
access: 'internal',
|
||||||
|
path: APIRoutes.PUT_PLAYGROUND_CREATE,
|
||||||
|
security: {
|
||||||
|
authz: {
|
||||||
|
requiredPrivileges: [PLUGIN_ID],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.addVersion(
|
||||||
|
{
|
||||||
|
security: {
|
||||||
|
authz: {
|
||||||
|
requiredPrivileges: [PLUGIN_ID],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
version: ROUTE_VERSIONS.v1,
|
||||||
|
validate: {
|
||||||
|
request: {
|
||||||
|
body: playgroundAttributesSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
errorHandler(logger)(async (context, request, response) => {
|
||||||
|
// Validate playground request
|
||||||
|
const playground = request.body;
|
||||||
|
const validationErrors = validatePlayground(playground);
|
||||||
|
if (validationErrors && validationErrors.length > 0) {
|
||||||
|
return response.badRequest({
|
||||||
|
body: {
|
||||||
|
message: i18n.translate('xpack.searchPlayground.savedPlaygrounds.validationError', {
|
||||||
|
defaultMessage: 'Invalid playground request',
|
||||||
|
}),
|
||||||
|
attributes: {
|
||||||
|
errors: validationErrors,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const soClient = (await context.core).savedObjects.client;
|
||||||
|
const soPlayground = await soClient.create<PlaygroundSavedObject>(
|
||||||
|
PLAYGROUND_SAVED_OBJECT_TYPE,
|
||||||
|
playground
|
||||||
|
);
|
||||||
|
if (soPlayground.error) {
|
||||||
|
return response.customError({
|
||||||
|
statusCode: soPlayground.error.statusCode,
|
||||||
|
body: {
|
||||||
|
message: soPlayground.error.message,
|
||||||
|
attributes: {
|
||||||
|
error: soPlayground.error.error,
|
||||||
|
...(soPlayground.error.metadata ?? {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const responseBody: PlaygroundResponse = parsePlaygroundSO(soPlayground);
|
||||||
|
|
||||||
|
return response.ok({
|
||||||
|
body: responseBody,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update
|
||||||
|
router.versioned
|
||||||
|
.put({
|
||||||
|
access: 'internal',
|
||||||
|
path: APIRoutes.PUT_PLAYGROUND_UPDATE,
|
||||||
|
security: {
|
||||||
|
authz: {
|
||||||
|
requiredPrivileges: [PLUGIN_ID],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.addVersion(
|
||||||
|
{
|
||||||
|
security: {
|
||||||
|
authz: {
|
||||||
|
requiredPrivileges: [PLUGIN_ID],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
version: ROUTE_VERSIONS.v1,
|
||||||
|
validate: {
|
||||||
|
request: {
|
||||||
|
params: schema.object({
|
||||||
|
id: schema.string(),
|
||||||
|
}),
|
||||||
|
body: playgroundAttributesSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
errorHandler(logger)(async (context, request, response) => {
|
||||||
|
const playground = request.body;
|
||||||
|
const validationErrors = validatePlayground(playground);
|
||||||
|
if (validationErrors && validationErrors.length > 0) {
|
||||||
|
return response.badRequest({
|
||||||
|
body: {
|
||||||
|
message: i18n.translate('xpack.searchPlayground.savedPlaygrounds.validationError', {
|
||||||
|
defaultMessage: 'Invalid playground request',
|
||||||
|
}),
|
||||||
|
attributes: {
|
||||||
|
errors: validationErrors,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const soClient = (await context.core).savedObjects.client;
|
||||||
|
const soPlayground = await soClient.update<PlaygroundSavedObject>(
|
||||||
|
PLAYGROUND_SAVED_OBJECT_TYPE,
|
||||||
|
request.params.id,
|
||||||
|
playground
|
||||||
|
);
|
||||||
|
if (soPlayground.error) {
|
||||||
|
return response.customError({
|
||||||
|
statusCode: soPlayground.error.statusCode,
|
||||||
|
body: {
|
||||||
|
message: soPlayground.error.message,
|
||||||
|
attributes: {
|
||||||
|
error: soPlayground.error.error,
|
||||||
|
...(soPlayground.error.metadata ?? {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return response.ok();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
router.versioned
|
||||||
|
.delete({
|
||||||
|
access: 'internal',
|
||||||
|
path: APIRoutes.DELETE_PLAYGROUND,
|
||||||
|
security: {
|
||||||
|
authz: {
|
||||||
|
requiredPrivileges: [PLUGIN_ID],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.addVersion(
|
||||||
|
{
|
||||||
|
security: {
|
||||||
|
authz: {
|
||||||
|
requiredPrivileges: [PLUGIN_ID],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
version: ROUTE_VERSIONS.v1,
|
||||||
|
validate: {
|
||||||
|
request: {
|
||||||
|
params: schema.object({
|
||||||
|
id: schema.string(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
errorHandler(logger)(async (context, request, response) => {
|
||||||
|
const soClient = (await context.core).savedObjects.client;
|
||||||
|
await soClient.delete(PLAYGROUND_SAVED_OBJECT_TYPE, request.params.id);
|
||||||
|
return response.ok();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
|
@ -5,12 +5,14 @@
|
||||||
* 2.0.
|
* 2.0.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { Logger } from '@kbn/logging';
|
||||||
import type { PluginStartContract as ActionsPluginStartContract } from '@kbn/actions-plugin/server';
|
import type { PluginStartContract as ActionsPluginStartContract } from '@kbn/actions-plugin/server';
|
||||||
import type { CloudSetup, CloudStart } from '@kbn/cloud-plugin/server';
|
import type { CloudSetup, CloudStart } from '@kbn/cloud-plugin/server';
|
||||||
import type { FeaturesPluginSetup } from '@kbn/features-plugin/server';
|
import type { FeaturesPluginSetup } from '@kbn/features-plugin/server';
|
||||||
import type { InferenceServerStart } from '@kbn/inference-plugin/server';
|
import type { InferenceServerStart } from '@kbn/inference-plugin/server';
|
||||||
import type { Document } from '@langchain/core/documents';
|
import type { Document } from '@langchain/core/documents';
|
||||||
import type { SearchHit } from '@elastic/elasticsearch/lib/api/types';
|
import type { SearchHit } from '@elastic/elasticsearch/lib/api/types';
|
||||||
|
import type { IRouter, StartServicesAccessor } from '@kbn/core/server';
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||||
export interface SearchPlaygroundPluginSetup {}
|
export interface SearchPlaygroundPluginSetup {}
|
||||||
|
@ -34,3 +36,12 @@ export * from '../common/types';
|
||||||
export type HitDocMapper = (hit: SearchHit) => Document;
|
export type HitDocMapper = (hit: SearchHit) => Document;
|
||||||
|
|
||||||
export type ElasticsearchRetrieverContentField = string | Record<string, string | string[]>;
|
export type ElasticsearchRetrieverContentField = string | Record<string, string | string[]>;
|
||||||
|
|
||||||
|
export interface DefineRoutesOptions {
|
||||||
|
logger: Logger;
|
||||||
|
router: IRouter;
|
||||||
|
getStartServices: StartServicesAccessor<
|
||||||
|
SearchPlaygroundPluginStartDependencies,
|
||||||
|
SearchPlaygroundPluginStart
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { RequestHandlerWrapper } from '@kbn/core-http-server';
|
import { RequestHandlerWrapper } from '@kbn/core-http-server';
|
||||||
|
import { SavedObjectsErrorHelpers } from '@kbn/core/server';
|
||||||
import { KibanaServerError } from '@kbn/kibana-utils-plugin/common';
|
import { KibanaServerError } from '@kbn/kibana-utils-plugin/common';
|
||||||
import type { Logger } from '@kbn/logging';
|
import type { Logger } from '@kbn/logging';
|
||||||
|
|
||||||
|
@ -22,6 +23,14 @@ export const errorHandler: (logger: Logger) => RequestHandlerWrapper = (logger)
|
||||||
if (isKibanaServerError(e)) {
|
if (isKibanaServerError(e)) {
|
||||||
return response.customError({ statusCode: e.statusCode, body: e.message });
|
return response.customError({ statusCode: e.statusCode, body: e.message });
|
||||||
}
|
}
|
||||||
|
if (SavedObjectsErrorHelpers.isSavedObjectsClientError(e)) {
|
||||||
|
return response.customError({
|
||||||
|
statusCode: e.output.statusCode,
|
||||||
|
body: {
|
||||||
|
message: e.message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -0,0 +1,212 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { SavedObject, SavedObjectsFindResult } from '@kbn/core/server';
|
||||||
|
import { PLAYGROUND_SAVED_OBJECT_TYPE } from '../../common';
|
||||||
|
import { type PlaygroundSavedObject } from '../types';
|
||||||
|
import { validatePlayground, parsePlaygroundSO, parsePlaygroundSOList } from './playgrounds';
|
||||||
|
|
||||||
|
const defaultElasticsearchQueryJSON = `{"retriever":{"standard":{"query":{"multi_match":{"query":"{query}","fields":["field1"]}}}}}`;
|
||||||
|
const validSearchPlayground: PlaygroundSavedObject = {
|
||||||
|
name: 'Test Playground',
|
||||||
|
indices: ['index1'],
|
||||||
|
queryFields: { index1: ['field1'] },
|
||||||
|
elasticsearchQueryJSON: defaultElasticsearchQueryJSON,
|
||||||
|
};
|
||||||
|
const validChatPlayground: PlaygroundSavedObject = {
|
||||||
|
...validSearchPlayground,
|
||||||
|
prompt: 'Test prompt',
|
||||||
|
citations: true,
|
||||||
|
context: {
|
||||||
|
sourceFields: { index1: ['field1'] },
|
||||||
|
docSize: 3,
|
||||||
|
},
|
||||||
|
summarizationModel: {
|
||||||
|
connectorId: 'connectorId',
|
||||||
|
modelId: 'model',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Playground utils', () => {
|
||||||
|
describe('validatePlayground', () => {
|
||||||
|
it('should return an empty array when search playground is valid', () => {
|
||||||
|
const errors = validatePlayground(validSearchPlayground);
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
|
it('should return an empty array when chat playground is valid', () => {
|
||||||
|
const errors = validatePlayground(validChatPlayground);
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
|
it('should return an empty array when playground user elasticsearch query is valid', () => {
|
||||||
|
const playground: PlaygroundSavedObject = {
|
||||||
|
...validSearchPlayground,
|
||||||
|
userElasticsearchQueryJSON:
|
||||||
|
'{"query":{"multi_match":{"query":"{query}","fields":["field1"]}}}',
|
||||||
|
};
|
||||||
|
const errors = validatePlayground(playground);
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return an error when playground name is empty', () => {
|
||||||
|
const playground: PlaygroundSavedObject = {
|
||||||
|
...validSearchPlayground,
|
||||||
|
name: '',
|
||||||
|
};
|
||||||
|
expect(validatePlayground(playground)).toContain('Playground name cannot be empty');
|
||||||
|
playground.name = ' ';
|
||||||
|
expect(validatePlayground(playground)).toContain('Playground name cannot be empty');
|
||||||
|
});
|
||||||
|
it('should return an error when elasticsearchQuery is invalid JSON', () => {
|
||||||
|
const playground: PlaygroundSavedObject = {
|
||||||
|
...validSearchPlayground,
|
||||||
|
elasticsearchQueryJSON: '{invalidJson}',
|
||||||
|
};
|
||||||
|
expect(validatePlayground(playground)).toContain(
|
||||||
|
"Elasticsearch query JSON is invalid\nExpected property name or '}' in JSON at position 1"
|
||||||
|
);
|
||||||
|
playground.elasticsearchQueryJSON = 'invalidJson';
|
||||||
|
expect(validatePlayground(playground)).toContain(
|
||||||
|
`Elasticsearch query JSON is invalid\nUnexpected token 'i', "invalidJson" is not valid JSON`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it('should validate queryFields', () => {
|
||||||
|
const playground: PlaygroundSavedObject = {
|
||||||
|
...validSearchPlayground,
|
||||||
|
queryFields: { index1: ['field1', ''] },
|
||||||
|
};
|
||||||
|
expect(validatePlayground(playground)).toContain(
|
||||||
|
'Query field cannot be empty, index1 item 1 is empty'
|
||||||
|
);
|
||||||
|
playground.queryFields = { index1: [] };
|
||||||
|
expect(validatePlayground(playground)).toContain('Query fields cannot be empty');
|
||||||
|
playground.queryFields = { index2: ['field1'] };
|
||||||
|
expect(validatePlayground(playground)).toContain(
|
||||||
|
'Query fields index index2 does not match selected indices'
|
||||||
|
);
|
||||||
|
playground.queryFields = { index1: ['field1'] };
|
||||||
|
expect(validatePlayground(playground)).toEqual([]);
|
||||||
|
playground.queryFields = { index1: [''] };
|
||||||
|
expect(validatePlayground(playground)).toContain(
|
||||||
|
'Query field cannot be empty, index1 item 0 is empty'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it('should validate context sourceFields', () => {
|
||||||
|
const playground: PlaygroundSavedObject = {
|
||||||
|
...validChatPlayground,
|
||||||
|
context: {
|
||||||
|
sourceFields: { index1: ['field1', ''] },
|
||||||
|
docSize: 3,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(validatePlayground(playground)).toContain(
|
||||||
|
'Source field cannot be empty, index1 item 1 is empty'
|
||||||
|
);
|
||||||
|
playground.context!.sourceFields = { index1: [] };
|
||||||
|
expect(validatePlayground(playground)).toContain('Source fields cannot be empty');
|
||||||
|
playground.context!.sourceFields = { index2: ['field1'] };
|
||||||
|
expect(validatePlayground(playground)).toContain(
|
||||||
|
'Source fields index index2 does not match selected indices'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('parsePlaygroundSO', () => {
|
||||||
|
it('should parse saved object to api response', () => {
|
||||||
|
const savedObject: SavedObject<PlaygroundSavedObject> = {
|
||||||
|
id: 'my-fake-id',
|
||||||
|
type: PLAYGROUND_SAVED_OBJECT_TYPE,
|
||||||
|
created_at: '2023-10-01T00:00:00Z',
|
||||||
|
updated_at: '2023-10-01T00:00:00Z',
|
||||||
|
attributes: validChatPlayground,
|
||||||
|
references: [],
|
||||||
|
version: '1',
|
||||||
|
namespaces: ['default'],
|
||||||
|
migrationVersion: {},
|
||||||
|
coreMigrationVersion: '9.0.0',
|
||||||
|
};
|
||||||
|
expect(parsePlaygroundSO(savedObject)).toEqual({
|
||||||
|
_meta: {
|
||||||
|
id: 'my-fake-id',
|
||||||
|
createdAt: '2023-10-01T00:00:00Z',
|
||||||
|
updatedAt: '2023-10-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
...validChatPlayground,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('should include all available metadata in api response', () => {
|
||||||
|
const savedObject: SavedObject<PlaygroundSavedObject> = {
|
||||||
|
id: 'my-fake-id',
|
||||||
|
type: PLAYGROUND_SAVED_OBJECT_TYPE,
|
||||||
|
created_at: '2023-10-01T00:00:00Z',
|
||||||
|
created_by: 'user1',
|
||||||
|
updated_at: '2023-10-02T00:00:00Z',
|
||||||
|
updated_by: 'user2',
|
||||||
|
attributes: validChatPlayground,
|
||||||
|
references: [],
|
||||||
|
version: '1',
|
||||||
|
namespaces: ['default'],
|
||||||
|
migrationVersion: {},
|
||||||
|
coreMigrationVersion: '9.0.0',
|
||||||
|
};
|
||||||
|
expect(parsePlaygroundSO(savedObject)).toEqual({
|
||||||
|
_meta: {
|
||||||
|
id: 'my-fake-id',
|
||||||
|
createdAt: '2023-10-01T00:00:00Z',
|
||||||
|
createdBy: 'user1',
|
||||||
|
updatedAt: '2023-10-02T00:00:00Z',
|
||||||
|
updatedBy: 'user2',
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
...validChatPlayground,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('parsePlaygroundSOList', () => {
|
||||||
|
it('should parse saved object list to api response', () => {
|
||||||
|
const savedObjects: Array<SavedObjectsFindResult<PlaygroundSavedObject>> = [
|
||||||
|
{
|
||||||
|
id: 'my-fake-id',
|
||||||
|
type: PLAYGROUND_SAVED_OBJECT_TYPE,
|
||||||
|
created_at: '2023-10-01T00:00:00Z',
|
||||||
|
updated_at: '2023-10-01T00:00:00Z',
|
||||||
|
attributes: validChatPlayground,
|
||||||
|
references: [],
|
||||||
|
version: '1',
|
||||||
|
namespaces: ['default'],
|
||||||
|
migrationVersion: {},
|
||||||
|
coreMigrationVersion: '9.0.0',
|
||||||
|
score: 1,
|
||||||
|
sort: [0],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const response = parsePlaygroundSOList({
|
||||||
|
total: 1,
|
||||||
|
// @ts-ignore-next-line
|
||||||
|
saved_objects: savedObjects,
|
||||||
|
page: 1,
|
||||||
|
per_page: 1,
|
||||||
|
});
|
||||||
|
expect(response).toEqual({
|
||||||
|
_meta: {
|
||||||
|
total: 1,
|
||||||
|
page: 1,
|
||||||
|
size: 1,
|
||||||
|
},
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'my-fake-id',
|
||||||
|
name: validChatPlayground.name,
|
||||||
|
createdAt: '2023-10-01T00:00:00Z',
|
||||||
|
updatedAt: '2023-10-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,169 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { SavedObjectsFindResponse, type SavedObject } from '@kbn/core/server';
|
||||||
|
import { i18n } from '@kbn/i18n';
|
||||||
|
import type {
|
||||||
|
PlaygroundSavedObject,
|
||||||
|
PlaygroundResponse,
|
||||||
|
PlaygroundListResponse,
|
||||||
|
PlaygroundListObject,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
export function validatePlayground(playground: PlaygroundSavedObject): string[] {
|
||||||
|
const errors: string[] = [];
|
||||||
|
if (playground.name.trim().length === 0) {
|
||||||
|
errors.push(
|
||||||
|
i18n.translate('xpack.searchPlayground.playgroundNameError', {
|
||||||
|
defaultMessage: 'Playground name cannot be empty',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
JSON.parse(playground.elasticsearchQueryJSON);
|
||||||
|
} catch (e) {
|
||||||
|
errors.push(
|
||||||
|
i18n.translate('xpack.searchPlayground.esQueryJSONError', {
|
||||||
|
defaultMessage: 'Elasticsearch query JSON is invalid\n{jsonParseError}',
|
||||||
|
values: { jsonParseError: e.message },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (playground.userElasticsearchQueryJSON) {
|
||||||
|
try {
|
||||||
|
JSON.parse(playground.userElasticsearchQueryJSON);
|
||||||
|
} catch (e) {
|
||||||
|
errors.push(
|
||||||
|
i18n.translate('xpack.searchPlayground.userESQueryJSONError', {
|
||||||
|
defaultMessage: 'User Elasticsearch query JSON is invalid\n{jsonParseError}',
|
||||||
|
values: { jsonParseError: e.message },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// validate query fields greater than 0 and match selected indices
|
||||||
|
let totalFieldsCount = 0;
|
||||||
|
Object.entries(playground.queryFields).forEach(([index, fields]) => {
|
||||||
|
if (!playground.indices.includes(index)) {
|
||||||
|
errors.push(
|
||||||
|
i18n.translate('xpack.searchPlayground.queryFieldsIndexError', {
|
||||||
|
defaultMessage: 'Query fields index {index} does not match selected indices',
|
||||||
|
values: { index },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
fields?.forEach((field, i) => {
|
||||||
|
if (field.trim().length === 0) {
|
||||||
|
errors.push(
|
||||||
|
i18n.translate('xpack.searchPlayground.queryFieldsError', {
|
||||||
|
defaultMessage: 'Query field cannot be empty, {index} item {i} is empty',
|
||||||
|
values: { index, i },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
totalFieldsCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (totalFieldsCount === 0) {
|
||||||
|
errors.push(
|
||||||
|
i18n.translate('xpack.searchPlayground.queryFieldsEmptyError', {
|
||||||
|
defaultMessage: 'Query fields cannot be empty',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playground.context) {
|
||||||
|
// validate source fields greater than 0 and match a selected index
|
||||||
|
let totalSourceFieldsCount = 0;
|
||||||
|
Object.entries(playground.context.sourceFields).forEach(([index, fields]) => {
|
||||||
|
if (!playground.indices.includes(index)) {
|
||||||
|
errors.push(
|
||||||
|
i18n.translate('xpack.searchPlayground.sourceFieldsIndexError', {
|
||||||
|
defaultMessage: 'Source fields index {index} does not match selected indices',
|
||||||
|
values: { index },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
fields?.forEach((field, i) => {
|
||||||
|
if (field.trim().length === 0) {
|
||||||
|
errors.push(
|
||||||
|
i18n.translate('xpack.searchPlayground.sourceFieldsError', {
|
||||||
|
defaultMessage: 'Source field cannot be empty, {index} item {i} is empty',
|
||||||
|
values: { index, i },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
totalSourceFieldsCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (totalSourceFieldsCount === 0) {
|
||||||
|
errors.push(
|
||||||
|
i18n.translate('xpack.searchPlayground.sourceFieldsEmptyError', {
|
||||||
|
defaultMessage: 'Source fields cannot be empty',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePlaygroundSO(
|
||||||
|
soPlayground: SavedObject<PlaygroundSavedObject>
|
||||||
|
): PlaygroundResponse {
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
created_at: createdAt,
|
||||||
|
created_by: createdBy,
|
||||||
|
updated_at: updatedAt,
|
||||||
|
updated_by: updatedBy,
|
||||||
|
attributes,
|
||||||
|
} = soPlayground;
|
||||||
|
|
||||||
|
return {
|
||||||
|
_meta: {
|
||||||
|
id,
|
||||||
|
createdAt,
|
||||||
|
createdBy,
|
||||||
|
updatedAt,
|
||||||
|
updatedBy,
|
||||||
|
},
|
||||||
|
data: attributes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePlaygroundSOList(
|
||||||
|
playgroundsResponse: SavedObjectsFindResponse<PlaygroundSavedObject, unknown>
|
||||||
|
): PlaygroundListResponse {
|
||||||
|
const items: PlaygroundListObject[] = playgroundsResponse.saved_objects.map((soPlayground) => {
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
created_at: createdAt,
|
||||||
|
created_by: createdBy,
|
||||||
|
updated_at: updatedAt,
|
||||||
|
updated_by: updatedBy,
|
||||||
|
attributes: { name },
|
||||||
|
} = soPlayground;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
createdAt,
|
||||||
|
createdBy,
|
||||||
|
updatedAt,
|
||||||
|
updatedBy,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
_meta: {
|
||||||
|
total: playgroundsResponse.total,
|
||||||
|
page: playgroundsResponse.page,
|
||||||
|
size: playgroundsResponse.per_page,
|
||||||
|
},
|
||||||
|
items,
|
||||||
|
};
|
||||||
|
}
|
|
@ -57,6 +57,8 @@
|
||||||
"@kbn/code-editor",
|
"@kbn/code-editor",
|
||||||
"@kbn/monaco",
|
"@kbn/monaco",
|
||||||
"@kbn/react-hooks",
|
"@kbn/react-hooks",
|
||||||
|
"@kbn/core-http-router-server-mocks",
|
||||||
|
"@kbn/core-saved-objects-server",
|
||||||
],
|
],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"target/**/*",
|
"target/**/*",
|
||||||
|
|
|
@ -28,9 +28,9 @@ import { ALL_SAVED_OBJECT_INDICES } from '@kbn/core-saved-objects-server';
|
||||||
export const refreshSavedObjectIndices = async (es: Client) => {
|
export const refreshSavedObjectIndices = async (es: Client) => {
|
||||||
// Refresh indices to prevent a race condition between a write and subsequent read operation. To
|
// Refresh indices to prevent a race condition between a write and subsequent read operation. To
|
||||||
// fix it deterministically we have to refresh saved object indices and wait until it's done.
|
// fix it deterministically we have to refresh saved object indices and wait until it's done.
|
||||||
await es.indices.refresh({ index: ALL_SAVED_OBJECT_INDICES });
|
await es.indices.refresh({ index: ALL_SAVED_OBJECT_INDICES, ignore_unavailable: true });
|
||||||
|
|
||||||
// Additionally, we need to clear the cache to ensure that the next read operation will
|
// Additionally, we need to clear the cache to ensure that the next read operation will
|
||||||
// not return stale data.
|
// not return stale data.
|
||||||
await es.indices.clearCache({ index: ALL_SAVED_OBJECT_INDICES });
|
await es.indices.clearCache({ index: ALL_SAVED_OBJECT_INDICES, ignore_unavailable: true });
|
||||||
};
|
};
|
||||||
|
|
|
@ -0,0 +1,65 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { User, Role, UserInfo } from './types';
|
||||||
|
import type { FtrProviderContext } from '../../../ftr_provider_context';
|
||||||
|
|
||||||
|
export const getUserInfo = (user: User): UserInfo => ({
|
||||||
|
username: user.username,
|
||||||
|
full_name: user.username.replace('_', ' '),
|
||||||
|
email: `${user.username}@elastic.co`,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the users and roles for use in the tests. Defaults to specific users and roles used by the security_and_spaces
|
||||||
|
* scenarios but can be passed specific ones as well.
|
||||||
|
*/
|
||||||
|
export const createUsersAndRoles = async (
|
||||||
|
getService: FtrProviderContext['getService'],
|
||||||
|
usersToCreate: User[],
|
||||||
|
rolesToCreate: Role[]
|
||||||
|
) => {
|
||||||
|
const security = getService('security');
|
||||||
|
|
||||||
|
const createRole = async ({ name, privileges }: Role) => {
|
||||||
|
return await security.role.create(name, privileges);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createUser = async (user: User) => {
|
||||||
|
const userInfo = getUserInfo(user);
|
||||||
|
|
||||||
|
return await security.user.create(user.username, {
|
||||||
|
password: user.password,
|
||||||
|
roles: user.roles,
|
||||||
|
full_name: userInfo.full_name,
|
||||||
|
email: userInfo.email,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
await Promise.all(rolesToCreate.map((role) => createRole(role)));
|
||||||
|
await Promise.all(usersToCreate.map((user) => createUser(user)));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteUsersAndRoles = async (
|
||||||
|
getService: FtrProviderContext['getService'],
|
||||||
|
usersToDelete: User[],
|
||||||
|
rolesToDelete: Role[]
|
||||||
|
) => {
|
||||||
|
const security = getService('security');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.allSettled(usersToDelete.map((user) => security.user.delete(user.username)));
|
||||||
|
} catch (error) {
|
||||||
|
// ignore errors because if a migration is run it will delete the .kibana index which remove the spaces and users
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.allSettled(rolesToDelete.map((role) => security.role.delete(role.name)));
|
||||||
|
} catch (error) {
|
||||||
|
// ignore errors because if a migration is run it will delete the .kibana index which remove the spaces and users
|
||||||
|
}
|
||||||
|
};
|
|
@ -0,0 +1,64 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Role } from './types';
|
||||||
|
|
||||||
|
export const playgroundAllRole: Role = {
|
||||||
|
name: 'playground_test_all',
|
||||||
|
privileges: {
|
||||||
|
elasticsearch: {
|
||||||
|
indices: [{ names: ['*'], privileges: ['all'] }],
|
||||||
|
},
|
||||||
|
kibana: [
|
||||||
|
{
|
||||||
|
base: ['all'],
|
||||||
|
feature: {},
|
||||||
|
spaces: ['*'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const playgroundReadRole: Role = {
|
||||||
|
name: 'playground_test_read',
|
||||||
|
privileges: {
|
||||||
|
elasticsearch: {
|
||||||
|
indices: [{ names: ['*'], privileges: ['read'] }],
|
||||||
|
},
|
||||||
|
kibana: [
|
||||||
|
{
|
||||||
|
base: ['read'],
|
||||||
|
feature: {},
|
||||||
|
spaces: ['*'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const playgroundNoAccessRole: Role = {
|
||||||
|
name: 'playground_test_no_access',
|
||||||
|
privileges: {
|
||||||
|
elasticsearch: {
|
||||||
|
indices: [{ names: ['*'], privileges: ['all'] }],
|
||||||
|
},
|
||||||
|
kibana: [
|
||||||
|
{
|
||||||
|
feature: {
|
||||||
|
discover: ['read'],
|
||||||
|
},
|
||||||
|
spaces: ['*'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ROLES = {
|
||||||
|
ALL: playgroundAllRole,
|
||||||
|
READ: playgroundReadRole,
|
||||||
|
NO_ACCESS: playgroundNoAccessRole,
|
||||||
|
};
|
||||||
|
export const ALL_ROLES = Object.values(ROLES);
|
|
@ -0,0 +1,47 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface UserInfo {
|
||||||
|
username: string;
|
||||||
|
full_name: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
description?: string;
|
||||||
|
roles: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FeaturesPrivileges {
|
||||||
|
[featureId: string]: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ElasticsearchIndices {
|
||||||
|
names: string[];
|
||||||
|
privileges: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ElasticSearchPrivilege {
|
||||||
|
cluster?: string[];
|
||||||
|
indices?: ElasticsearchIndices[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KibanaPrivilege {
|
||||||
|
spaces: string[];
|
||||||
|
base?: string[];
|
||||||
|
feature?: FeaturesPrivileges;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Role {
|
||||||
|
name: string;
|
||||||
|
privileges: {
|
||||||
|
elasticsearch?: ElasticSearchPrivilege;
|
||||||
|
kibana?: KibanaPrivilege[];
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,35 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { User } from './types';
|
||||||
|
import { ROLES } from './roles';
|
||||||
|
|
||||||
|
export const playgroundAllUser: User = {
|
||||||
|
username: 'playground_test_all',
|
||||||
|
password: 'password',
|
||||||
|
roles: [ROLES.ALL.name],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const playgroundReadUser: User = {
|
||||||
|
username: 'playground_test_read',
|
||||||
|
password: 'password',
|
||||||
|
roles: [ROLES.READ.name],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const nonPlaygroundUser: User = {
|
||||||
|
username: 'playground_test_no_access',
|
||||||
|
password: 'password',
|
||||||
|
roles: [ROLES.NO_ACCESS.name],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const USERS = {
|
||||||
|
ALL: playgroundAllUser,
|
||||||
|
READ: playgroundReadUser,
|
||||||
|
NO_ACCESS: nonPlaygroundUser,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ALL_USERS = Object.values(USERS);
|
17
x-pack/test/api_integration/apis/search_playground/config.ts
Normal file
17
x-pack/test/api_integration/apis/search_playground/config.ts
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { FtrConfigProviderContext } from '@kbn/test';
|
||||||
|
|
||||||
|
export default async function ({ readConfigFile }: FtrConfigProviderContext) {
|
||||||
|
const baseIntegrationTestsConfig = await readConfigFile(require.resolve('../../config.ts'));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...baseIntegrationTestsConfig.getAll(),
|
||||||
|
testFiles: [require.resolve('.')],
|
||||||
|
};
|
||||||
|
}
|
14
x-pack/test/api_integration/apis/search_playground/index.ts
Normal file
14
x-pack/test/api_integration/apis/search_playground/index.ts
Normal 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
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { FtrProviderContext } from '../../ftr_provider_context';
|
||||||
|
|
||||||
|
export default function ({ loadTestFile }: FtrProviderContext) {
|
||||||
|
describe('search_playground apis', () => {
|
||||||
|
loadTestFile(require.resolve('./playgrounds'));
|
||||||
|
});
|
||||||
|
}
|
|
@ -0,0 +1,467 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import expect from 'expect';
|
||||||
|
import { ELASTIC_HTTP_VERSION_HEADER } from '@kbn/core-http-common';
|
||||||
|
import { FtrProviderContext } from '../../ftr_provider_context';
|
||||||
|
import { ALL_USERS, USERS } from './common/users';
|
||||||
|
import { ALL_ROLES } from './common/roles';
|
||||||
|
import { createUsersAndRoles, deleteUsersAndRoles } from './common/helpers';
|
||||||
|
|
||||||
|
const INTERNAL_API_BASE_PATH = '/internal/search_playground/playgrounds';
|
||||||
|
const INITIAL_REST_VERSION = '1' as const;
|
||||||
|
|
||||||
|
export default function ({ getService }: FtrProviderContext) {
|
||||||
|
const log = getService('log');
|
||||||
|
const supertestWithoutAuth = getService('supertestWithoutAuth');
|
||||||
|
|
||||||
|
describe('playgrounds - /internal/search_playground/playgrounds', function () {
|
||||||
|
const testPlaygroundIds: Set<string> = new Set<string>();
|
||||||
|
let testPlaygroundId: string | undefined;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
await createUsersAndRoles(getService, ALL_USERS, ALL_ROLES);
|
||||||
|
});
|
||||||
|
after(async () => {
|
||||||
|
if (testPlaygroundIds.size > 0) {
|
||||||
|
for (const id of testPlaygroundIds) {
|
||||||
|
try {
|
||||||
|
await supertestWithoutAuth
|
||||||
|
.delete(`${INTERNAL_API_BASE_PATH}/${id}`)
|
||||||
|
.set('kbn-xsrf', 'xxx')
|
||||||
|
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
|
||||||
|
.auth(USERS.ALL.username, USERS.ALL.password)
|
||||||
|
.expect(200);
|
||||||
|
} catch (err) {
|
||||||
|
log.warning('[Cleanup error] Error deleting playground', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteUsersAndRoles(getService, ALL_USERS, ALL_ROLES);
|
||||||
|
});
|
||||||
|
describe('developer', function () {
|
||||||
|
describe('PUT playgrounds', function () {
|
||||||
|
it('should allow creating a new playground', async () => {
|
||||||
|
const { body } = await supertestWithoutAuth
|
||||||
|
.put(INTERNAL_API_BASE_PATH)
|
||||||
|
.send({
|
||||||
|
name: 'Test Playground',
|
||||||
|
indices: ['test-index'],
|
||||||
|
queryFields: { 'test-index': ['field1', 'field2'] },
|
||||||
|
elasticsearchQueryJSON: `{"retriever":{"standard":{"query":{"multi_match":{"query":"{query}","fields":["field1","field2"]}}}}}`,
|
||||||
|
})
|
||||||
|
.set('kbn-xsrf', 'xxx')
|
||||||
|
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
|
||||||
|
.auth(USERS.ALL.username, USERS.ALL.password)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(body).toBeDefined();
|
||||||
|
expect(body._meta).toBeDefined();
|
||||||
|
expect(body._meta.id).toBeDefined();
|
||||||
|
testPlaygroundIds.add(body._meta.id);
|
||||||
|
});
|
||||||
|
it('should allow creating chat playground', async () => {
|
||||||
|
const { body } = await supertestWithoutAuth
|
||||||
|
.put(INTERNAL_API_BASE_PATH)
|
||||||
|
.send({
|
||||||
|
name: 'Test Chat Playground',
|
||||||
|
indices: ['test-index', 'my-index'],
|
||||||
|
queryFields: { 'test-index': ['field1', 'field2'], 'my-index': ['field3'] },
|
||||||
|
elasticsearchQueryJSON: `{"retriever":{"standard":{"query":{"multi_match":{"query":"{query}","fields":["field1","field2"]}}}}}`,
|
||||||
|
prompt: 'Test prompt',
|
||||||
|
citations: false,
|
||||||
|
context: {
|
||||||
|
sourceFields: { 'test-index': ['field1', 'field2'], 'my-index': ['field3'] },
|
||||||
|
docSize: 3,
|
||||||
|
},
|
||||||
|
summarizationModel: {
|
||||||
|
connectorId: 'connectorId',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.set('kbn-xsrf', 'xxx')
|
||||||
|
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
|
||||||
|
.auth(USERS.ALL.username, USERS.ALL.password)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(body?._meta?.id).toBeDefined();
|
||||||
|
testPlaygroundIds.add(body._meta.id);
|
||||||
|
});
|
||||||
|
it('should allow creating search playground with custom query', async () => {
|
||||||
|
const { body } = await supertestWithoutAuth
|
||||||
|
.put(INTERNAL_API_BASE_PATH)
|
||||||
|
.send({
|
||||||
|
name: 'My awesome Playground',
|
||||||
|
indices: ['test-index', 'my-index'],
|
||||||
|
queryFields: { 'test-index': ['field1', 'field2'], 'my-index': ['field3'] },
|
||||||
|
elasticsearchQueryJSON: `{"retriever":{"standard":{"query":{"multi_match":{"query":"{query}","fields":["field1","field2"]}}}}}`,
|
||||||
|
userElasticsearchQueryJSON: `{"query":{"multi_match":{"query":"{query}","fields":["field1"]}}}`,
|
||||||
|
prompt: 'Test prompt',
|
||||||
|
citations: false,
|
||||||
|
context: {
|
||||||
|
sourceFields: { 'test-index': ['field1', 'field2'], 'my-index': ['field3'] },
|
||||||
|
docSize: 3,
|
||||||
|
},
|
||||||
|
summarizationModel: {
|
||||||
|
connectorId: 'connectorId',
|
||||||
|
modelId: 'modelId',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.set('kbn-xsrf', 'xxx')
|
||||||
|
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
|
||||||
|
.auth(USERS.ALL.username, USERS.ALL.password)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(body?._meta?.id).toBeDefined();
|
||||||
|
testPlaygroundIds.add(body._meta.id);
|
||||||
|
});
|
||||||
|
it('should allow creating chat playground with custom query', async () => {
|
||||||
|
const { body } = await supertestWithoutAuth
|
||||||
|
.put(INTERNAL_API_BASE_PATH)
|
||||||
|
.send({
|
||||||
|
name: 'Another Chat Playground',
|
||||||
|
indices: ['test-index', 'my-index'],
|
||||||
|
queryFields: { 'test-index': ['field1', 'field2'], 'my-index': ['field3'] },
|
||||||
|
elasticsearchQueryJSON: `{"retriever":{"standard":{"query":{"multi_match":{"query":"{query}","fields":["field1","field2"]}}}}}`,
|
||||||
|
userElasticsearchQueryJSON: `{"query":{"multi_match":{"query":"{query}","fields":["field1"]}}}`,
|
||||||
|
})
|
||||||
|
.set('kbn-xsrf', 'xxx')
|
||||||
|
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
|
||||||
|
.auth(USERS.ALL.username, USERS.ALL.password)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(body?._meta?.id).toBeDefined();
|
||||||
|
testPlaygroundIds.add(body._meta.id);
|
||||||
|
});
|
||||||
|
it('should validate playground create request', async () => {
|
||||||
|
await supertestWithoutAuth
|
||||||
|
.put(INTERNAL_API_BASE_PATH)
|
||||||
|
.send({
|
||||||
|
name: '',
|
||||||
|
indices: ['test-index'],
|
||||||
|
queryFields: { 'test-index': [''] },
|
||||||
|
elasticsearchQueryJSON: `{"retriever":{"standard":{"query":{"multi_match":{"query":"{query}","fields":["field1","field2"]}`,
|
||||||
|
})
|
||||||
|
.set('kbn-xsrf', 'xxx')
|
||||||
|
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
|
||||||
|
.auth(USERS.ALL.username, USERS.ALL.password)
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
it('should validate playground create request with custom query', async () => {
|
||||||
|
await supertestWithoutAuth
|
||||||
|
.put(INTERNAL_API_BASE_PATH)
|
||||||
|
.send({
|
||||||
|
name: '',
|
||||||
|
indices: ['test-index'],
|
||||||
|
queryFields: { 'test-index': [''] },
|
||||||
|
elasticsearchQueryJSON: `{"retriever":{"standard":{"query":{"multi_match":{"query":"{query}","fields":["field1","field2"]}}}}}`,
|
||||||
|
userElasticsearchQueryJSON: `{"query":{"multi_match":{"query":"{query}","fields":["field1`,
|
||||||
|
})
|
||||||
|
.set('kbn-xsrf', 'xxx')
|
||||||
|
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
|
||||||
|
.auth(USERS.ALL.username, USERS.ALL.password)
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('GET playgrounds/{id}', function () {
|
||||||
|
before(() => {
|
||||||
|
expect(testPlaygroundIds.size).toBeGreaterThan(0);
|
||||||
|
testPlaygroundId = Array.from(testPlaygroundIds)[0];
|
||||||
|
});
|
||||||
|
after(() => {
|
||||||
|
testPlaygroundId = undefined;
|
||||||
|
});
|
||||||
|
it('should return existing playground', async () => {
|
||||||
|
const { body } = await supertestWithoutAuth
|
||||||
|
.get(`${INTERNAL_API_BASE_PATH}/${testPlaygroundId}`)
|
||||||
|
.set('kbn-xsrf', 'xxx')
|
||||||
|
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
|
||||||
|
.auth(USERS.ALL.username, USERS.ALL.password)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(body).toBeDefined();
|
||||||
|
expect(body._meta).toBeDefined();
|
||||||
|
expect(body._meta.id).toBeDefined();
|
||||||
|
expect(body._meta.id).toEqual(testPlaygroundId);
|
||||||
|
expect(body.data).toBeDefined();
|
||||||
|
expect(body.data.name).toBeDefined();
|
||||||
|
});
|
||||||
|
it('should return 404 for unknown playground', async () => {
|
||||||
|
await supertestWithoutAuth
|
||||||
|
.get(`${INTERNAL_API_BASE_PATH}/some-fake-id`)
|
||||||
|
.set('kbn-xsrf', 'xxx')
|
||||||
|
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
|
||||||
|
.auth(USERS.ALL.username, USERS.ALL.password)
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('GET playgrounds', function () {
|
||||||
|
it('should return playgrounds', async () => {
|
||||||
|
const { body } = await supertestWithoutAuth
|
||||||
|
.get(INTERNAL_API_BASE_PATH)
|
||||||
|
.set('kbn-xsrf', 'xxx')
|
||||||
|
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
|
||||||
|
.auth(USERS.ALL.username, USERS.ALL.password)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(body).toBeDefined();
|
||||||
|
expect(body._meta).toBeDefined();
|
||||||
|
expect(body._meta.total).toBeDefined();
|
||||||
|
expect(body.items).toBeDefined();
|
||||||
|
expect(body._meta.total).toBeGreaterThan(0);
|
||||||
|
expect(body.items.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
it('should return playgrounds with pagination & sorting', async () => {
|
||||||
|
const { body } = await supertestWithoutAuth
|
||||||
|
.get(`${INTERNAL_API_BASE_PATH}?page=1&size=1&sortOrder=asc`)
|
||||||
|
.set('kbn-xsrf', 'xxx')
|
||||||
|
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
|
||||||
|
.auth(USERS.ALL.username, USERS.ALL.password)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(body).toBeDefined();
|
||||||
|
expect(body._meta).toBeDefined();
|
||||||
|
expect(body._meta.total).toBeDefined();
|
||||||
|
expect(body.items).toBeDefined();
|
||||||
|
expect(body._meta.total).toBeGreaterThan(0);
|
||||||
|
expect(body.items.length).toEqual(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('PUT playgrounds/{id}', function () {
|
||||||
|
before(() => {
|
||||||
|
expect(testPlaygroundIds.size).toBeGreaterThan(0);
|
||||||
|
testPlaygroundId = Array.from(testPlaygroundIds)[0];
|
||||||
|
});
|
||||||
|
after(() => {
|
||||||
|
testPlaygroundId = undefined;
|
||||||
|
});
|
||||||
|
it('should update existing playground', async () => {
|
||||||
|
await supertestWithoutAuth
|
||||||
|
.put(`${INTERNAL_API_BASE_PATH}/${testPlaygroundId}`)
|
||||||
|
.send({
|
||||||
|
name: 'Updated Test Playground',
|
||||||
|
indices: ['test-index'],
|
||||||
|
queryFields: { 'test-index': ['field1', 'field2'] },
|
||||||
|
elasticsearchQueryJSON: `{"retriever":{"standard":{"query":{"multi_match":{"query":"{query}","fields":["field1","field2"]}}}}}`,
|
||||||
|
})
|
||||||
|
.set('kbn-xsrf', 'xxx')
|
||||||
|
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
|
||||||
|
.auth(USERS.ALL.username, USERS.ALL.password)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const { body } = await supertestWithoutAuth
|
||||||
|
.get(`${INTERNAL_API_BASE_PATH}/${testPlaygroundId}`)
|
||||||
|
.set('kbn-xsrf', 'xxx')
|
||||||
|
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
|
||||||
|
.auth(USERS.ALL.username, USERS.ALL.password)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(body).toBeDefined();
|
||||||
|
expect(body._meta).toBeDefined();
|
||||||
|
expect(body._meta.id).toEqual(testPlaygroundId);
|
||||||
|
expect(body.data).toBeDefined();
|
||||||
|
expect(body.data.name).toEqual('Updated Test Playground');
|
||||||
|
});
|
||||||
|
it('should return 404 for unknown playground', async () => {
|
||||||
|
await supertestWithoutAuth
|
||||||
|
.put(`${INTERNAL_API_BASE_PATH}/some-fake-id`)
|
||||||
|
.send({
|
||||||
|
name: 'Updated Test Playground',
|
||||||
|
indices: ['test-index'],
|
||||||
|
queryFields: { 'test-index': ['field1', 'field2'] },
|
||||||
|
elasticsearchQueryJSON: `{"retriever":{"standard":{"query":{"multi_match":{"query":"{query}","fields":["field1","field2"]}}}}}`,
|
||||||
|
})
|
||||||
|
.set('kbn-xsrf', 'xxx')
|
||||||
|
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
|
||||||
|
.auth(USERS.ALL.username, USERS.ALL.password)
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
it('should validate playground update request', async () => {
|
||||||
|
await supertestWithoutAuth
|
||||||
|
.put(`${INTERNAL_API_BASE_PATH}/${testPlaygroundId}`)
|
||||||
|
.send({
|
||||||
|
name: '',
|
||||||
|
indices: ['test-index'],
|
||||||
|
queryFields: { 'test-index': [''] },
|
||||||
|
elasticsearchQueryJSON: `{"retriever":{"standard":{"query":{"multi_match":{"query":"{query}","fields":["field1","field2"]}`,
|
||||||
|
})
|
||||||
|
.set('kbn-xsrf', 'xxx')
|
||||||
|
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
|
||||||
|
.auth(USERS.ALL.username, USERS.ALL.password)
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('DELETE playgrounds/{id}', function () {
|
||||||
|
it('should allow you to delete an existing playground', async () => {
|
||||||
|
expect(testPlaygroundIds.size).toBeGreaterThan(0);
|
||||||
|
const playgroundId = Array.from(testPlaygroundIds)[0];
|
||||||
|
await supertestWithoutAuth
|
||||||
|
.delete(`${INTERNAL_API_BASE_PATH}/${playgroundId}`)
|
||||||
|
.set('kbn-xsrf', 'xxx')
|
||||||
|
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
|
||||||
|
.auth(USERS.ALL.username, USERS.ALL.password)
|
||||||
|
.expect(200);
|
||||||
|
testPlaygroundIds.delete(playgroundId);
|
||||||
|
|
||||||
|
await supertestWithoutAuth
|
||||||
|
.get(`${INTERNAL_API_BASE_PATH}/${playgroundId}`)
|
||||||
|
.set('kbn-xsrf', 'xxx')
|
||||||
|
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
|
||||||
|
.auth(USERS.ALL.username, USERS.ALL.password)
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
it('should return 404 for unknown playground', async () => {
|
||||||
|
await supertestWithoutAuth
|
||||||
|
.delete(`${INTERNAL_API_BASE_PATH}/some-fake-id`)
|
||||||
|
.set('kbn-xsrf', 'xxx')
|
||||||
|
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
|
||||||
|
.auth(USERS.ALL.username, USERS.ALL.password)
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('viewer', function () {
|
||||||
|
before(async () => {
|
||||||
|
expect(testPlaygroundIds.size).toBeGreaterThan(0);
|
||||||
|
testPlaygroundId = Array.from(testPlaygroundIds)[0];
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET playgrounds', function () {
|
||||||
|
it('should have playgrounds to test with', async () => {
|
||||||
|
const { body } = await supertestWithoutAuth
|
||||||
|
.get(INTERNAL_API_BASE_PATH)
|
||||||
|
.set('kbn-xsrf', 'xxx')
|
||||||
|
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
|
||||||
|
.auth(USERS.READ.username, USERS.READ.password)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(body).toBeDefined();
|
||||||
|
expect(body._meta).toBeDefined();
|
||||||
|
expect(body._meta.total).toBeDefined();
|
||||||
|
expect(body.items).toBeDefined();
|
||||||
|
expect(body._meta.total).toBeGreaterThan(0);
|
||||||
|
expect(body.items.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('GET playgrounds/{id}', function () {
|
||||||
|
it('should return existing playground', async () => {
|
||||||
|
const { body } = await supertestWithoutAuth
|
||||||
|
.get(`${INTERNAL_API_BASE_PATH}/${testPlaygroundId}`)
|
||||||
|
.set('kbn-xsrf', 'xxx')
|
||||||
|
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
|
||||||
|
.auth(USERS.READ.username, USERS.READ.password)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(body).toBeDefined();
|
||||||
|
expect(body._meta).toBeDefined();
|
||||||
|
expect(body._meta.id).toBeDefined();
|
||||||
|
expect(body._meta.id).toEqual(testPlaygroundId);
|
||||||
|
expect(body.data).toBeDefined();
|
||||||
|
expect(body.data.name).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('PUT playgrounds', function () {
|
||||||
|
it('should fail', async () => {
|
||||||
|
await supertestWithoutAuth
|
||||||
|
.put(INTERNAL_API_BASE_PATH)
|
||||||
|
.send({
|
||||||
|
name: 'Viewer Test Playground',
|
||||||
|
indices: ['test-index'],
|
||||||
|
queryFields: { 'test-index': ['field1', 'field2'] },
|
||||||
|
elasticsearchQueryJSON: `{"retriever":{"standard":{"query":{"multi_match":{"query":"{query}","fields":["field1","field2"]}}}}}`,
|
||||||
|
})
|
||||||
|
.set('kbn-xsrf', 'xxx')
|
||||||
|
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
|
||||||
|
.auth(USERS.READ.username, USERS.READ.password)
|
||||||
|
.expect(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('PUT playgrounds/{id}', function () {
|
||||||
|
it('should fail', async () => {
|
||||||
|
await supertestWithoutAuth
|
||||||
|
.put(`${INTERNAL_API_BASE_PATH}/${testPlaygroundId}`)
|
||||||
|
.send({
|
||||||
|
name: 'Updated Test Playground viewer',
|
||||||
|
indices: ['test-index'],
|
||||||
|
queryFields: { 'test-index': ['field1', 'field2'] },
|
||||||
|
elasticsearchQueryJSON: `{"retriever":{"standard":{"query":{"multi_match":{"query":"{query}","fields":["field1","field2"]}}}}}`,
|
||||||
|
})
|
||||||
|
.set('kbn-xsrf', 'xxx')
|
||||||
|
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
|
||||||
|
.auth(USERS.READ.username, USERS.READ.password)
|
||||||
|
.expect(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('DELETE playgrounds/{id}', function () {
|
||||||
|
it('should fail', async () => {
|
||||||
|
await supertestWithoutAuth
|
||||||
|
.delete(`${INTERNAL_API_BASE_PATH}/${testPlaygroundId}`)
|
||||||
|
.set('kbn-xsrf', 'xxx')
|
||||||
|
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
|
||||||
|
.auth(USERS.READ.username, USERS.READ.password)
|
||||||
|
.expect(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('non-playground users', function () {
|
||||||
|
describe('Playground routes should be unavailable', () => {
|
||||||
|
it('GET playgrounds', async () => {
|
||||||
|
await supertestWithoutAuth
|
||||||
|
.get(INTERNAL_API_BASE_PATH)
|
||||||
|
.set('kbn-xsrf', 'foo')
|
||||||
|
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
|
||||||
|
.auth(USERS.NO_ACCESS.username, USERS.NO_ACCESS.password)
|
||||||
|
.expect(403);
|
||||||
|
});
|
||||||
|
it('GET playgrounds/{id}', async () => {
|
||||||
|
await supertestWithoutAuth
|
||||||
|
.get(`${INTERNAL_API_BASE_PATH}/some-fake-id`)
|
||||||
|
.set('kbn-xsrf', 'foo')
|
||||||
|
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
|
||||||
|
.auth(USERS.NO_ACCESS.username, USERS.NO_ACCESS.password)
|
||||||
|
.expect(403);
|
||||||
|
});
|
||||||
|
it('PUT playgrounds', async () => {
|
||||||
|
await supertestWithoutAuth
|
||||||
|
.put(INTERNAL_API_BASE_PATH)
|
||||||
|
.send({
|
||||||
|
name: 'Test Playground',
|
||||||
|
indices: ['test-index'],
|
||||||
|
queryFields: { 'test-index': ['field1', 'field2'] },
|
||||||
|
elasticsearchQueryJSON: `{"retriever":{"standard":{"query":{"multi_match":{"query":"{query}","fields":["field1","field2"]}}}}}`,
|
||||||
|
})
|
||||||
|
.set('kbn-xsrf', 'foo')
|
||||||
|
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
|
||||||
|
.auth(USERS.NO_ACCESS.username, USERS.NO_ACCESS.password)
|
||||||
|
.expect(403);
|
||||||
|
});
|
||||||
|
it('PUT playgrounds/{id}', async () => {
|
||||||
|
await supertestWithoutAuth
|
||||||
|
.put(`${INTERNAL_API_BASE_PATH}/some-fake-id`)
|
||||||
|
.send({
|
||||||
|
name: 'Test Playground',
|
||||||
|
indices: ['test-index'],
|
||||||
|
queryFields: { 'test-index': ['field1', 'field2'] },
|
||||||
|
elasticsearchQueryJSON: `{"retriever":{"standard":{"query":{"multi_match":{"query":"{query}","fields":["field1","field2"]}}}}}`,
|
||||||
|
})
|
||||||
|
.set('kbn-xsrf', 'foo')
|
||||||
|
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
|
||||||
|
.auth(USERS.NO_ACCESS.username, USERS.NO_ACCESS.password)
|
||||||
|
.expect(403);
|
||||||
|
});
|
||||||
|
it('DELETE playgrounds/{id}', async () => {
|
||||||
|
await supertestWithoutAuth
|
||||||
|
.delete(`${INTERNAL_API_BASE_PATH}/some-fake-id`)
|
||||||
|
.set('kbn-xsrf', 'foo')
|
||||||
|
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
|
||||||
|
.auth(USERS.NO_ACCESS.username, USERS.NO_ACCESS.password)
|
||||||
|
.expect(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
|
@ -178,6 +178,7 @@ export function getTestDataLoader({ getService }: Pick<FtrProviderContext, 'getS
|
||||||
deleteAllSavedObjectsFromKibanaIndex: async () => {
|
deleteAllSavedObjectsFromKibanaIndex: async () => {
|
||||||
await es.deleteByQuery({
|
await es.deleteByQuery({
|
||||||
index: ALL_SAVED_OBJECT_INDICES,
|
index: ALL_SAVED_OBJECT_INDICES,
|
||||||
|
ignore_unavailable: true,
|
||||||
wait_for_completion: true,
|
wait_for_completion: true,
|
||||||
conflicts: 'proceed',
|
conflicts: 'proceed',
|
||||||
query: {
|
query: {
|
||||||
|
|
|
@ -71,7 +71,7 @@ export default ({ getService }: FtrProviderContext): void => {
|
||||||
|
|
||||||
// Refresh ES indices to avoid race conditions between write and reading of indeces
|
// Refresh ES indices to avoid race conditions between write and reading of indeces
|
||||||
// See implementation utility function at x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/install_prebuilt_rules_fleet_package.ts
|
// See implementation utility function at x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/install_prebuilt_rules_fleet_package.ts
|
||||||
await es.indices.refresh({ index: ALL_SAVED_OBJECT_INDICES });
|
await es.indices.refresh({ index: ALL_SAVED_OBJECT_INDICES, ignore_unavailable: true });
|
||||||
|
|
||||||
// Verify that status is updated after package installation
|
// Verify that status is updated after package installation
|
||||||
const statusAfterPackageInstallation = await getPrebuiltRulesStatus(es, supertest);
|
const statusAfterPackageInstallation = await getPrebuiltRulesStatus(es, supertest);
|
||||||
|
|
|
@ -37,9 +37,9 @@ export const refreshIndex = async (es: Client, index?: string) => {
|
||||||
export const refreshSavedObjectIndices = async (es: Client) => {
|
export const refreshSavedObjectIndices = async (es: Client) => {
|
||||||
// Refresh indices to prevent a race condition between a write and subsequent read operation. To
|
// Refresh indices to prevent a race condition between a write and subsequent read operation. To
|
||||||
// fix it deterministically we have to refresh saved object indices and wait until it's done.
|
// fix it deterministically we have to refresh saved object indices and wait until it's done.
|
||||||
await es.indices.refresh({ index: ALL_SAVED_OBJECT_INDICES });
|
await es.indices.refresh({ index: ALL_SAVED_OBJECT_INDICES, ignore_unavailable: true });
|
||||||
|
|
||||||
// Additionally, we need to clear the cache to ensure that the next read operation will
|
// Additionally, we need to clear the cache to ensure that the next read operation will
|
||||||
// not return stale data.
|
// not return stale data.
|
||||||
await es.indices.clearCache({ index: ALL_SAVED_OBJECT_INDICES });
|
await es.indices.clearCache({ index: ALL_SAVED_OBJECT_INDICES, ignore_unavailable: true });
|
||||||
};
|
};
|
||||||
|
|
|
@ -19,6 +19,7 @@ export async function getSavedObjectFromES<T>(
|
||||||
return await es.search<T>(
|
return await es.search<T>(
|
||||||
{
|
{
|
||||||
index: ALL_SAVED_OBJECT_INDICES,
|
index: ALL_SAVED_OBJECT_INDICES,
|
||||||
|
ignore_unavailable: true,
|
||||||
query: {
|
query: {
|
||||||
bool: {
|
bool: {
|
||||||
filter: [
|
filter: [
|
||||||
|
|
|
@ -42,6 +42,7 @@ export function getTestScenariosForSpace(spaceId: string) {
|
||||||
export function getAggregatedSpaceData(es: Client, objectTypes: string[]) {
|
export function getAggregatedSpaceData(es: Client, objectTypes: string[]) {
|
||||||
return es.search({
|
return es.search({
|
||||||
index: ALL_SAVED_OBJECT_INDICES,
|
index: ALL_SAVED_OBJECT_INDICES,
|
||||||
|
ignore_unavailable: true,
|
||||||
request_cache: false,
|
request_cache: false,
|
||||||
size: 0,
|
size: 0,
|
||||||
runtime_mappings: {
|
runtime_mappings: {
|
||||||
|
|
|
@ -199,6 +199,7 @@ export function getTestDataLoader({ getService }: Pick<FtrProviderContext, 'getS
|
||||||
deleteAllSavedObjectsFromKibanaIndex: async () => {
|
deleteAllSavedObjectsFromKibanaIndex: async () => {
|
||||||
await es.deleteByQuery({
|
await es.deleteByQuery({
|
||||||
index: ALL_SAVED_OBJECT_INDICES,
|
index: ALL_SAVED_OBJECT_INDICES,
|
||||||
|
ignore_unavailable: true,
|
||||||
wait_for_completion: true,
|
wait_for_completion: true,
|
||||||
conflicts: 'proceed',
|
conflicts: 'proceed',
|
||||||
query: {
|
query: {
|
||||||
|
|
|
@ -178,6 +178,7 @@ export function deleteTestSuiteFactory({ getService }: DeploymentAgnosticFtrProv
|
||||||
// are updated to remove it, and of those, any that don't exist in any space are deleted.
|
// are updated to remove it, and of those, any that don't exist in any space are deleted.
|
||||||
const multiNamespaceResponse = await es.search<Record<string, any>>({
|
const multiNamespaceResponse = await es.search<Record<string, any>>({
|
||||||
index: ALL_SAVED_OBJECT_INDICES,
|
index: ALL_SAVED_OBJECT_INDICES,
|
||||||
|
ignore_unavailable: true,
|
||||||
size: 100,
|
size: 100,
|
||||||
query: { terms: { type: ['index-pattern'] } },
|
query: { terms: { type: ['index-pattern'] } },
|
||||||
});
|
});
|
||||||
|
|
|
@ -99,11 +99,15 @@ export function updateObjectsSpacesTestSuiteFactory(
|
||||||
if (expectAliasDifference !== undefined) {
|
if (expectAliasDifference !== undefined) {
|
||||||
// if we deleted an object that had an alias pointing to it, the alias should have been deleted as well
|
// if we deleted an object that had an alias pointing to it, the alias should have been deleted as well
|
||||||
if (!hasRefreshed) {
|
if (!hasRefreshed) {
|
||||||
await es.indices.refresh({ index: ALL_SAVED_OBJECT_INDICES }); // alias deletion uses refresh: false, so we need to manually refresh the index before searching
|
await es.indices.refresh({
|
||||||
|
index: ALL_SAVED_OBJECT_INDICES,
|
||||||
|
ignore_unavailable: true,
|
||||||
|
}); // alias deletion uses refresh: false, so we need to manually refresh the index before searching
|
||||||
hasRefreshed = true;
|
hasRefreshed = true;
|
||||||
}
|
}
|
||||||
const searchResponse = await es.search({
|
const searchResponse = await es.search({
|
||||||
index: ALL_SAVED_OBJECT_INDICES,
|
index: ALL_SAVED_OBJECT_INDICES,
|
||||||
|
ignore_unavailable: true,
|
||||||
size: 0,
|
size: 0,
|
||||||
query: { terms: { type: ['legacy-url-alias'] } },
|
query: { terms: { type: ['legacy-url-alias'] } },
|
||||||
track_total_hits: true,
|
track_total_hits: true,
|
||||||
|
|
|
@ -15,5 +15,6 @@ export default function ({ loadTestFile }: FtrProviderContext) {
|
||||||
loadTestFile(require.resolve('./cases/post_case'));
|
loadTestFile(require.resolve('./cases/post_case'));
|
||||||
loadTestFile(require.resolve('./serverless_search'));
|
loadTestFile(require.resolve('./serverless_search'));
|
||||||
loadTestFile(require.resolve('./platform_security'));
|
loadTestFile(require.resolve('./platform_security'));
|
||||||
|
loadTestFile(require.resolve('./search_playground'));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { FtrProviderContext } from '../../../ftr_provider_context';
|
||||||
|
|
||||||
|
export default function ({ loadTestFile }: FtrProviderContext) {
|
||||||
|
describe('search playground APIs', function () {
|
||||||
|
loadTestFile(require.resolve('./playgrounds'));
|
||||||
|
});
|
||||||
|
}
|
|
@ -0,0 +1,378 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import expect from 'expect';
|
||||||
|
import { ELASTIC_HTTP_VERSION_HEADER } from '@kbn/core-http-common';
|
||||||
|
import { SupertestWithRoleScopeType } from '@kbn/test-suites-xpack/api_integration/deployment_agnostic/services';
|
||||||
|
import { FtrProviderContext } from '../../../ftr_provider_context';
|
||||||
|
|
||||||
|
const INTERNAL_API_BASE_PATH = '/internal/search_playground/playgrounds';
|
||||||
|
const INITIAL_REST_VERSION = '1' as const;
|
||||||
|
|
||||||
|
export default function ({ getService }: FtrProviderContext) {
|
||||||
|
const log = getService('log');
|
||||||
|
const roleScopedSupertest = getService('roleScopedSupertest');
|
||||||
|
|
||||||
|
let supertestDeveloperWithCookieCredentials: SupertestWithRoleScopeType;
|
||||||
|
|
||||||
|
describe('playgrounds routes', function () {
|
||||||
|
const testPlaygroundIds: Set<string> = new Set<string>();
|
||||||
|
let testPlaygroundId: string | undefined;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
supertestDeveloperWithCookieCredentials = await roleScopedSupertest.getSupertestWithRoleScope(
|
||||||
|
'developer',
|
||||||
|
{
|
||||||
|
useCookieHeader: true,
|
||||||
|
withInternalHeaders: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
after(async () => {
|
||||||
|
if (testPlaygroundIds.size > 0) {
|
||||||
|
for (const id of testPlaygroundIds) {
|
||||||
|
try {
|
||||||
|
await supertestDeveloperWithCookieCredentials
|
||||||
|
.delete(`${INTERNAL_API_BASE_PATH}/${id}`)
|
||||||
|
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
|
||||||
|
.expect(200);
|
||||||
|
} catch (err) {
|
||||||
|
log.warning('[Cleanup error] Error deleting playground', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
describe('developer', function () {
|
||||||
|
describe('PUT playgrounds', function () {
|
||||||
|
it('should allow creating a new playground', async () => {
|
||||||
|
const { body } = await supertestDeveloperWithCookieCredentials
|
||||||
|
.put(INTERNAL_API_BASE_PATH)
|
||||||
|
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
|
||||||
|
.send({
|
||||||
|
name: 'Test Playground',
|
||||||
|
indices: ['test-index'],
|
||||||
|
queryFields: { 'test-index': ['field1', 'field2'] },
|
||||||
|
elasticsearchQueryJSON: `{"retriever":{"standard":{"query":{"multi_match":{"query":"{query}","fields":["field1","field2"]}}}}}`,
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(body).toBeDefined();
|
||||||
|
expect(body._meta).toBeDefined();
|
||||||
|
expect(body._meta.id).toBeDefined();
|
||||||
|
testPlaygroundIds.add(body._meta.id);
|
||||||
|
});
|
||||||
|
it('should allow creating chat playground', async () => {
|
||||||
|
const { body } = await supertestDeveloperWithCookieCredentials
|
||||||
|
.put(INTERNAL_API_BASE_PATH)
|
||||||
|
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
|
||||||
|
.send({
|
||||||
|
name: 'Test Chat Playground',
|
||||||
|
indices: ['test-index', 'my-index'],
|
||||||
|
queryFields: { 'test-index': ['field1', 'field2'], 'my-index': ['field3'] },
|
||||||
|
elasticsearchQueryJSON: `{"retriever":{"standard":{"query":{"multi_match":{"query":"{query}","fields":["field1","field2"]}}}}}`,
|
||||||
|
prompt: 'Test prompt',
|
||||||
|
citations: false,
|
||||||
|
context: {
|
||||||
|
sourceFields: { 'test-index': ['field1', 'field2'], 'my-index': ['field3'] },
|
||||||
|
docSize: 3,
|
||||||
|
},
|
||||||
|
summarizationModel: {
|
||||||
|
connectorId: 'connectorId',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(body?._meta?.id).toBeDefined();
|
||||||
|
testPlaygroundIds.add(body._meta.id);
|
||||||
|
});
|
||||||
|
it('should allow creating search playground with custom query', async () => {
|
||||||
|
const { body } = await supertestDeveloperWithCookieCredentials
|
||||||
|
.put(INTERNAL_API_BASE_PATH)
|
||||||
|
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
|
||||||
|
.send({
|
||||||
|
name: 'My awesome Playground',
|
||||||
|
indices: ['test-index', 'my-index'],
|
||||||
|
queryFields: { 'test-index': ['field1', 'field2'], 'my-index': ['field3'] },
|
||||||
|
elasticsearchQueryJSON: `{"retriever":{"standard":{"query":{"multi_match":{"query":"{query}","fields":["field1","field2"]}}}}}`,
|
||||||
|
userElasticsearchQueryJSON: `{"query":{"multi_match":{"query":"{query}","fields":["field1"]}}}`,
|
||||||
|
prompt: 'Test prompt',
|
||||||
|
citations: false,
|
||||||
|
context: {
|
||||||
|
sourceFields: { 'test-index': ['field1', 'field2'], 'my-index': ['field3'] },
|
||||||
|
docSize: 3,
|
||||||
|
},
|
||||||
|
summarizationModel: {
|
||||||
|
connectorId: 'connectorId',
|
||||||
|
modelId: 'modelId',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(body?._meta?.id).toBeDefined();
|
||||||
|
testPlaygroundIds.add(body._meta.id);
|
||||||
|
});
|
||||||
|
it('should allow creating chat playground with custom query', async () => {
|
||||||
|
const { body } = await supertestDeveloperWithCookieCredentials
|
||||||
|
.put(INTERNAL_API_BASE_PATH)
|
||||||
|
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
|
||||||
|
.send({
|
||||||
|
name: 'Another Chat Playground',
|
||||||
|
indices: ['test-index', 'my-index'],
|
||||||
|
queryFields: { 'test-index': ['field1', 'field2'], 'my-index': ['field3'] },
|
||||||
|
elasticsearchQueryJSON: `{"retriever":{"standard":{"query":{"multi_match":{"query":"{query}","fields":["field1","field2"]}}}}}`,
|
||||||
|
userElasticsearchQueryJSON: `{"query":{"multi_match":{"query":"{query}","fields":["field1"]}}}`,
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(body?._meta?.id).toBeDefined();
|
||||||
|
testPlaygroundIds.add(body._meta.id);
|
||||||
|
});
|
||||||
|
it('should validate playground create request', async () => {
|
||||||
|
await supertestDeveloperWithCookieCredentials
|
||||||
|
.put(INTERNAL_API_BASE_PATH)
|
||||||
|
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
|
||||||
|
.send({
|
||||||
|
name: '',
|
||||||
|
indices: ['test-index'],
|
||||||
|
queryFields: { 'test-index': [''] },
|
||||||
|
elasticsearchQueryJSON: `{"retriever":{"standard":{"query":{"multi_match":{"query":"{query}","fields":["field1","field2"]}`,
|
||||||
|
})
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
it('should validate playground create request with custom query', async () => {
|
||||||
|
await supertestDeveloperWithCookieCredentials
|
||||||
|
.put(INTERNAL_API_BASE_PATH)
|
||||||
|
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
|
||||||
|
.send({
|
||||||
|
name: '',
|
||||||
|
indices: ['test-index'],
|
||||||
|
queryFields: { 'test-index': [''] },
|
||||||
|
elasticsearchQueryJSON: `{"retriever":{"standard":{"query":{"multi_match":{"query":"{query}","fields":["field1","field2"]}}}}}`,
|
||||||
|
userElasticsearchQueryJSON: `{"query":{"multi_match":{"query":"{query}","fields":["field1`,
|
||||||
|
})
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('GET playgrounds/{id}', function () {
|
||||||
|
before(() => {
|
||||||
|
expect(testPlaygroundIds.size).toBeGreaterThan(0);
|
||||||
|
testPlaygroundId = Array.from(testPlaygroundIds)[0];
|
||||||
|
});
|
||||||
|
after(() => {
|
||||||
|
testPlaygroundId = undefined;
|
||||||
|
});
|
||||||
|
it('should return existing playground', async () => {
|
||||||
|
const { body } = await supertestDeveloperWithCookieCredentials
|
||||||
|
.get(`${INTERNAL_API_BASE_PATH}/${testPlaygroundId}`)
|
||||||
|
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(body).toBeDefined();
|
||||||
|
expect(body._meta).toBeDefined();
|
||||||
|
expect(body._meta.id).toBeDefined();
|
||||||
|
expect(body._meta.id).toEqual(testPlaygroundId);
|
||||||
|
expect(body.data).toBeDefined();
|
||||||
|
expect(body.data.name).toBeDefined();
|
||||||
|
});
|
||||||
|
it('should return 404 for unknown playground', async () => {
|
||||||
|
await supertestDeveloperWithCookieCredentials
|
||||||
|
.get(`${INTERNAL_API_BASE_PATH}/some-fake-id`)
|
||||||
|
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('GET playgrounds', function () {
|
||||||
|
it('should return playgrounds', async () => {
|
||||||
|
const { body } = await supertestDeveloperWithCookieCredentials
|
||||||
|
.get(INTERNAL_API_BASE_PATH)
|
||||||
|
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(body).toBeDefined();
|
||||||
|
expect(body._meta).toBeDefined();
|
||||||
|
expect(body._meta.total).toBeDefined();
|
||||||
|
expect(body.items).toBeDefined();
|
||||||
|
expect(body._meta.total).toBeGreaterThan(0);
|
||||||
|
expect(body.items.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
it('should return playgrounds with pagination & sorting', async () => {
|
||||||
|
const { body } = await supertestDeveloperWithCookieCredentials
|
||||||
|
.get(`${INTERNAL_API_BASE_PATH}?page=1&size=1&sortOrder=asc`)
|
||||||
|
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(body).toBeDefined();
|
||||||
|
expect(body._meta).toBeDefined();
|
||||||
|
expect(body._meta.total).toBeDefined();
|
||||||
|
expect(body.items).toBeDefined();
|
||||||
|
expect(body._meta.total).toBeGreaterThan(0);
|
||||||
|
expect(body.items.length).toEqual(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('PUT playgrounds/{id}', function () {
|
||||||
|
before(() => {
|
||||||
|
expect(testPlaygroundIds.size).toBeGreaterThan(0);
|
||||||
|
testPlaygroundId = Array.from(testPlaygroundIds)[0];
|
||||||
|
});
|
||||||
|
after(() => {
|
||||||
|
testPlaygroundId = undefined;
|
||||||
|
});
|
||||||
|
it('should update existing playground', async () => {
|
||||||
|
await supertestDeveloperWithCookieCredentials
|
||||||
|
.put(`${INTERNAL_API_BASE_PATH}/${testPlaygroundId}`)
|
||||||
|
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
|
||||||
|
.send({
|
||||||
|
name: 'Updated Test Playground',
|
||||||
|
indices: ['test-index'],
|
||||||
|
queryFields: { 'test-index': ['field1', 'field2'] },
|
||||||
|
elasticsearchQueryJSON: `{"retriever":{"standard":{"query":{"multi_match":{"query":"{query}","fields":["field1","field2"]}}}}}`,
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const { body } = await supertestDeveloperWithCookieCredentials
|
||||||
|
.get(`${INTERNAL_API_BASE_PATH}/${testPlaygroundId}`)
|
||||||
|
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(body).toBeDefined();
|
||||||
|
expect(body._meta).toBeDefined();
|
||||||
|
expect(body._meta.id).toEqual(testPlaygroundId);
|
||||||
|
expect(body.data).toBeDefined();
|
||||||
|
expect(body.data.name).toEqual('Updated Test Playground');
|
||||||
|
});
|
||||||
|
it('should return 404 for unknown playground', async () => {
|
||||||
|
await supertestDeveloperWithCookieCredentials
|
||||||
|
.put(`${INTERNAL_API_BASE_PATH}/some-fake-id`)
|
||||||
|
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
|
||||||
|
.send({
|
||||||
|
name: 'Updated Test Playground',
|
||||||
|
indices: ['test-index'],
|
||||||
|
queryFields: { 'test-index': ['field1', 'field2'] },
|
||||||
|
elasticsearchQueryJSON: `{"retriever":{"standard":{"query":{"multi_match":{"query":"{query}","fields":["field1","field2"]}}}}}`,
|
||||||
|
})
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
it('should validate playground update request', async () => {
|
||||||
|
await supertestDeveloperWithCookieCredentials
|
||||||
|
.put(`${INTERNAL_API_BASE_PATH}/${testPlaygroundId}`)
|
||||||
|
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
|
||||||
|
.send({
|
||||||
|
name: '',
|
||||||
|
indices: ['test-index'],
|
||||||
|
queryFields: { 'test-index': [''] },
|
||||||
|
elasticsearchQueryJSON: `{"retriever":{"standard":{"query":{"multi_match":{"query":"{query}","fields":["field1","field2"]}`,
|
||||||
|
})
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('DELETE playgrounds/{id}', function () {
|
||||||
|
it('should allow you to delete an existing playground', async () => {
|
||||||
|
expect(testPlaygroundIds.size).toBeGreaterThan(0);
|
||||||
|
const playgroundId = Array.from(testPlaygroundIds)[0];
|
||||||
|
await supertestDeveloperWithCookieCredentials
|
||||||
|
.delete(`${INTERNAL_API_BASE_PATH}/${playgroundId}`)
|
||||||
|
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
|
||||||
|
.expect(200);
|
||||||
|
testPlaygroundIds.delete(playgroundId);
|
||||||
|
|
||||||
|
await supertestDeveloperWithCookieCredentials
|
||||||
|
.get(`${INTERNAL_API_BASE_PATH}/${playgroundId}`)
|
||||||
|
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
it('should return 404 for unknown playground', async () => {
|
||||||
|
await supertestDeveloperWithCookieCredentials
|
||||||
|
.delete(`${INTERNAL_API_BASE_PATH}/some-fake-id`)
|
||||||
|
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('viewer', function () {
|
||||||
|
let supertestViewerWithCookieCredentials: SupertestWithRoleScopeType;
|
||||||
|
before(async () => {
|
||||||
|
expect(testPlaygroundIds.size).toBeGreaterThan(0);
|
||||||
|
testPlaygroundId = Array.from(testPlaygroundIds)[0];
|
||||||
|
|
||||||
|
supertestViewerWithCookieCredentials = await roleScopedSupertest.getSupertestWithRoleScope(
|
||||||
|
'viewer',
|
||||||
|
{
|
||||||
|
useCookieHeader: true,
|
||||||
|
withInternalHeaders: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET playgrounds', function () {
|
||||||
|
it('should have playgrounds to test with', async () => {
|
||||||
|
const { body } = await supertestViewerWithCookieCredentials
|
||||||
|
.get(INTERNAL_API_BASE_PATH)
|
||||||
|
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(body).toBeDefined();
|
||||||
|
expect(body._meta).toBeDefined();
|
||||||
|
expect(body._meta.total).toBeDefined();
|
||||||
|
expect(body.items).toBeDefined();
|
||||||
|
expect(body._meta.total).toBeGreaterThan(0);
|
||||||
|
expect(body.items.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('GET playgrounds/{id}', function () {
|
||||||
|
it('should return existing playground', async () => {
|
||||||
|
const { body } = await supertestViewerWithCookieCredentials
|
||||||
|
.get(`${INTERNAL_API_BASE_PATH}/${testPlaygroundId}`)
|
||||||
|
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(body).toBeDefined();
|
||||||
|
expect(body._meta).toBeDefined();
|
||||||
|
expect(body._meta.id).toBeDefined();
|
||||||
|
expect(body._meta.id).toEqual(testPlaygroundId);
|
||||||
|
expect(body.data).toBeDefined();
|
||||||
|
expect(body.data.name).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('PUT playgrounds', function () {
|
||||||
|
it('should fail', async () => {
|
||||||
|
await supertestViewerWithCookieCredentials
|
||||||
|
.put(INTERNAL_API_BASE_PATH)
|
||||||
|
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
|
||||||
|
.send({
|
||||||
|
name: 'Viewer Test Playground',
|
||||||
|
indices: ['test-index'],
|
||||||
|
queryFields: { 'test-index': ['field1', 'field2'] },
|
||||||
|
elasticsearchQueryJSON: `{"retriever":{"standard":{"query":{"multi_match":{"query":"{query}","fields":["field1","field2"]}}}}}`,
|
||||||
|
})
|
||||||
|
.expect(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('PUT playgrounds/{id}', function () {
|
||||||
|
it('should fail', async () => {
|
||||||
|
await supertestViewerWithCookieCredentials
|
||||||
|
.put(`${INTERNAL_API_BASE_PATH}/${testPlaygroundId}`)
|
||||||
|
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
|
||||||
|
.send({
|
||||||
|
name: 'Updated Test Playground viewer',
|
||||||
|
indices: ['test-index'],
|
||||||
|
queryFields: { 'test-index': ['field1', 'field2'] },
|
||||||
|
elasticsearchQueryJSON: `{"retriever":{"standard":{"query":{"multi_match":{"query":"{query}","fields":["field1","field2"]}}}}}`,
|
||||||
|
})
|
||||||
|
.expect(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('DELETE playgrounds/{id}', function () {
|
||||||
|
it('should fail', async () => {
|
||||||
|
await supertestViewerWithCookieCredentials
|
||||||
|
.delete(`${INTERNAL_API_BASE_PATH}/${testPlaygroundId}`)
|
||||||
|
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
|
||||||
|
.expect(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue