[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:
Rodney Norris 2025-04-24 16:20:31 -05:00 committed by GitHub
parent bea20769c9
commit f7b28d7b5f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
45 changed files with 2725 additions and 38 deletions

View file

@ -168,6 +168,7 @@ enabled:
- 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/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/spaces/config.ts
- x-pack/test/api_integration/apis/stats/config.ts

1
.github/CODEOWNERS vendored
View file

@ -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_search_* @elastic/search-kibana
/x-pack/test/functional_search/ @elastic/search-kibana
/x-pack/test/api_integration/apis/search_playground/ @elastic/search-kibana
# workchat
/x-pack/test_serverless/api_integration/test_suites/chat @elastic/search-kibana @elastic/workchat-eng

View file

@ -927,6 +927,9 @@
"username"
],
"search-telemetry": [],
"search_playground": [
"name"
],
"security-ai-prompt": [
"description",
"model",

View file

@ -3069,6 +3069,19 @@
"dynamic": false,
"properties": {}
},
"search_playground": {
"dynamic": false,
"properties": {
"name": {
"fields": {
"keyword": {
"type": "keyword"
}
},
"type": "text"
}
}
},
"security-ai-prompt": {
"dynamic": false,
"properties": {

View file

@ -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
// 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;

View file

@ -64,6 +64,7 @@ export {
ANALYTICS_SAVED_OBJECT_INDEX,
USAGE_COUNTERS_SAVED_OBJECT_INDEX,
ALL_SAVED_OBJECT_INDICES,
SEARCH_SOLUTION_SAVED_OBJECT_INDEX,
} from './src/saved_objects_index_pattern';
export type {
SavedObjectsType,

View file

@ -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 ANALYTICS_SAVED_OBJECT_INDEX = `${MAIN_SAVED_OBJECT_INDEX}_analytics`;
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 = [
MAIN_SAVED_OBJECT_INDEX,
@ -30,4 +31,5 @@ export const ALL_SAVED_OBJECT_INDICES = [
SECURITY_SOLUTION_SAVED_OBJECT_INDEX,
ANALYTICS_SAVED_OBJECT_INDEX,
USAGE_COUNTERS_SAVED_OBJECT_INDEX,
SEARCH_SOLUTION_SAVED_OBJECT_INDEX,
];

View file

@ -158,6 +158,7 @@ describe('checking migration metadata changes on all registered SO types', () =>
"search": "0aa6eefb37edd3145be340a8b67779c2ca578b22",
"search-session": "b2fcd840e12a45039ada50b1355faeafa39876d1",
"search-telemetry": "b568601618744720b5662946d3103e3fb75fe8ee",
"search_playground": "9e06ddbaad7c9eeb24b24c871b6b3df484d6c1ed",
"security-ai-prompt": "cc8ee5aaa9d001e89c131bbd5af6bc80bc271046",
"security-rule": "07abb4d7e707d91675ec0495c73816394c7b521f",
"security-solution-signals-migration": "9d99715fe5246f19de2273ba77debd2446c36bb1",

View file

@ -339,9 +339,13 @@ export const deleteSavedObjectIndices = async (
client: ElasticsearchClient,
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(
{ index: Object.keys(indices), allow_no_indices: true },
{ index: indices, ignore_unavailable: true },
{ ignore: [404] }
);
};

View file

@ -125,6 +125,7 @@ const previouslyRegisteredTypes = [
'search',
'search-session',
'search-telemetry',
'search_playground',
'security-ai-prompt',
'security-rule',
'security-solution-signals-migration',

View file

@ -17,7 +17,13 @@ export async function emptyKibanaIndexAction({ client, log }: { client: Client;
const stats = createStats('emptyKibanaIndex', 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();
}

View file

@ -122,6 +122,7 @@ export async function cleanSavedObjectIndices({
const resp = await client.deleteByQuery(
{
index,
ignore_unavailable: true,
refresh: true,
query: {
bool: {

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { MockedVersionedRouter } from '@kbn/core-http-router-server-mocks';
import {
IRouter,
KibanaRequest,
@ -23,6 +24,7 @@ type PayloadType = 'params' | 'query' | 'body';
interface IMockRouter {
method: MethodType;
path: string;
version?: string;
context?: jest.Mocked<RequestHandlerContext>;
}
interface IMockRouterRequest {
@ -36,14 +38,21 @@ export class MockRouter {
public router!: jest.Mocked<IRouter>;
public method: MethodType;
public path: string;
public version?: string;
public context: jest.Mocked<RequestHandlerContext>;
public payload?: PayloadType;
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.method = method;
this.path = path;
this.version = version;
this.context = context;
}
@ -84,6 +93,20 @@ export class MockRouter {
};
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[];
if (!routerCalls.length) throw new Error('No routes registered.');

View file

@ -21,3 +21,9 @@ export const DEFAULT_PAGINATION: Pagination = {
size: 10,
total: 0,
};
export enum ROUTE_VERSIONS {
v1 = '1',
}
export const PLAYGROUND_SAVED_OBJECT_TYPE = 'search_playground';

View file

@ -46,14 +46,22 @@ export interface QuerySourceFields {
skipped_fields: number;
}
const BASE_API_PATH = '/internal/search_playground';
export enum APIRoutes {
POST_API_KEY = '/internal/search_playground/api_key',
POST_CHAT_MESSAGE = '/internal/search_playground/chat',
POST_QUERY_SOURCE_FIELDS = '/internal/search_playground/query_source_fields',
GET_INDICES = '/internal/search_playground/indices',
POST_SEARCH_QUERY = '/internal/search_playground/search',
GET_INDEX_MAPPINGS = '/internal/search_playground/mappings',
POST_QUERY_TEST = '/internal/search_playground/query_test',
BASE_API = BASE_API_PATH,
POST_API_KEY = `${BASE_API_PATH}/api_key`,
POST_CHAT_MESSAGE = `${BASE_API_PATH}/chat`,
POST_QUERY_SOURCE_FIELDS = `${BASE_API_PATH}/query_source_fields`,
GET_INDICES = `${BASE_API_PATH}/indices`,
POST_SEARCH_QUERY = `${BASE_API_PATH}/search`,
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 {
@ -99,3 +107,49 @@ export interface QueryTestResponse {
documents?: Document[];
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[];
}

View file

@ -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,
},
},
},
});

View file

@ -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()),
})
),
});

View file

@ -23,7 +23,8 @@ import {
SearchPlaygroundPluginStartDependencies,
} from './types';
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
implements
@ -45,6 +46,9 @@ export class SearchPlaygroundPlugin
{ features }: SearchPlaygroundPluginSetupDependencies
) {
this.logger.debug('searchPlayground: Setup');
core.savedObjects.registerType(createPlaygroundSavedObjectType());
const router = core.http.createRouter();
defineRoutes({ router, logger: this.logger, getStartServices: core.getStartServices });
@ -66,16 +70,17 @@ export class SearchPlaygroundPlugin
api: [PLUGIN_ID],
catalogue: [PLUGIN_ID],
savedObject: {
all: [],
read: [],
all: [PLAYGROUND_SAVED_OBJECT_TYPE],
read: [PLAYGROUND_SAVED_OBJECT_TYPE],
},
ui: [],
},
read: {
disabled: true,
api: [PLUGIN_ID],
savedObject: {
all: [],
read: [],
read: [PLAYGROUND_SAVED_OBJECT_TYPE],
},
ui: [],
},

View file

@ -6,9 +6,7 @@
*/
import { schema } from '@kbn/config-schema';
import type { Logger } from '@kbn/logging';
import type { SearchRequest } from '@elastic/elasticsearch/lib/api/types';
import { IRouter, StartServicesAccessor } from '@kbn/core/server';
import { i18n } from '@kbn/i18n';
import { PLUGIN_ID } from '../common';
import { sendMessageEvent, SendMessageEventData } from './analytics/events';
@ -19,10 +17,9 @@ import { errorHandler } from './utils/error_handler';
import { handleStreamResponse } from './utils/handle_stream_response';
import {
APIRoutes,
DefineRoutesOptions,
ElasticsearchRetrieverContentField,
QueryTestResponse,
SearchPlaygroundPluginStart,
SearchPlaygroundPluginStartDependencies,
} from './types';
import { getChatParams } from './lib/get_chat_params';
import { fetchIndices } from './lib/fetch_indices';
@ -32,6 +29,7 @@ import { ContextLimitError } from './lib/errors';
import { contextDocumentHitMapper } from './utils/context_document_mapper';
import { parseSourceFields } from './utils/parse_source_fields';
import { getErrorMessage } from '../common/errors';
import { defineSavedPlaygroundRoutes } from './routes/saved_playgrounds';
const EMPTY_INDICES_ERROR_MESSAGE = i18n.translate(
'xpack.searchPlayground.serverErrors.emptyIndices',
@ -60,18 +58,9 @@ export function parseElasticsearchQuery(esQuery: string) {
};
}
export function defineRoutes({
logger,
router,
getStartServices,
}: {
logger: Logger;
router: IRouter;
getStartServices: StartServicesAccessor<
SearchPlaygroundPluginStartDependencies,
SearchPlaygroundPluginStart
>;
}) {
export function defineRoutes(routeOptions: DefineRoutesOptions) {
const { logger, router, getStartServices } = routeOptions;
router.post(
{
path: APIRoutes.POST_QUERY_SOURCE_FIELDS,
@ -496,4 +485,6 @@ export function defineRoutes({
}
})
);
defineSavedPlaygroundRoutes(routeOptions);
}

View file

@ -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',
},
});
});
});
});
});

View file

@ -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();
})
);
};

View file

@ -5,12 +5,14 @@
* 2.0.
*/
import type { Logger } from '@kbn/logging';
import type { PluginStartContract as ActionsPluginStartContract } from '@kbn/actions-plugin/server';
import type { CloudSetup, CloudStart } from '@kbn/cloud-plugin/server';
import type { FeaturesPluginSetup } from '@kbn/features-plugin/server';
import type { InferenceServerStart } from '@kbn/inference-plugin/server';
import type { Document } from '@langchain/core/documents';
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
export interface SearchPlaygroundPluginSetup {}
@ -34,3 +36,12 @@ export * from '../common/types';
export type HitDocMapper = (hit: SearchHit) => Document;
export type ElasticsearchRetrieverContentField = string | Record<string, string | string[]>;
export interface DefineRoutesOptions {
logger: Logger;
router: IRouter;
getStartServices: StartServicesAccessor<
SearchPlaygroundPluginStartDependencies,
SearchPlaygroundPluginStart
>;
}

View file

@ -6,6 +6,7 @@
*/
import { RequestHandlerWrapper } from '@kbn/core-http-server';
import { SavedObjectsErrorHelpers } from '@kbn/core/server';
import { KibanaServerError } from '@kbn/kibana-utils-plugin/common';
import type { Logger } from '@kbn/logging';
@ -22,6 +23,14 @@ export const errorHandler: (logger: Logger) => RequestHandlerWrapper = (logger)
if (isKibanaServerError(e)) {
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;
}
};

View file

@ -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',
},
],
});
});
});
});

View file

@ -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,
};
}

View file

@ -57,6 +57,8 @@
"@kbn/code-editor",
"@kbn/monaco",
"@kbn/react-hooks",
"@kbn/core-http-router-server-mocks",
"@kbn/core-saved-objects-server",
],
"exclude": [
"target/**/*",

View file

@ -28,9 +28,9 @@ import { ALL_SAVED_OBJECT_INDICES } from '@kbn/core-saved-objects-server';
export const refreshSavedObjectIndices = async (es: Client) => {
// 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 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
// 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 });
};

View file

@ -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
}
};

View file

@ -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);

View file

@ -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[];
};
}

View file

@ -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);

View 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('.')],
};
}

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 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'));
});
}

View file

@ -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);
});
});
});
});
}

View file

@ -178,6 +178,7 @@ export function getTestDataLoader({ getService }: Pick<FtrProviderContext, 'getS
deleteAllSavedObjectsFromKibanaIndex: async () => {
await es.deleteByQuery({
index: ALL_SAVED_OBJECT_INDICES,
ignore_unavailable: true,
wait_for_completion: true,
conflicts: 'proceed',
query: {

View file

@ -71,7 +71,7 @@ export default ({ getService }: FtrProviderContext): void => {
// 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
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
const statusAfterPackageInstallation = await getPrebuiltRulesStatus(es, supertest);

View file

@ -37,9 +37,9 @@ export const refreshIndex = async (es: Client, index?: string) => {
export const refreshSavedObjectIndices = async (es: Client) => {
// 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 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
// 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 });
};

View file

@ -19,6 +19,7 @@ export async function getSavedObjectFromES<T>(
return await es.search<T>(
{
index: ALL_SAVED_OBJECT_INDICES,
ignore_unavailable: true,
query: {
bool: {
filter: [

View file

@ -42,6 +42,7 @@ export function getTestScenariosForSpace(spaceId: string) {
export function getAggregatedSpaceData(es: Client, objectTypes: string[]) {
return es.search({
index: ALL_SAVED_OBJECT_INDICES,
ignore_unavailable: true,
request_cache: false,
size: 0,
runtime_mappings: {

View file

@ -199,6 +199,7 @@ export function getTestDataLoader({ getService }: Pick<FtrProviderContext, 'getS
deleteAllSavedObjectsFromKibanaIndex: async () => {
await es.deleteByQuery({
index: ALL_SAVED_OBJECT_INDICES,
ignore_unavailable: true,
wait_for_completion: true,
conflicts: 'proceed',
query: {

View file

@ -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.
const multiNamespaceResponse = await es.search<Record<string, any>>({
index: ALL_SAVED_OBJECT_INDICES,
ignore_unavailable: true,
size: 100,
query: { terms: { type: ['index-pattern'] } },
});

View file

@ -99,11 +99,15 @@ export function updateObjectsSpacesTestSuiteFactory(
if (expectAliasDifference !== undefined) {
// if we deleted an object that had an alias pointing to it, the alias should have been deleted as well
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;
}
const searchResponse = await es.search({
index: ALL_SAVED_OBJECT_INDICES,
ignore_unavailable: true,
size: 0,
query: { terms: { type: ['legacy-url-alias'] } },
track_total_hits: true,

View file

@ -15,5 +15,6 @@ export default function ({ loadTestFile }: FtrProviderContext) {
loadTestFile(require.resolve('./cases/post_case'));
loadTestFile(require.resolve('./serverless_search'));
loadTestFile(require.resolve('./platform_security'));
loadTestFile(require.resolve('./search_playground'));
});
}

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 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'));
});
}

View file

@ -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);
});
});
});
});
}