From f7b28d7b5f678bcf381df73f9fba3ebb7419d599 Mon Sep 17 00:00:00 2001 From: Rodney Norris Date: Thu, 24 Apr 2025 16:20:31 -0500 Subject: [PATCH] [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 Co-authored-by: Elastic Machine --- .buildkite/ftr_platform_stateful_configs.yml | 1 + .github/CODEOWNERS | 1 + .../current_fields.json | 3 + .../current_mappings.json | 13 + .../server-internal/src/object_types/index.ts | 2 +- .../packages/saved-objects/server/index.ts | 1 + .../server/src/saved_objects_index_pattern.ts | 2 + .../check_registered_types.test.ts | 1 + .../migrations/kibana_migrator_test_kit.ts | 8 +- .../registration/type_registrations.test.ts | 1 + .../src/actions/empty_kibana_index.ts | 8 +- .../src/lib/indices/kibana_index.ts | 1 + .../__mocks__/router.mock.ts | 25 +- .../plugins/search_playground/common/index.ts | 6 + .../plugins/search_playground/common/types.ts | 68 +- .../playground_saved_object.ts | 40 ++ .../playground_saved_object/schema/v1/v1.ts | 35 + .../search_playground/server/plugin.ts | 13 +- .../search_playground/server/routes.ts | 23 +- .../server/routes/saved_playgrounds.test.ts | 680 ++++++++++++++++++ .../server/routes/saved_playgrounds.ts | 305 ++++++++ .../plugins/search_playground/server/types.ts | 11 + .../server/utils/error_handler.ts | 9 + .../server/utils/playgrounds.test.ts | 212 ++++++ .../server/utils/playgrounds.ts | 169 +++++ .../plugins/search_playground/tsconfig.json | 2 + .../observability/helpers/refresh_index.ts | 4 +- .../apis/search_playground/common/helpers.ts | 65 ++ .../apis/search_playground/common/roles.ts | 64 ++ .../apis/search_playground/common/types.ts | 47 ++ .../apis/search_playground/common/users.ts | 35 + .../apis/search_playground/config.ts | 17 + .../apis/search_playground/index.ts | 14 + .../apis/search_playground/playgrounds.ts | 467 ++++++++++++ x-pack/test/common/lib/test_data_loader.ts | 1 + .../install_latest_bundled_prebuilt_rules.ts | 2 +- .../utils/refresh_index.ts | 4 +- .../test_suites/utils.ts | 1 + .../common/lib/space_test_utils.ts | 1 + .../common/services/test_data_loader.ts | 1 + .../common/suites/delete.agnostic.ts | 1 + .../common/suites/update_objects_spaces.ts | 6 +- .../test_suites/search/index.ts | 1 + .../search/search_playground/index.ts | 14 + .../search/search_playground/playgrounds.ts | 378 ++++++++++ 45 files changed, 2725 insertions(+), 38 deletions(-) create mode 100644 x-pack/solutions/search/plugins/search_playground/server/playground_saved_object/playground_saved_object.ts create mode 100644 x-pack/solutions/search/plugins/search_playground/server/playground_saved_object/schema/v1/v1.ts create mode 100644 x-pack/solutions/search/plugins/search_playground/server/routes/saved_playgrounds.test.ts create mode 100644 x-pack/solutions/search/plugins/search_playground/server/routes/saved_playgrounds.ts create mode 100644 x-pack/solutions/search/plugins/search_playground/server/utils/playgrounds.test.ts create mode 100644 x-pack/solutions/search/plugins/search_playground/server/utils/playgrounds.ts create mode 100644 x-pack/test/api_integration/apis/search_playground/common/helpers.ts create mode 100644 x-pack/test/api_integration/apis/search_playground/common/roles.ts create mode 100644 x-pack/test/api_integration/apis/search_playground/common/types.ts create mode 100644 x-pack/test/api_integration/apis/search_playground/common/users.ts create mode 100644 x-pack/test/api_integration/apis/search_playground/config.ts create mode 100644 x-pack/test/api_integration/apis/search_playground/index.ts create mode 100644 x-pack/test/api_integration/apis/search_playground/playgrounds.ts create mode 100644 x-pack/test_serverless/api_integration/test_suites/search/search_playground/index.ts create mode 100644 x-pack/test_serverless/api_integration/test_suites/search/search_playground/playgrounds.ts diff --git a/.buildkite/ftr_platform_stateful_configs.yml b/.buildkite/ftr_platform_stateful_configs.yml index cc1d6a4beaf4..0a272036bac4 100644 --- a/.buildkite/ftr_platform_stateful_configs.yml +++ b/.buildkite/ftr_platform_stateful_configs.yml @@ -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 diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 802882f2e985..ac0054d3a742 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -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 diff --git a/packages/kbn-check-mappings-update-cli/current_fields.json b/packages/kbn-check-mappings-update-cli/current_fields.json index 8eb2936a5c86..65725783b6ba 100644 --- a/packages/kbn-check-mappings-update-cli/current_fields.json +++ b/packages/kbn-check-mappings-update-cli/current_fields.json @@ -927,6 +927,9 @@ "username" ], "search-telemetry": [], + "search_playground": [ + "name" + ], "security-ai-prompt": [ "description", "model", diff --git a/packages/kbn-check-mappings-update-cli/current_mappings.json b/packages/kbn-check-mappings-update-cli/current_mappings.json index 93df69084889..a5ad489b5428 100644 --- a/packages/kbn-check-mappings-update-cli/current_mappings.json +++ b/packages/kbn-check-mappings-update-cli/current_mappings.json @@ -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": { diff --git a/src/core/packages/saved-objects/server-internal/src/object_types/index.ts b/src/core/packages/saved-objects/server-internal/src/object_types/index.ts index f4b96cef91b8..eaca99d1dd08 100644 --- a/src/core/packages/saved-objects/server-internal/src/object_types/index.ts +++ b/src/core/packages/saved-objects/server-internal/src/object_types/index.ts @@ -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; diff --git a/src/core/packages/saved-objects/server/index.ts b/src/core/packages/saved-objects/server/index.ts index c34cbd43203c..16fbfa92c224 100644 --- a/src/core/packages/saved-objects/server/index.ts +++ b/src/core/packages/saved-objects/server/index.ts @@ -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, diff --git a/src/core/packages/saved-objects/server/src/saved_objects_index_pattern.ts b/src/core/packages/saved-objects/server/src/saved_objects_index_pattern.ts index 5bd08e6ad441..fec8c8b0283f 100644 --- a/src/core/packages/saved-objects/server/src/saved_objects_index_pattern.ts +++ b/src/core/packages/saved-objects/server/src/saved_objects_index_pattern.ts @@ -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, ]; diff --git a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts index 6c7e4a668503..7a6d533fbd32 100644 --- a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts +++ b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts @@ -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", diff --git a/src/core/server/integration_tests/saved_objects/migrations/kibana_migrator_test_kit.ts b/src/core/server/integration_tests/saved_objects/migrations/kibana_migrator_test_kit.ts index 70b92843add7..0264d62d76b1 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/kibana_migrator_test_kit.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/kibana_migrator_test_kit.ts @@ -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] } ); }; diff --git a/src/core/server/integration_tests/saved_objects/registration/type_registrations.test.ts b/src/core/server/integration_tests/saved_objects/registration/type_registrations.test.ts index c015563d304b..d2288b8c33a2 100644 --- a/src/core/server/integration_tests/saved_objects/registration/type_registrations.test.ts +++ b/src/core/server/integration_tests/saved_objects/registration/type_registrations.test.ts @@ -125,6 +125,7 @@ const previouslyRegisteredTypes = [ 'search', 'search-session', 'search-telemetry', + 'search_playground', 'security-ai-prompt', 'security-rule', 'security-solution-signals-migration', diff --git a/src/platform/packages/shared/kbn-es-archiver/src/actions/empty_kibana_index.ts b/src/platform/packages/shared/kbn-es-archiver/src/actions/empty_kibana_index.ts index f93dbc965836..fc344cf4a1af 100644 --- a/src/platform/packages/shared/kbn-es-archiver/src/actions/empty_kibana_index.ts +++ b/src/platform/packages/shared/kbn-es-archiver/src/actions/empty_kibana_index.ts @@ -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(); } diff --git a/src/platform/packages/shared/kbn-es-archiver/src/lib/indices/kibana_index.ts b/src/platform/packages/shared/kbn-es-archiver/src/lib/indices/kibana_index.ts index b43d5594bc3f..630d0b3b1c1d 100644 --- a/src/platform/packages/shared/kbn-es-archiver/src/lib/indices/kibana_index.ts +++ b/src/platform/packages/shared/kbn-es-archiver/src/lib/indices/kibana_index.ts @@ -122,6 +122,7 @@ export async function cleanSavedObjectIndices({ const resp = await client.deleteByQuery( { index, + ignore_unavailable: true, refresh: true, query: { bool: { diff --git a/x-pack/solutions/search/plugins/search_playground/__mocks__/router.mock.ts b/x-pack/solutions/search/plugins/search_playground/__mocks__/router.mock.ts index 328f44997b75..ac8fa36804d0 100644 --- a/x-pack/solutions/search/plugins/search_playground/__mocks__/router.mock.ts +++ b/x-pack/solutions/search/plugins/search_playground/__mocks__/router.mock.ts @@ -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; } interface IMockRouterRequest { @@ -36,14 +38,21 @@ export class MockRouter { public router!: jest.Mocked; public method: MethodType; public path: string; + public version?: string; public context: jest.Mocked; public payload?: PayloadType; public response = httpServerMock.createResponseFactory(); - constructor({ method, path, context = {} as jest.Mocked }: IMockRouter) { + constructor({ + method, + path, + version, + context = {} as jest.Mocked, + }: 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.'); diff --git a/x-pack/solutions/search/plugins/search_playground/common/index.ts b/x-pack/solutions/search/plugins/search_playground/common/index.ts index 5d972f6a17e0..e83878e9ace3 100644 --- a/x-pack/solutions/search/plugins/search_playground/common/index.ts +++ b/x-pack/solutions/search/plugins/search_playground/common/index.ts @@ -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'; diff --git a/x-pack/solutions/search/plugins/search_playground/common/types.ts b/x-pack/solutions/search/plugins/search_playground/common/types.ts index eae65a692ae8..7eb6a834e56b 100644 --- a/x-pack/solutions/search/plugins/search_playground/common/types.ts +++ b/x-pack/solutions/search/plugins/search_playground/common/types.ts @@ -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; + elasticsearchQueryJSON: string; + userElasticsearchQueryJSON?: string; + prompt?: string; + citations?: boolean; + context?: { + sourceFields: Record; + 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[]; +} diff --git a/x-pack/solutions/search/plugins/search_playground/server/playground_saved_object/playground_saved_object.ts b/x-pack/solutions/search/plugins/search_playground/server/playground_saved_object/playground_saved_object.ts new file mode 100644 index 000000000000..0e89cf12c6b8 --- /dev/null +++ b/x-pack/solutions/search/plugins/search_playground/server/playground_saved_object/playground_saved_object.ts @@ -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, + }, + }, + }, +}); diff --git a/x-pack/solutions/search/plugins/search_playground/server/playground_saved_object/schema/v1/v1.ts b/x-pack/solutions/search/plugins/search_playground/server/playground_saved_object/schema/v1/v1.ts new file mode 100644 index 000000000000..fd3abb3eee7b --- /dev/null +++ b/x-pack/solutions/search/plugins/search_playground/server/playground_saved_object/schema/v1/v1.ts @@ -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()), + }) + ), +}); diff --git a/x-pack/solutions/search/plugins/search_playground/server/plugin.ts b/x-pack/solutions/search/plugins/search_playground/server/plugin.ts index f2291de820a2..66900da5ee8f 100644 --- a/x-pack/solutions/search/plugins/search_playground/server/plugin.ts +++ b/x-pack/solutions/search/plugins/search_playground/server/plugin.ts @@ -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: [], }, diff --git a/x-pack/solutions/search/plugins/search_playground/server/routes.ts b/x-pack/solutions/search/plugins/search_playground/server/routes.ts index 039d5d77b27e..0e7adeee9ae9 100644 --- a/x-pack/solutions/search/plugins/search_playground/server/routes.ts +++ b/x-pack/solutions/search/plugins/search_playground/server/routes.ts @@ -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); } diff --git a/x-pack/solutions/search/plugins/search_playground/server/routes/saved_playgrounds.test.ts b/x-pack/solutions/search/plugins/search_playground/server/routes/saved_playgrounds.test.ts new file mode 100644 index 000000000000..678b76ba5a51 --- /dev/null +++ b/x-pack/solutions/search/plugins/search_playground/server/routes/saved_playgrounds.test.ts @@ -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; + let mockGetStartServices: jest.Mocked< + StartServicesAccessor + >; + + beforeEach(() => { + jest.clearAllMocks(); + + const coreStart = coreMock.createStart(); + mockGetStartServices = jest.fn().mockResolvedValue([coreStart, {}, {}]); + + context = { + core: Promise.resolve(mockCore), + } as unknown as jest.Mocked; + }); + + 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', + }, + }); + }); + }); + }); +}); diff --git a/x-pack/solutions/search/plugins/search_playground/server/routes/saved_playgrounds.ts b/x-pack/solutions/search/plugins/search_playground/server/routes/saved_playgrounds.ts new file mode 100644 index 000000000000..9901edf06481 --- /dev/null +++ b/x-pack/solutions/search/plugins/search_playground/server/routes/saved_playgrounds.ts @@ -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({ + 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( + 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( + 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( + 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(); + }) + ); +}; diff --git a/x-pack/solutions/search/plugins/search_playground/server/types.ts b/x-pack/solutions/search/plugins/search_playground/server/types.ts index e0a7d1243f48..b24417b87ae7 100644 --- a/x-pack/solutions/search/plugins/search_playground/server/types.ts +++ b/x-pack/solutions/search/plugins/search_playground/server/types.ts @@ -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; + +export interface DefineRoutesOptions { + logger: Logger; + router: IRouter; + getStartServices: StartServicesAccessor< + SearchPlaygroundPluginStartDependencies, + SearchPlaygroundPluginStart + >; +} diff --git a/x-pack/solutions/search/plugins/search_playground/server/utils/error_handler.ts b/x-pack/solutions/search/plugins/search_playground/server/utils/error_handler.ts index b4b3894125bd..ac19042e465a 100644 --- a/x-pack/solutions/search/plugins/search_playground/server/utils/error_handler.ts +++ b/x-pack/solutions/search/plugins/search_playground/server/utils/error_handler.ts @@ -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; } }; diff --git a/x-pack/solutions/search/plugins/search_playground/server/utils/playgrounds.test.ts b/x-pack/solutions/search/plugins/search_playground/server/utils/playgrounds.test.ts new file mode 100644 index 000000000000..2344ade76774 --- /dev/null +++ b/x-pack/solutions/search/plugins/search_playground/server/utils/playgrounds.test.ts @@ -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 = { + 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 = { + 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> = [ + { + 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', + }, + ], + }); + }); + }); +}); diff --git a/x-pack/solutions/search/plugins/search_playground/server/utils/playgrounds.ts b/x-pack/solutions/search/plugins/search_playground/server/utils/playgrounds.ts new file mode 100644 index 000000000000..9eae39b63dd4 --- /dev/null +++ b/x-pack/solutions/search/plugins/search_playground/server/utils/playgrounds.ts @@ -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 +): 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 +): 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, + }; +} diff --git a/x-pack/solutions/search/plugins/search_playground/tsconfig.json b/x-pack/solutions/search/plugins/search_playground/tsconfig.json index ac4f4a61e639..fbfc5ad10fde 100644 --- a/x-pack/solutions/search/plugins/search_playground/tsconfig.json +++ b/x-pack/solutions/search/plugins/search_playground/tsconfig.json @@ -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/**/*", diff --git a/x-pack/test/alerting_api_integration/observability/helpers/refresh_index.ts b/x-pack/test/alerting_api_integration/observability/helpers/refresh_index.ts index 4808a07ba4b1..2933779ea896 100644 --- a/x-pack/test/alerting_api_integration/observability/helpers/refresh_index.ts +++ b/x-pack/test/alerting_api_integration/observability/helpers/refresh_index.ts @@ -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 }); }; diff --git a/x-pack/test/api_integration/apis/search_playground/common/helpers.ts b/x-pack/test/api_integration/apis/search_playground/common/helpers.ts new file mode 100644 index 000000000000..c4822f75943d --- /dev/null +++ b/x-pack/test/api_integration/apis/search_playground/common/helpers.ts @@ -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 + } +}; diff --git a/x-pack/test/api_integration/apis/search_playground/common/roles.ts b/x-pack/test/api_integration/apis/search_playground/common/roles.ts new file mode 100644 index 000000000000..f89285d28bcd --- /dev/null +++ b/x-pack/test/api_integration/apis/search_playground/common/roles.ts @@ -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); diff --git a/x-pack/test/api_integration/apis/search_playground/common/types.ts b/x-pack/test/api_integration/apis/search_playground/common/types.ts new file mode 100644 index 000000000000..a575821e2d59 --- /dev/null +++ b/x-pack/test/api_integration/apis/search_playground/common/types.ts @@ -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[]; + }; +} diff --git a/x-pack/test/api_integration/apis/search_playground/common/users.ts b/x-pack/test/api_integration/apis/search_playground/common/users.ts new file mode 100644 index 000000000000..0f0d8973bb81 --- /dev/null +++ b/x-pack/test/api_integration/apis/search_playground/common/users.ts @@ -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); diff --git a/x-pack/test/api_integration/apis/search_playground/config.ts b/x-pack/test/api_integration/apis/search_playground/config.ts new file mode 100644 index 000000000000..5f335f116fef --- /dev/null +++ b/x-pack/test/api_integration/apis/search_playground/config.ts @@ -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('.')], + }; +} diff --git a/x-pack/test/api_integration/apis/search_playground/index.ts b/x-pack/test/api_integration/apis/search_playground/index.ts new file mode 100644 index 000000000000..327eba1c1cd8 --- /dev/null +++ b/x-pack/test/api_integration/apis/search_playground/index.ts @@ -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')); + }); +} diff --git a/x-pack/test/api_integration/apis/search_playground/playgrounds.ts b/x-pack/test/api_integration/apis/search_playground/playgrounds.ts new file mode 100644 index 000000000000..a0d3ae16e256 --- /dev/null +++ b/x-pack/test/api_integration/apis/search_playground/playgrounds.ts @@ -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 = new Set(); + 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); + }); + }); + }); + }); +} diff --git a/x-pack/test/common/lib/test_data_loader.ts b/x-pack/test/common/lib/test_data_loader.ts index a31bf96812f2..20796fcd8e58 100644 --- a/x-pack/test/common/lib/test_data_loader.ts +++ b/x-pack/test/common/lib/test_data_loader.ts @@ -178,6 +178,7 @@ export function getTestDataLoader({ getService }: Pick { await es.deleteByQuery({ index: ALL_SAVED_OBJECT_INDICES, + ignore_unavailable: true, wait_for_completion: true, conflicts: 'proceed', query: { diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/bundled_prebuilt_rules_package/trial_license_complete_tier/install_latest_bundled_prebuilt_rules.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/bundled_prebuilt_rules_package/trial_license_complete_tier/install_latest_bundled_prebuilt_rules.ts index 52386aaa6d01..fa2adc20d415 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/bundled_prebuilt_rules_package/trial_license_complete_tier/install_latest_bundled_prebuilt_rules.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/bundled_prebuilt_rules_package/trial_license_complete_tier/install_latest_bundled_prebuilt_rules.ts @@ -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); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/refresh_index.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/refresh_index.ts index 16439adb00e3..2d73447eee31 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/refresh_index.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/refresh_index.ts @@ -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 }); }; diff --git a/x-pack/test/security_solution_api_integration/test_suites/utils.ts b/x-pack/test/security_solution_api_integration/test_suites/utils.ts index 716a2cb30c09..386e46aa1fd3 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/utils.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/utils.ts @@ -19,6 +19,7 @@ export async function getSavedObjectFromES( return await es.search( { index: ALL_SAVED_OBJECT_INDICES, + ignore_unavailable: true, query: { bool: { filter: [ diff --git a/x-pack/test/spaces_api_integration/common/lib/space_test_utils.ts b/x-pack/test/spaces_api_integration/common/lib/space_test_utils.ts index 99fdbddead9f..4ea48e94ba43 100644 --- a/x-pack/test/spaces_api_integration/common/lib/space_test_utils.ts +++ b/x-pack/test/spaces_api_integration/common/lib/space_test_utils.ts @@ -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: { diff --git a/x-pack/test/spaces_api_integration/common/services/test_data_loader.ts b/x-pack/test/spaces_api_integration/common/services/test_data_loader.ts index b4da9abc8891..56587dabf38a 100644 --- a/x-pack/test/spaces_api_integration/common/services/test_data_loader.ts +++ b/x-pack/test/spaces_api_integration/common/services/test_data_loader.ts @@ -199,6 +199,7 @@ export function getTestDataLoader({ getService }: Pick { await es.deleteByQuery({ index: ALL_SAVED_OBJECT_INDICES, + ignore_unavailable: true, wait_for_completion: true, conflicts: 'proceed', query: { diff --git a/x-pack/test/spaces_api_integration/common/suites/delete.agnostic.ts b/x-pack/test/spaces_api_integration/common/suites/delete.agnostic.ts index 79f70eabd4e6..4d2b597c41d5 100644 --- a/x-pack/test/spaces_api_integration/common/suites/delete.agnostic.ts +++ b/x-pack/test/spaces_api_integration/common/suites/delete.agnostic.ts @@ -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>({ index: ALL_SAVED_OBJECT_INDICES, + ignore_unavailable: true, size: 100, query: { terms: { type: ['index-pattern'] } }, }); diff --git a/x-pack/test/spaces_api_integration/common/suites/update_objects_spaces.ts b/x-pack/test/spaces_api_integration/common/suites/update_objects_spaces.ts index 5334034f7686..de5c716b35ac 100644 --- a/x-pack/test/spaces_api_integration/common/suites/update_objects_spaces.ts +++ b/x-pack/test/spaces_api_integration/common/suites/update_objects_spaces.ts @@ -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, diff --git a/x-pack/test_serverless/api_integration/test_suites/search/index.ts b/x-pack/test_serverless/api_integration/test_suites/search/index.ts index 42b8d0dd9043..27094c9fb9eb 100644 --- a/x-pack/test_serverless/api_integration/test_suites/search/index.ts +++ b/x-pack/test_serverless/api_integration/test_suites/search/index.ts @@ -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')); }); } diff --git a/x-pack/test_serverless/api_integration/test_suites/search/search_playground/index.ts b/x-pack/test_serverless/api_integration/test_suites/search/search_playground/index.ts new file mode 100644 index 000000000000..bbd5640a698d --- /dev/null +++ b/x-pack/test_serverless/api_integration/test_suites/search/search_playground/index.ts @@ -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')); + }); +} diff --git a/x-pack/test_serverless/api_integration/test_suites/search/search_playground/playgrounds.ts b/x-pack/test_serverless/api_integration/test_suites/search/search_playground/playgrounds.ts new file mode 100644 index 000000000000..a340abeabb03 --- /dev/null +++ b/x-pack/test_serverless/api_integration/test_suites/search/search_playground/playgrounds.ts @@ -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 = new Set(); + 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); + }); + }); + }); + }); +}