diff --git a/.buildkite/ftr_security_serverless_configs.yml b/.buildkite/ftr_security_serverless_configs.yml index 4e575c081323b..52e92332cd506 100644 --- a/.buildkite/ftr_security_serverless_configs.yml +++ b/.buildkite/ftr_security_serverless_configs.yml @@ -108,6 +108,8 @@ enabled: - x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/attack_discovery/schedules/trial_license_complete_tier/configs/serverless.config.ts - x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/attack_discovery/schedules/basic_license_essentials_tier/configs/ess.config.ts - x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/attack_discovery/schedules/basic_license_essentials_tier/configs/serverless.config.ts + - x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/trial_license_complete_tier/configs/ess.config.ts + - x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/trial_license_complete_tier/configs/serverless.config.ts - x-pack/solutions/security/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/configs/serverless.config.ts - x-pack/solutions/security/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/basic_license_essentials_tier/configs/serverless.config.ts - x-pack/solutions/security/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/configs/serverless.config.ts diff --git a/oas_docs/output/kibana.serverless.yaml b/oas_docs/output/kibana.serverless.yaml index 518385e1aa2ae..bc4870e52ff21 100644 --- a/oas_docs/output/kibana.serverless.yaml +++ b/oas_docs/output/kibana.serverless.yaml @@ -61544,14 +61544,6 @@ components: - insights example: assistant type: string - Security_AI_Assistant_API_ConversationConfidence: - description: The conversation confidence. - enum: - - low - - medium - - high - example: high - type: string Security_AI_Assistant_API_ConversationCreateProps: type: object properties: @@ -61636,24 +61628,28 @@ components: - namespace - category Security_AI_Assistant_API_ConversationSummary: + allOf: + - $ref: '#/components/schemas/Security_AI_Assistant_API_ConversationSummaryBase' + - type: object + properties: + timestamp: + $ref: '#/components/schemas/Security_AI_Assistant_API_NonEmptyTimestamp' + description: The timestamp summary was updated. + example: '2025-04-30T16:00:00Z' + required: + - timestamp + Security_AI_Assistant_API_ConversationSummaryBase: type: object properties: - confidence: - $ref: '#/components/schemas/Security_AI_Assistant_API_ConversationConfidence' - description: How confident you are about this being a correct and useful learning. - example: high - content: + semanticContent: description: Summary text of the conversation over time. example: This conversation covered how to configure the Security AI Assistant. type: string - public: - description: Define if summary is marked as publicly available. - example: true - type: boolean - timestamp: - $ref: '#/components/schemas/Security_AI_Assistant_API_NonEmptyTimestamp' - description: The timestamp summary was updated. - example: '2025-04-30T16:00:00Z' + summarizedMessageIds: + description: The list of summarized messages. + items: + $ref: '#/components/schemas/Security_AI_Assistant_API_NonEmptyString' + type: array Security_AI_Assistant_API_ConversationUpdateProps: type: object properties: @@ -61677,7 +61673,7 @@ components: replacements: $ref: '#/components/schemas/Security_AI_Assistant_API_Replacements' summary: - $ref: '#/components/schemas/Security_AI_Assistant_API_ConversationSummary' + $ref: '#/components/schemas/Security_AI_Assistant_API_ConversationSummaryBase' title: description: The conversation title. example: Updated Security AI Assistant Setup diff --git a/oas_docs/output/kibana.yaml b/oas_docs/output/kibana.yaml index 5bf8ab802d589..7356577c674b1 100644 --- a/oas_docs/output/kibana.yaml +++ b/oas_docs/output/kibana.yaml @@ -74093,14 +74093,6 @@ components: - insights example: assistant type: string - Security_AI_Assistant_API_ConversationConfidence: - description: The conversation confidence. - enum: - - low - - medium - - high - example: high - type: string Security_AI_Assistant_API_ConversationCreateProps: type: object properties: @@ -74185,24 +74177,28 @@ components: - namespace - category Security_AI_Assistant_API_ConversationSummary: + allOf: + - $ref: '#/components/schemas/Security_AI_Assistant_API_ConversationSummaryBase' + - type: object + properties: + timestamp: + $ref: '#/components/schemas/Security_AI_Assistant_API_NonEmptyTimestamp' + description: The timestamp summary was updated. + example: '2025-04-30T16:00:00Z' + required: + - timestamp + Security_AI_Assistant_API_ConversationSummaryBase: type: object properties: - confidence: - $ref: '#/components/schemas/Security_AI_Assistant_API_ConversationConfidence' - description: How confident you are about this being a correct and useful learning. - example: high - content: + semanticContent: description: Summary text of the conversation over time. example: This conversation covered how to configure the Security AI Assistant. type: string - public: - description: Define if summary is marked as publicly available. - example: true - type: boolean - timestamp: - $ref: '#/components/schemas/Security_AI_Assistant_API_NonEmptyTimestamp' - description: The timestamp summary was updated. - example: '2025-04-30T16:00:00Z' + summarizedMessageIds: + description: The list of summarized messages. + items: + $ref: '#/components/schemas/Security_AI_Assistant_API_NonEmptyString' + type: array Security_AI_Assistant_API_ConversationUpdateProps: type: object properties: @@ -74226,7 +74222,7 @@ components: replacements: $ref: '#/components/schemas/Security_AI_Assistant_API_Replacements' summary: - $ref: '#/components/schemas/Security_AI_Assistant_API_ConversationSummary' + $ref: '#/components/schemas/Security_AI_Assistant_API_ConversationSummaryBase' title: description: The conversation title. example: Updated Security AI Assistant Setup diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant-common/docs/openapi/ess/elastic_assistant_api_2023_10_31.bundled.schema.yaml b/x-pack/platform/packages/shared/kbn-elastic-assistant-common/docs/openapi/ess/elastic_assistant_api_2023_10_31.bundled.schema.yaml index 7aaaf4b064a33..3358f427983dc 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant-common/docs/openapi/ess/elastic_assistant_api_2023_10_31.bundled.schema.yaml +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant-common/docs/openapi/ess/elastic_assistant_api_2023_10_31.bundled.schema.yaml @@ -1959,14 +1959,6 @@ components: - insights example: assistant type: string - ConversationConfidence: - description: The conversation confidence. - enum: - - low - - medium - - high - example: high - type: string ConversationCreateProps: type: object properties: @@ -2051,28 +2043,30 @@ components: - namespace - category ConversationSummary: + allOf: + - $ref: '#/components/schemas/ConversationSummaryBase' + - type: object + properties: + timestamp: + $ref: '#/components/schemas/NonEmptyTimestamp' + description: The timestamp summary was updated. + example: '2025-04-30T16:00:00Z' + required: + - timestamp + ConversationSummaryBase: type: object properties: - confidence: - $ref: '#/components/schemas/ConversationConfidence' - description: >- - How confident you are about this being a correct and useful - learning. - example: high - content: + semanticContent: description: Summary text of the conversation over time. example: >- This conversation covered how to configure the Security AI Assistant. type: string - public: - description: Define if summary is marked as publicly available. - example: true - type: boolean - timestamp: - $ref: '#/components/schemas/NonEmptyTimestamp' - description: The timestamp summary was updated. - example: '2025-04-30T16:00:00Z' + summarizedMessageIds: + description: The list of summarized messages. + items: + $ref: '#/components/schemas/NonEmptyString' + type: array ConversationUpdateProps: type: object properties: @@ -2096,7 +2090,7 @@ components: replacements: $ref: '#/components/schemas/Replacements' summary: - $ref: '#/components/schemas/ConversationSummary' + $ref: '#/components/schemas/ConversationSummaryBase' title: description: The conversation title. example: Updated Security AI Assistant Setup diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant-common/docs/openapi/serverless/elastic_assistant_api_2023_10_31.bundled.schema.yaml b/x-pack/platform/packages/shared/kbn-elastic-assistant-common/docs/openapi/serverless/elastic_assistant_api_2023_10_31.bundled.schema.yaml index d0ba85456497c..c6d4fd2494d75 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant-common/docs/openapi/serverless/elastic_assistant_api_2023_10_31.bundled.schema.yaml +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant-common/docs/openapi/serverless/elastic_assistant_api_2023_10_31.bundled.schema.yaml @@ -1959,14 +1959,6 @@ components: - insights example: assistant type: string - ConversationConfidence: - description: The conversation confidence. - enum: - - low - - medium - - high - example: high - type: string ConversationCreateProps: type: object properties: @@ -2051,28 +2043,30 @@ components: - namespace - category ConversationSummary: + allOf: + - $ref: '#/components/schemas/ConversationSummaryBase' + - type: object + properties: + timestamp: + $ref: '#/components/schemas/NonEmptyTimestamp' + description: The timestamp summary was updated. + example: '2025-04-30T16:00:00Z' + required: + - timestamp + ConversationSummaryBase: type: object properties: - confidence: - $ref: '#/components/schemas/ConversationConfidence' - description: >- - How confident you are about this being a correct and useful - learning. - example: high - content: + semanticContent: description: Summary text of the conversation over time. example: >- This conversation covered how to configure the Security AI Assistant. type: string - public: - description: Define if summary is marked as publicly available. - example: true - type: boolean - timestamp: - $ref: '#/components/schemas/NonEmptyTimestamp' - description: The timestamp summary was updated. - example: '2025-04-30T16:00:00Z' + summarizedMessageIds: + description: The list of summarized messages. + items: + $ref: '#/components/schemas/NonEmptyString' + type: array ConversationUpdateProps: type: object properties: @@ -2096,7 +2090,7 @@ components: replacements: $ref: '#/components/schemas/Replacements' summary: - $ref: '#/components/schemas/ConversationSummary' + $ref: '#/components/schemas/ConversationSummaryBase' title: description: The conversation title. example: Updated Security AI Assistant Setup diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/schemas/conversations/common_attributes.gen.ts b/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/schemas/conversations/common_attributes.gen.ts index 4d1c14e6d45f4..9fbff11764c9d 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/schemas/conversations/common_attributes.gen.ts +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/schemas/conversations/common_attributes.gen.ts @@ -298,26 +298,28 @@ export const ApiConfig = z.object({ model: z.string().optional(), }); -export type ConversationSummary = z.infer; -export const ConversationSummary = z.object({ +export type ConversationSummaryBase = z.infer; +export const ConversationSummaryBase = z.object({ /** * Summary text of the conversation over time. */ - content: z.string().optional(), - /** - * The timestamp summary was updated. - */ - timestamp: NonEmptyTimestamp.optional(), + semanticContent: z.string().optional(), /** - * Define if summary is marked as publicly available. + * The list of summarized messages. */ - public: z.boolean().optional(), - /** - * How confident you are about this being a correct and useful learning. - */ - confidence: ConversationConfidence.optional(), + summarizedMessageIds: z.array(NonEmptyString).optional(), }); +export type ConversationSummary = z.infer; +export const ConversationSummary = ConversationSummaryBase.merge( + z.object({ + /** + * The timestamp summary was updated. + */ + timestamp: NonEmptyTimestamp, + }) +); + export type ErrorSchema = z.infer; export const ErrorSchema = z .object({ @@ -389,7 +391,7 @@ export const ConversationUpdateProps = z.object({ * LLM API configuration. */ apiConfig: ApiConfig.optional(), - summary: ConversationSummary.optional(), + summary: ConversationSummaryBase.optional(), /** * Exclude from last conversation storage. */ diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/schemas/conversations/common_attributes.schema.yaml b/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/schemas/conversations/common_attributes.schema.yaml index 48d8cdbe892d2..4990574056956 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/schemas/conversations/common_attributes.schema.yaml +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/schemas/conversations/common_attributes.schema.yaml @@ -306,25 +306,30 @@ components: description: Model example: 'gpt-4' - ConversationSummary: + ConversationSummaryBase: type: object properties: - content: + semanticContent: type: string description: Summary text of the conversation over time. example: 'This conversation covered how to configure the Security AI Assistant.' - timestamp: - $ref: '../common_attributes.schema.yaml#/components/schemas/NonEmptyTimestamp' - description: The timestamp summary was updated. - example: '2025-04-30T16:00:00Z' - public: - type: boolean - description: Define if summary is marked as publicly available. - example: true - confidence: - $ref: '#/components/schemas/ConversationConfidence' - description: How confident you are about this being a correct and useful learning. - example: 'high' + summarizedMessageIds: + type: array + description: The list of summarized messages. + items: + $ref: '../common_attributes.schema.yaml#/components/schemas/NonEmptyString' + + ConversationSummary: + allOf: + - $ref: '#/components/schemas/ConversationSummaryBase' + - type: object + required: + - timestamp + properties: + timestamp: + $ref: '../common_attributes.schema.yaml#/components/schemas/NonEmptyTimestamp' + description: The timestamp summary was updated. + example: '2025-04-30T16:00:00Z' ErrorSchema: type: object @@ -426,7 +431,7 @@ components: $ref: '#/components/schemas/ApiConfig' description: LLM API configuration. summary: - $ref: '#/components/schemas/ConversationSummary' + $ref: '#/components/schemas/ConversationSummaryBase' excludeFromLastConversationStorage: description: Exclude from last conversation storage. type: boolean diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/__mocks__/conversations_schema.mock.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/__mocks__/conversations_schema.mock.ts index ac6cddf9623d8..4fa12522efdd0 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/__mocks__/conversations_schema.mock.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/__mocks__/conversations_schema.mock.ts @@ -11,8 +11,9 @@ import type { PerformBulkActionRequestBody, ConversationCreateProps, ConversationResponse, - ConversationUpdateProps, DeleteAllConversationsRequestBody, + ConversationUpdateProps, + ConversationSummary, } from '@kbn/elastic-assistant-common'; import type { CreateMessageSchema, @@ -95,7 +96,7 @@ export const getDeleteAllConversationsSchemaMock = (): DeleteAllConversationsReq export const getUpdateConversationSchemaMock = ( conversationId = 'conversation-1' -): ConversationUpdateProps => ({ +): ConversationUpdateProps & { summary?: ConversationSummary } => ({ title: 'Welcome 2', apiConfig: { actionTypeId: '.gen-ai', @@ -133,9 +134,11 @@ export const getAppendConversationMessagesSchemaMock = ], }); -export const getConversationMock = ( - params: ConversationCreateProps | ConversationUpdateProps -): ConversationResponse => ({ +export type ConversationMockParams = (ConversationCreateProps | ConversationUpdateProps) & { + summary?: ConversationSummary; +}; + +export const getConversationMock = (params: ConversationMockParams): ConversationResponse => ({ id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', apiConfig: { actionTypeId: '.gen-ai', @@ -155,9 +158,7 @@ export const getConversationMock = ( ], }); -export const getQueryConversationParams = ( - isUpdate?: boolean -): ConversationCreateProps | ConversationUpdateProps => { +export const getQueryConversationParams = (isUpdate?: boolean): ConversationMockParams => { return isUpdate ? { title: 'Welcome 2', diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/field_maps_configuration.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/field_maps_configuration.ts index 71a52366213ba..7ea5c32f7fe4c 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/field_maps_configuration.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/field_maps_configuration.ts @@ -5,6 +5,28 @@ * 2.0. */ import type { FieldMap } from '@kbn/data-stream-adapter'; +import { defaultInferenceEndpoints } from '@kbn/inference-common'; + +/** + * These are legacy fields that were never used, but we cannot just remove it from mapping. + */ +const legacySummaryFields: FieldMap = { + 'summary.content': { + type: 'text', + array: false, + required: false, + }, + 'summary.public': { + type: 'boolean', + array: false, + required: false, + }, + 'summary.confidence': { + type: 'keyword', + array: false, + required: false, + }, +}; export const conversationsFieldMap: FieldMap = { '@timestamp': { @@ -122,26 +144,23 @@ export const conversationsFieldMap: FieldMap = { array: false, required: false, }, - 'summary.content': { - type: 'text', - array: false, - required: false, - }, 'summary.@timestamp': { type: 'date', array: false, required: true, }, - 'summary.public': { - type: 'boolean', + 'summary.semantic_content': { + type: 'semantic_text', array: false, required: false, + inference_id: defaultInferenceEndpoints.ELSER, }, - 'summary.confidence': { + 'summary.summarized_message_ids': { type: 'keyword', - array: false, + array: true, required: false, }, + ...legacySummaryFields, api_config: { type: 'object', array: false, diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/get_conversation.test.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/get_conversation.test.ts index 79f77a60f2a70..7716c8cd346ff 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/get_conversation.test.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/get_conversation.test.ts @@ -31,7 +31,8 @@ export const getConversationResponseMock = (): ConversationResponse => ({ provider: 'Azure OpenAI', }, summary: { - content: 'test', + timestamp: '2020-04-20T15:25:31.830Z', + semanticContent: 'test', }, category: 'assistant', users: [ @@ -76,7 +77,8 @@ export const getSearchConversationMock = (): estypes.SearchResponse { + return { + summary: { + '@timestamp': '2025-08-19T13:26:01.746Z', + semantic_content: 'Very nice demo semantic content 4.', + }, + updated_at: '2025-08-19T13:26:01.746Z', + api_config: { + action_type_id: '.gen-ai', + connector_id: 'gpt-4-1', + }, + replacements: [], + title: 'Viewing the Number of Open Alerts in Elastic Security', + id: 'a565baa8-5566-47b2-ab69-807248b2fc46', + }; +}; + +describe('helpers', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('getUpdateScript', () => { + it('should always return a doc for bulk updates', () => { + const updateConversation = getUpdateConversationMock(); + const updateScript = getUpdateScript({ conversation: updateConversation }); + expect(updateScript).toEqual({ doc: updateConversation }); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/helpers.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/helpers.ts index f5a0b2454a3bf..849e5d60e5178 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/helpers.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/helpers.ts @@ -7,82 +7,10 @@ import type { UpdateConversationSchema } from './update_conversation'; -export const getUpdateScript = ({ - conversation, - isPatch, -}: { - conversation: UpdateConversationSchema; - isPatch?: boolean; -}) => { +export const getUpdateScript = ({ conversation }: { conversation: UpdateConversationSchema }) => { + // https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/semantic-text#update-script + // Cannot use script for bulk update of the documents with semantic_text fields return { - script: { - source: ` - if (params.assignEmpty == true || params.containsKey('api_config')) { - if (ctx._source.api_config != null) { - if (params.assignEmpty == true || params.api_config.containsKey('connector_id')) { - ctx._source.api_config.connector_id = params.api_config.connector_id; - ctx._source.api_config.remove('model'); - ctx._source.api_config.remove('provider'); - } - // an update to apiConfig that does not contain defaultSystemPromptId should remove it - if (params.assignEmpty == true || (params.containsKey('api_config') && !params.api_config.containsKey('default_system_prompt_id'))) { - ctx._source.api_config.remove('default_system_prompt_id'); - } - if (params.assignEmpty == true || params.api_config.containsKey('action_type_id')) { - ctx._source.api_config.action_type_id = params.api_config.action_type_id; - } - if (params.assignEmpty == true || params.api_config.containsKey('default_system_prompt_id')) { - ctx._source.api_config.default_system_prompt_id = params.api_config.default_system_prompt_id; - } - if (params.assignEmpty == true || params.api_config.containsKey('model')) { - ctx._source.api_config.model = params.api_config.model; - } - if (params.assignEmpty == true || params.api_config.containsKey('provider')) { - ctx._source.api_config.provider = params.api_config.provider; - } - } else { - ctx._source.api_config = params.api_config; - } - } - if (params.assignEmpty == true || params.containsKey('exclude_from_last_conversation_storage')) { - ctx._source.exclude_from_last_conversation_storage = params.exclude_from_last_conversation_storage; - } - if (params.assignEmpty == true || params.containsKey('replacements')) { - ctx._source.replacements = params.replacements; - } - if (params.assignEmpty == true || params.containsKey('title')) { - ctx._source.title = params.title; - } - if (params.assignEmpty == true || params.containsKey('messages')) { - def messages = []; - for (message in params.messages) { - def newMessage = [:]; - newMessage['@timestamp'] = message['@timestamp']; - newMessage.content = message.content; - newMessage.is_error = message.is_error; - newMessage.reader = message.reader; - newMessage.role = message.role; - if (message.trace_data != null) { - newMessage.trace_data = message.trace_data; - } - if (message.metadata != null) { - newMessage.metadata = [:]; - if (message.metadata.content_references != null) { - newMessage.metadata.content_references = message.metadata.content_references; - } - } - messages.add(newMessage); - } - ctx._source.messages = messages; - } - ctx._source.updated_at = params.updated_at; - `, - lang: 'painless', - params: { - ...conversation, // when assigning undefined in painless, it will remove property and wil set it to null - // for patch we don't want to remove unspecified value in payload - assignEmpty: !(isPatch ?? true), - }, - }, + doc: conversation, }; }; diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/index.test.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/index.test.ts index 947979482e802..9519dc8ecdc9c 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/index.test.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/index.test.ts @@ -5,7 +5,7 @@ * 2.0. */ import { elasticsearchServiceMock, loggingSystemMock } from '@kbn/core/server/mocks'; -import type { UpdateByQueryRequest } from '@elastic/elasticsearch/lib/api/types'; +import type { BulkRequest } from '@elastic/elasticsearch/lib/api/types'; import { AIAssistantConversationsDataClient } from '.'; import { getUpdateConversationSchemaMock } from '../../__mocks__/conversations_schema.mock'; import { authenticatedUser } from '../../__mocks__/user'; @@ -146,6 +146,20 @@ describe('AIAssistantConversationsDataClient', () => { }); test('should update conversation with new messages', async () => { + clusterClient.search.mockReturnValue({ + // @ts-ignore + hits: { + total: { value: 1 }, + hits: [ + { + _id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + _index: 'test-index', + _source: {}, + }, + ], + }, + }); + const assistantConversationsDataClient = new AIAssistantConversationsDataClient( assistantConversationsDataClientParams ); @@ -156,45 +170,42 @@ describe('AIAssistantConversationsDataClient', () => { ), }); - const params = clusterClient.updateByQuery.mock.calls[0][0] as UpdateByQueryRequest; + const params = clusterClient.bulk.mock.calls[0][0] as BulkRequest; - expect(params.query).toEqual({ - ids: { - values: ['04128c15-0d1b-4716-a4c5-46997ac7f3bd'], - }, - }); - - expect(params.script).toEqual({ - source: expect.anything(), - lang: 'painless', - params: { - api_config: { - action_type_id: '.gen-ai', - connector_id: '2', - default_system_prompt_id: 'Default', - model: 'model', - provider: undefined, + expect(params.refresh).toEqual('wait_for'); + expect(params.body).toEqual([ + { + update: { + _id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + _index: 'test-index', + _source: true, }, - assignEmpty: false, - exclude_from_last_conversation_storage: false, - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - messages: [ - { - '@timestamp': '2019-12-13T16:40:33.400Z', - content: 'test content', - is_error: undefined, - reader: undefined, - role: 'user', - trace_data: { - trace_id: '1', - transaction_id: '2', - }, + }, + { + doc: { + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + updated_at: '2023-03-28T22:27:28.159Z', + title: 'Welcome 2', + api_config: { + action_type_id: '.gen-ai', + connector_id: '2', + default_system_prompt_id: 'Default', + model: 'model', }, - ], - replacements: undefined, - title: 'Welcome 2', - updated_at: '2023-03-28T22:27:28.159Z', + exclude_from_last_conversation_storage: false, + messages: [ + { + '@timestamp': '2019-12-13T16:40:33.400Z', + content: 'test content', + role: 'user', + trace_data: { + trace_id: '1', + transaction_id: '2', + }, + }, + ], + }, }, - }); + ]); }); }); diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/index.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/index.ts index 1184d6c87cf5f..43ead6c81b84a 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/index.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/index.ts @@ -128,19 +128,15 @@ export class AIAssistantConversationsDataClient extends AIAssistantDataClient { public updateConversation = async ({ conversationUpdateProps, authenticatedUser, - isPatch, }: { conversationUpdateProps: ConversationUpdateProps; authenticatedUser?: AuthenticatedUser; - isPatch?: boolean; }): Promise => { - const esClient = await this.options.elasticsearchClientPromise; + const dataWriter = await this.getWriter(); return updateConversation({ - esClient, - logger: this.options.logger, - conversationIndex: this.indexTemplateAndPattern.alias, conversationUpdateProps, - isPatch, + dataWriter, + logger: this.options.logger, user: authenticatedUser ?? this.options.currentUser ?? undefined, }); }; diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/transform.test.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/transform.test.ts new file mode 100644 index 0000000000000..01a0ca7610a02 --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/transform.test.ts @@ -0,0 +1,275 @@ +/* + * 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 { estypes } from '@elastic/elasticsearch'; +import { + transformESToConversation, + transformESSearchToConversations, + transformESToConversations, + transformFieldNamesToSourceScheme, +} from './transforms'; +import type { EsConversationSchema } from './types'; + +const getEsConversationMock = (): EsConversationSchema => { + return { + summary: { + '@timestamp': '2025-08-19T13:26:01.746Z', + semantic_content: 'Very nice demo semantic content 4.', + }, + '@timestamp': '2025-08-19T10:49:52.884Z', + updated_at: '2025-08-19T13:26:01.746Z', + api_config: { + action_type_id: '.gen-ai', + connector_id: 'gpt-4-1', + }, + namespace: 'default', + created_at: '2025-08-19T10:49:52.884Z', + messages: [ + { + '@timestamp': '2025-08-19T10:49:53.799Z', + role: 'user', + content: 'Hello there, how many opened alerts do I have?', + }, + { + metadata: { + content_references: { + oQ5xL: { + id: 'oQ5xL', + type: 'SecurityAlertsPage', + }, + }, + }, + '@timestamp': '2025-08-19T10:49:57.398Z', + role: 'assistant', + is_error: false, + trace_data: { + transaction_id: 'ee432e8be6ad3f9c', + trace_id: 'f44d01b6095d35dce15aa8137df76e29', + }, + content: 'You currently have 61 open alerts in your environment. {reference(oQ5xL)}', + }, + ], + replacements: [], + title: 'Viewing the Number of Open Alerts in Elastic Security', + category: 'assistant', + users: [ + { + name: 'elastic', + id: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0', + }, + ], + id: 'a565baa8-5566-47b2-ab69-807248b2fc46', + }; +}; + +const getEsSearchConversationsMock = (): estypes.SearchResponse => { + return { + took: 0, + timed_out: false, + _shards: { + total: 1, + successful: 1, + skipped: 0, + failed: 0, + }, + hits: { + total: { + value: 1, + relation: 'eq', + }, + max_score: null, + hits: [ + { + _index: '.ds-.kibana-elastic-ai-assistant-conversations-default-2025.08.19-000001', + _id: 'a565baa8-5566-47b2-ab69-807248b2fc46', + _seq_no: 8, + _primary_term: 1, + _score: null, + _source: { + ...getEsConversationMock(), + }, + sort: [1755607491083], + }, + ], + }, + }; +}; + +describe('transforms', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('transformESToConversation', () => { + it('should correctly transform ES conversation', () => { + const esConversation = getEsConversationMock(); + const conversation = transformESToConversation(esConversation); + expect(conversation).toEqual({ + timestamp: '2025-08-19T10:49:52.884Z', + createdAt: '2025-08-19T10:49:52.884Z', + users: [{ id: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0', name: 'elastic' }], + title: 'Viewing the Number of Open Alerts in Elastic Security', + category: 'assistant', + summary: { + timestamp: '2025-08-19T13:26:01.746Z', + semanticContent: 'Very nice demo semantic content 4.', + }, + apiConfig: { actionTypeId: '.gen-ai', connectorId: 'gpt-4-1' }, + messages: [ + { + timestamp: '2025-08-19T10:49:53.799Z', + content: 'Hello there, how many opened alerts do I have?', + role: 'user', + }, + { + timestamp: '2025-08-19T10:49:57.398Z', + content: 'You currently have 61 open alerts in your environment. {reference(oQ5xL)}', + role: 'assistant', + metadata: { contentReferences: { oQ5xL: { id: 'oQ5xL', type: 'SecurityAlertsPage' } } }, + traceData: { + traceId: 'f44d01b6095d35dce15aa8137df76e29', + transactionId: 'ee432e8be6ad3f9c', + }, + }, + ], + updatedAt: '2025-08-19T13:26:01.746Z', + replacements: {}, + namespace: 'default', + id: 'a565baa8-5566-47b2-ab69-807248b2fc46', + }); + }); + }); + + describe('transformESSearchToConversations', () => { + it('should correctly transform conversation hits', () => { + const conversationHits = getEsSearchConversationsMock(); + const conversations = transformESSearchToConversations(conversationHits); + expect(conversations).toEqual([ + { + timestamp: '2025-08-19T10:49:52.884Z', + createdAt: '2025-08-19T10:49:52.884Z', + users: [{ id: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0', name: 'elastic' }], + title: 'Viewing the Number of Open Alerts in Elastic Security', + category: 'assistant', + summary: { + timestamp: '2025-08-19T13:26:01.746Z', + semanticContent: 'Very nice demo semantic content 4.', + }, + apiConfig: { actionTypeId: '.gen-ai', connectorId: 'gpt-4-1' }, + messages: [ + { + timestamp: '2025-08-19T10:49:53.799Z', + content: 'Hello there, how many opened alerts do I have?', + role: 'user', + }, + { + timestamp: '2025-08-19T10:49:57.398Z', + content: 'You currently have 61 open alerts in your environment. {reference(oQ5xL)}', + role: 'assistant', + metadata: { + contentReferences: { oQ5xL: { id: 'oQ5xL', type: 'SecurityAlertsPage' } }, + }, + traceData: { + traceId: 'f44d01b6095d35dce15aa8137df76e29', + transactionId: 'ee432e8be6ad3f9c', + }, + }, + ], + updatedAt: '2025-08-19T13:26:01.746Z', + replacements: {}, + namespace: 'default', + id: 'a565baa8-5566-47b2-ab69-807248b2fc46', + }, + ]); + }); + }); + + describe('transformESToConversations', () => { + it('should correctly transform ES conversations', () => { + const esConversations = [getEsConversationMock()]; + const conversations = transformESToConversations(esConversations); + expect(conversations).toEqual([ + { + timestamp: '2025-08-19T10:49:52.884Z', + createdAt: '2025-08-19T10:49:52.884Z', + users: [{ id: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0', name: 'elastic' }], + title: 'Viewing the Number of Open Alerts in Elastic Security', + category: 'assistant', + summary: { + timestamp: '2025-08-19T13:26:01.746Z', + semanticContent: 'Very nice demo semantic content 4.', + }, + apiConfig: { actionTypeId: '.gen-ai', connectorId: 'gpt-4-1' }, + messages: [ + { + timestamp: '2025-08-19T10:49:53.799Z', + content: 'Hello there, how many opened alerts do I have?', + role: 'user', + }, + { + timestamp: '2025-08-19T10:49:57.398Z', + content: 'You currently have 61 open alerts in your environment. {reference(oQ5xL)}', + role: 'assistant', + metadata: { + contentReferences: { oQ5xL: { id: 'oQ5xL', type: 'SecurityAlertsPage' } }, + }, + traceData: { + traceId: 'f44d01b6095d35dce15aa8137df76e29', + transactionId: 'ee432e8be6ad3f9c', + }, + }, + ], + updatedAt: '2025-08-19T13:26:01.746Z', + replacements: {}, + namespace: 'default', + id: 'a565baa8-5566-47b2-ab69-807248b2fc46', + }, + ]); + }); + }); + + describe('transformFieldNamesToSourceScheme', () => { + it('should correctly transform empty array', () => { + const sourceNames = transformFieldNamesToSourceScheme([]); + expect(sourceNames).toEqual([]); + }); + + it('should correctly transform field names', () => { + const fields = [ + 'timestamp', + 'apiConfig', + 'apiConfig.actionTypeId', + 'apiConfig.connectorId', + 'apiConfig.defaultSystemPromptId', + 'apiConfig.model', + 'apiConfig.provider', + 'summary', + 'summary.timestamp', + 'summary.semanticContent', + 'summary.summarizedMessageIds', + ]; + const sourceNames = transformFieldNamesToSourceScheme(fields); + expect(sourceNames).toEqual([ + '@timestamp', + 'api_config', + 'api_config.action_type_id', + 'api_config.connector_id', + 'api_config.default_system_prompt_id', + 'api_config.model', + 'api_config.provider', + 'summary', + 'summary.@timestamp', + 'summary.semantic_content', + 'summary.summarized_message_ids', + ]); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/transforms.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/transforms.ts index 3d7ff53525b45..df817f33c57f7 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/transforms.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/transforms.ts @@ -11,6 +11,83 @@ import { replaceOriginalValuesWithUuidValues } from '@kbn/elastic-assistant-comm import _ from 'lodash'; import type { EsConversationSchema } from './types'; +export const transformESToConversation = ( + conversationSchema: EsConversationSchema +): ConversationResponse => { + const replacements = conversationSchema.replacements?.reduce((acc: Record, r) => { + acc[r.uuid] = r.value; + return acc; + }, {}) as Replacements; + const conversation: ConversationResponse = { + timestamp: conversationSchema['@timestamp'], + createdAt: conversationSchema.created_at, + users: + conversationSchema.users?.map((user) => ({ + id: user.id, + name: user.name, + })) ?? [], + title: conversationSchema.title, + category: conversationSchema.category, + ...(conversationSchema.summary + ? { + summary: { + timestamp: conversationSchema.summary['@timestamp'], + semanticContent: conversationSchema.summary.semantic_content, + summarizedMessageIds: conversationSchema.summary.summarized_message_ids, + }, + } + : {}), + ...(conversationSchema.api_config + ? { + apiConfig: { + actionTypeId: conversationSchema.api_config.action_type_id, + connectorId: conversationSchema.api_config.connector_id, + defaultSystemPromptId: conversationSchema.api_config.default_system_prompt_id, + model: conversationSchema.api_config.model, + provider: conversationSchema.api_config.provider, + }, + } + : {}), + excludeFromLastConversationStorage: conversationSchema.exclude_from_last_conversation_storage, + messages: + // eslint-disable-next-line @typescript-eslint/no-explicit-any + conversationSchema.messages?.map((message: Record) => ({ + timestamp: message['@timestamp'], + // always return anonymized data from the client + content: replaceOriginalValuesWithUuidValues({ + messageContent: message.content, + replacements, + }), + ...(message.is_error ? { isError: message.is_error } : {}), + ...(message.reader ? { reader: message.reader } : {}), + role: message.role, + ...(message.metadata + ? { + metadata: { + ...(message.metadata.content_references + ? { contentReferences: message.metadata.content_references } + : {}), + }, + } + : {}), + ...(message.trace_data + ? { + traceData: { + traceId: message.trace_data?.trace_id, + transactionId: message.trace_data?.transaction_id, + }, + } + : {}), + })), + updatedAt: conversationSchema.updated_at, + replacements, + namespace: conversationSchema.namespace, + id: conversationSchema.id, + }; + + return conversation; +}; + export const transformESSearchToConversations = ( response: estypes.SearchResponse ): ConversationResponse[] => { @@ -19,145 +96,18 @@ export const transformESSearchToConversations = ( .map((hit) => { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const conversationSchema = hit._source!; - const conversation: ConversationResponse = { - timestamp: conversationSchema['@timestamp'], - createdAt: conversationSchema.created_at, - users: - conversationSchema.users?.map((user) => ({ - id: user.id, - name: user.name, - })) ?? [], - title: conversationSchema.title, - category: conversationSchema.category, - summary: conversationSchema.summary, - ...(conversationSchema.api_config - ? { - apiConfig: { - connectorId: conversationSchema.api_config.connector_id, - actionTypeId: conversationSchema.api_config.action_type_id, - defaultSystemPromptId: conversationSchema.api_config.default_system_prompt_id, - model: conversationSchema.api_config.model, - provider: conversationSchema.api_config.provider, - }, - } - : {}), - excludeFromLastConversationStorage: - conversationSchema.exclude_from_last_conversation_storage, - messages: - // eslint-disable-next-line @typescript-eslint/no-explicit-any - conversationSchema.messages?.map((message: Record) => ({ - timestamp: message['@timestamp'], - // always return anonymized data from the client - content: conversationSchema.replacements - ? replaceOriginalValuesWithUuidValues({ - messageContent: message.content, - replacements: conversationSchema.replacements?.reduce( - (acc: Record, r) => { - acc[r.uuid] = r.value; - return acc; - }, - {} - ), - }) - : message.content, - ...(message.is_error ? { isError: message.is_error } : {}), - ...(message.reader ? { reader: message.reader } : {}), - role: message.role, - ...(message.metadata - ? { - metadata: { - ...(message.metadata.content_references - ? { contentReferences: message.metadata.content_references } - : {}), - }, - } - : {}), - ...(message.trace_data - ? { - traceData: { - traceId: message.trace_data?.trace_id, - transactionId: message.trace_data?.transaction_id, - }, - } - : {}), - })) ?? [], - updatedAt: conversationSchema.updated_at, - replacements: conversationSchema.replacements?.reduce((acc: Record, r) => { - acc[r.uuid] = r.value; - return acc; - }, {}), - namespace: conversationSchema.namespace, + return transformESToConversation({ + ...conversationSchema, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion id: hit._id!, - }; - - return conversation; + }); }); }; export const transformESToConversations = ( response: EsConversationSchema[] ): ConversationResponse[] => { - return response.map((conversationSchema) => { - const replacements = conversationSchema.replacements?.reduce( - (acc: Record, r) => { - acc[r.uuid] = r.value; - return acc; - }, - {} - ) as Replacements; - const conversation: ConversationResponse = { - timestamp: conversationSchema['@timestamp'], - createdAt: conversationSchema.created_at, - users: - conversationSchema.users?.map((user) => ({ - id: user.id, - name: user.name, - })) ?? [], - title: conversationSchema.title, - category: conversationSchema.category, - summary: conversationSchema.summary, - ...(conversationSchema.api_config - ? { - apiConfig: { - actionTypeId: conversationSchema.api_config.action_type_id, - connectorId: conversationSchema.api_config.connector_id, - defaultSystemPromptId: conversationSchema.api_config.default_system_prompt_id, - model: conversationSchema.api_config.model, - provider: conversationSchema.api_config.provider, - }, - } - : {}), - excludeFromLastConversationStorage: conversationSchema.exclude_from_last_conversation_storage, - messages: - // eslint-disable-next-line @typescript-eslint/no-explicit-any - conversationSchema.messages?.map((message: Record) => ({ - timestamp: message['@timestamp'], - // always return anonymized data from the client - content: replaceOriginalValuesWithUuidValues({ - messageContent: message.content, - replacements, - }), - ...(message.is_error ? { isError: message.is_error } : {}), - ...(message.reader ? { reader: message.reader } : {}), - role: message.role, - ...(message.trace_data - ? { - traceData: { - traceId: message.trace_data?.trace_id, - transactionId: message.trace_data?.transaction_id, - }, - } - : {}), - })), - updatedAt: conversationSchema.updated_at, - replacements, - namespace: conversationSchema.namespace, - id: conversationSchema.id, - }; - - return conversation; - }); + return response.map((conversationSchema) => transformESToConversation(conversationSchema)); }; export const transformFieldNamesToSourceScheme = (fields: string[]) => { @@ -177,6 +127,12 @@ export const transformFieldNamesToSourceScheme = (fields: string[]) => { return 'api_config.model'; case 'apiConfig.provider': return 'api_config.provider'; + case 'summary.timestamp': + return 'summary.@timestamp'; + case 'summary.semanticContent': + return 'summary.semantic_content'; + case 'summary.summarizedMessageIds': + return 'summary.summarized_message_ids'; default: return _.snakeCase(f); } diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/types.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/types.ts index 68e43999883b7..bf1cccfb9647c 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/types.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/types.ts @@ -7,7 +7,6 @@ import type { ConversationCategory, - ConversationConfidence, MessageRole, Provider, Reader, @@ -24,10 +23,9 @@ export interface EsConversationSchema { created_at: string; title: string; summary?: { - content?: string; - timestamp?: string; - public?: boolean; - confidence?: ConversationConfidence; + '@timestamp': string; + semantic_content?: string; + summarized_message_ids?: string[]; }; category: ConversationCategory; messages?: Array<{ @@ -40,6 +38,9 @@ export interface EsConversationSchema { transaction_id?: string; trace_id?: string; }; + metadata?: { + content_references?: unknown; + }; }>; api_config?: { connector_id: string; diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/update_conversation.test.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/update_conversation.test.ts index 75f3c10698eda..9289da3a2bb17 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/update_conversation.test.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/update_conversation.test.ts @@ -5,13 +5,14 @@ * 2.0. */ -import { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks'; import { loggerMock } from '@kbn/logging-mocks'; +import type { ConversationUpdateProps } from '@kbn/elastic-assistant-common'; + import type { UpdateConversationSchema } from './update_conversation'; import { transformToUpdateScheme, updateConversation } from './update_conversation'; -import { getConversation } from './get_conversation'; +import type { EsConversationSchema } from './types'; import { authenticatedUser } from '../../__mocks__/user'; -import type { ConversationResponse, ConversationUpdateProps } from '@kbn/elastic-assistant-common'; +import type { DocumentsDataWriter } from '../../lib/data_stream/documents_data_writer'; export const getUpdateConversationOptionsMock = (): ConversationUpdateProps => ({ id: 'test', @@ -26,50 +27,82 @@ export const getUpdateConversationOptionsMock = (): ConversationUpdateProps => ( excludeFromLastConversationStorage: false, messages: [], replacements: {}, + summary: { + semanticContent: 'Updated semantic content.', + }, }); const mockUser1 = authenticatedUser; -export const getConversationResponseMock = (): ConversationResponse => ({ - id: 'test', - title: 'test', - apiConfig: { - actionTypeId: '.gen-ai', - connectorId: '1', - defaultSystemPromptId: 'default-system-prompt', - model: 'test-model', - provider: 'OpenAI', - }, - category: 'assistant', - excludeFromLastConversationStorage: false, - messages: [ - { - content: 'Message 3', - role: 'user', - timestamp: '2024-02-14T22:29:43.862Z', +const getEsConversationMock = (): EsConversationSchema => { + return { + summary: { + '@timestamp': '2025-08-19T13:26:01.746Z', + semantic_content: 'Very nice demo semantic content 4.', }, - { - content: 'Message 4', - role: 'user', - timestamp: '2024-02-14T22:29:43.862Z', - }, - ], - replacements: {}, - createdAt: '2020-04-20T15:25:31.830Z', - namespace: 'default', - updatedAt: '2020-04-20T15:25:31.830Z', - timestamp: '2020-04-20T15:25:31.830Z', - users: [ - { - id: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0', - name: 'elastic', + '@timestamp': '2025-08-19T10:49:52.884Z', + updated_at: '2025-08-19T13:26:01.746Z', + api_config: { + action_type_id: '.gen-ai', + connector_id: 'gpt-4-1', }, - ], -}); + namespace: 'default', + created_at: '2025-08-19T10:49:52.884Z', + messages: [ + { + '@timestamp': '2025-08-19T10:49:53.799Z', + role: 'user', + content: 'Hello there, how many opened alerts do I have?', + }, + { + metadata: { + content_references: { + oQ5xL: { + id: 'oQ5xL', + type: 'SecurityAlertsPage', + }, + }, + }, + '@timestamp': '2025-08-19T10:49:57.398Z', + role: 'assistant', + is_error: false, + trace_data: { + transaction_id: 'ee432e8be6ad3f9c', + trace_id: 'f44d01b6095d35dce15aa8137df76e29', + }, + content: 'You currently have 61 open alerts in your environment. {reference(oQ5xL)}', + }, + ], + replacements: [], + title: 'Viewing the Number of Open Alerts in Elastic Security', + category: 'assistant', + users: [ + { + name: 'elastic', + id: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0', + }, + ], + id: 'a565baa8-5566-47b2-ab69-807248b2fc46', + }; +}; -jest.mock('./get_conversation', () => ({ - getConversation: jest.fn(), -})); +const getNothingToUpdateErrorResponseMock = () => { + return { + errors: [ + { + status_code: 500, + conversations: [{ id: '', name: '' }], + message: + 'null_pointer_exception\n\tRoot causes:\n\t\tnull_pointer_exception: Cannot invoke "org.elasticsearch.xcontent.XContentType.xContent()" because "xContentType" is null', + }, + ], + docs_updated: [], + }; +}; + +const dataWriterMock = { + bulk: jest.fn(), +} as unknown as DocumentsDataWriter; describe('updateConversation', () => { beforeEach(() => { @@ -80,42 +113,131 @@ describe('updateConversation', () => { jest.clearAllMocks(); }); + test('it calls a `dataWriter.bulk` with the correct parameters', async () => { + const conversation: ConversationUpdateProps = getUpdateConversationOptionsMock(); + const updatedESConversation = getEsConversationMock(); + + (dataWriterMock.bulk as jest.Mock).mockResolvedValue({ + errors: [], + docs_updated: [updatedESConversation], + }); + + await updateConversation({ + conversationUpdateProps: conversation, + dataWriter: dataWriterMock, + logger: loggerMock.create(), + user: mockUser1, + }); + + expect(dataWriterMock.bulk).toHaveBeenCalledWith({ + documentsToUpdate: [ + { + api_config: { + action_type_id: '.gen-ai', + connector_id: '1', + default_system_prompt_id: 'default-system-prompt', + model: 'test-model', + provider: 'OpenAI', + }, + exclude_from_last_conversation_storage: false, + id: 'test', + messages: [], + replacements: [], + summary: { + '@timestamp': expect.anything(), + semantic_content: 'Updated semantic content.', + summarized_message_ids: undefined, + }, + title: 'test', + updated_at: expect.anything(), + }, + ], + getUpdateScript: expect.anything(), + authenticatedUser: mockUser1, + }); + }); + test('it returns a conversation with serializer and deserializer', async () => { const conversation: ConversationUpdateProps = getUpdateConversationOptionsMock(); - const existingConversation = getConversationResponseMock(); - (getConversation as unknown as jest.Mock).mockResolvedValueOnce(existingConversation); + const updatedESConversation = getEsConversationMock(); - const esClient = elasticsearchClientMock.createScopedClusterClient().asCurrentUser; - esClient.updateByQuery.mockResolvedValue({ updated: 1 }); + (dataWriterMock.bulk as jest.Mock).mockResolvedValue({ + errors: [], + docs_updated: [updatedESConversation], + }); const updatedList = await updateConversation({ - esClient, - logger: loggerMock.create(), - conversationIndex: 'index-1', conversationUpdateProps: conversation, + dataWriter: dataWriterMock, + logger: loggerMock.create(), user: mockUser1, }); - const expected: ConversationResponse = { - ...getConversationResponseMock(), - id: conversation.id, - title: 'test', - }; - expect(updatedList).toEqual(expected); + + expect(updatedList).toEqual({ + timestamp: '2025-08-19T10:49:52.884Z', + createdAt: '2025-08-19T10:49:52.884Z', + users: [ + { + id: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0', + name: 'elastic', + }, + ], + title: 'Viewing the Number of Open Alerts in Elastic Security', + category: 'assistant', + summary: { + timestamp: '2025-08-19T13:26:01.746Z', + semanticContent: 'Very nice demo semantic content 4.', + }, + apiConfig: { + actionTypeId: '.gen-ai', + connectorId: 'gpt-4-1', + }, + messages: [ + { + timestamp: '2025-08-19T10:49:53.799Z', + content: 'Hello there, how many opened alerts do I have?', + role: 'user', + }, + { + timestamp: '2025-08-19T10:49:57.398Z', + content: 'You currently have 61 open alerts in your environment. {reference(oQ5xL)}', + role: 'assistant', + metadata: { + contentReferences: { + oQ5xL: { + id: 'oQ5xL', + type: 'SecurityAlertsPage', + }, + }, + }, + traceData: { + traceId: 'f44d01b6095d35dce15aa8137df76e29', + transactionId: 'ee432e8be6ad3f9c', + }, + }, + ], + updatedAt: '2025-08-19T13:26:01.746Z', + replacements: {}, + namespace: 'default', + id: 'a565baa8-5566-47b2-ab69-807248b2fc46', + }); }); test('it returns null when there is not a conversation to update', async () => { - (getConversation as unknown as jest.Mock).mockResolvedValueOnce(null); const conversation = getUpdateConversationOptionsMock(); + (dataWriterMock.bulk as jest.Mock).mockResolvedValue(getNothingToUpdateErrorResponseMock()); - const esClient = elasticsearchClientMock.createScopedClusterClient().asCurrentUser; + const mockedLogger = loggerMock.create(); const updatedList = await updateConversation({ - esClient, - logger: loggerMock.create(), - conversationIndex: 'index-1', conversationUpdateProps: conversation, + dataWriter: dataWriterMock, + logger: mockedLogger, user: mockUser1, }); expect(updatedList).toEqual(null); + expect(mockedLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Error updating conversation: null_pointer_exception') + ); }); }); @@ -126,8 +248,6 @@ describe('transformToUpdateScheme', () => { test('it returns a transformed conversation with converted string datetime to ISO from the client', async () => { const conversation: ConversationUpdateProps = getUpdateConversationOptionsMock(); - const existingConversation = getConversationResponseMock(); - (getConversation as unknown as jest.Mock).mockResolvedValueOnce(existingConversation); const updateAt = new Date().toISOString(); const transformed = transformToUpdateScheme(updateAt, { @@ -202,13 +322,15 @@ describe('transformToUpdateScheme', () => { role: 'user', }, ], + summary: { + '@timestamp': updateAt, + semantic_content: 'Updated semantic content.', + }, }; expect(transformed).toEqual(expected); }); test('it does not pass api_config if apiConfig is not updated', async () => { const conversation: ConversationUpdateProps = getUpdateConversationOptionsMock(); - const existingConversation = getConversationResponseMock(); - (getConversation as unknown as jest.Mock).mockResolvedValueOnce(existingConversation); const updateAt = new Date().toISOString(); const transformed = transformToUpdateScheme(updateAt, { diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/update_conversation.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/update_conversation.ts index 726d919a9fbaa..636a284c5fc36 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/update_conversation.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/update_conversation.ts @@ -5,20 +5,20 @@ * 2.0. */ -import type { AuthenticatedUser, ElasticsearchClient, Logger } from '@kbn/core/server'; +import type { AuthenticatedUser, Logger } from '@kbn/core/server'; import type { ConversationResponse, Reader, ConversationUpdateProps, Provider, MessageRole, - ConversationSummary, UUID, ContentReferences, } from '@kbn/elastic-assistant-common'; -import { getConversation } from './get_conversation'; import { getUpdateScript } from './helpers'; -import type { EsReplacementSchema } from './types'; +import type { EsConversationSchema, EsReplacementSchema } from './types'; +import type { DocumentsDataWriter } from '../../lib/data_stream/documents_data_writer'; +import { transformESToConversations } from './transforms'; export interface UpdateConversationSchema { id: UUID; @@ -45,91 +45,50 @@ export interface UpdateConversationSchema { provider?: Provider; model?: string; }; - summary?: ConversationSummary; + summary?: { + '@timestamp': string; + semantic_content?: string; + summarized_message_ids?: UUID[]; + }; exclude_from_last_conversation_storage?: boolean; replacements?: EsReplacementSchema[]; updated_at?: string; } export interface UpdateConversationParams { - esClient: ElasticsearchClient; + conversationUpdateProps: ConversationUpdateProps; + dataWriter: DocumentsDataWriter; logger: Logger; user?: AuthenticatedUser; - conversationIndex: string; - conversationUpdateProps: ConversationUpdateProps; - isPatch?: boolean; } export const updateConversation = async ({ - esClient, - logger, - conversationIndex, conversationUpdateProps, - isPatch, + dataWriter, + logger, user, }: UpdateConversationParams): Promise => { const updatedAt = new Date().toISOString(); const params = transformToUpdateScheme(updatedAt, conversationUpdateProps); - const maxRetries = 3; - let attempt = 0; - let response; - while (attempt < maxRetries) { - try { - response = await esClient.updateByQuery({ - conflicts: 'proceed', - index: conversationIndex, - query: { - ids: { - values: [params.id], - }, - }, - refresh: true, - script: getUpdateScript({ conversation: params, isPatch }).script, - }); - if ( - (response?.updated && response?.updated > 0) || - (response?.failures && response?.failures.length > 0) - ) { - break; - } - if ( - response?.version_conflicts && - response?.version_conflicts > 0 && - response?.updated === 0 - ) { - attempt++; - if (attempt < maxRetries) { - logger.warn( - `Version conflict detected, retrying updateConversation (attempt ${ - attempt + 1 - }) for conversation ID: ${params.id}` - ); - await new Promise((resolve) => setTimeout(resolve, 100 * attempt)); - } - } else { - break; - } - } catch (err) { - logger.warn(`Error updating conversation: ${err} by ID: ${params.id}`); - throw err; - } - } + const { errors, docs_updated: docsUpdated } = await dataWriter.bulk({ + documentsToUpdate: [params], + getUpdateScript: (document: UpdateConversationSchema) => + getUpdateScript({ conversation: document }), + authenticatedUser: user, + }); - if (response && response?.failures && response?.failures.length > 0) { + if (errors && errors.length > 0) { logger.warn( - `Error updating conversation: ${response?.failures.map((f) => f.id)} by ID: ${params.id}` + `Error updating conversation: ${errors.map((err) => err.message)} by ID: ${params.id}` ); return null; } - const updatedConversation = await getConversation({ - esClient, - conversationIndex, - id: params.id, - logger, - user, - }); + const updatedConversation = transformESToConversations( + docsUpdated as EsConversationSchema[] + )?.[0]; + return updatedConversation; }; @@ -142,6 +101,7 @@ export const transformToUpdateScheme = ( messages, replacements, id, + summary, }: ConversationUpdateProps ): UpdateConversationSchema => { return { @@ -200,5 +160,14 @@ export const transformToUpdateScheme = ( })), } : {}), + ...(summary + ? { + summary: { + '@timestamp': updatedAt, + semantic_content: summary.semanticContent, + summarized_message_ids: summary.summarizedMessageIds, + }, + } + : {}), }; }; diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/ai_assistant_data_clients/find.test.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/ai_assistant_data_clients/find.test.ts index bc6b9838e3d6f..c5b4626ce5063 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/ai_assistant_data_clients/find.test.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/ai_assistant_data_clients/find.test.ts @@ -43,7 +43,8 @@ export const getSearchConversationMock = (): estypes.SearchResponse { namespace: 'default', replacements: undefined, summary: { - content: 'test', + '@timestamp': '2020-04-20T15:25:31.830Z', + semantic_content: 'test', }, title: 'title-1', updated_at: '2020-04-20T15:25:31.830Z', diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/routes/chat/chat_complete_route.test.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/routes/chat/chat_complete_route.test.ts index 979deea49eead..fb5b8eba120cf 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/routes/chat/chat_complete_route.test.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/routes/chat/chat_complete_route.test.ts @@ -14,9 +14,11 @@ import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; import { coreMock } from '@kbn/core/server/mocks'; import { INVOKE_ASSISTANT_ERROR_EVENT } from '../../lib/telemetry/event_based_telemetry'; import { PassThrough } from 'stream'; -import { getConversationResponseMock } from '../../ai_assistant_data_clients/conversations/update_conversation.test'; import { actionsClientMock } from '@kbn/actions-plugin/server/actions_client/actions_client.mock'; -import { getFindAnonymizationFieldsResultWithSingleHit } from '../../__mocks__/response'; +import { + getConversationResponseMock, + getFindAnonymizationFieldsResultWithSingleHit, +} from '../../__mocks__/response'; import { defaultAssistantFeatures } from '@kbn/elastic-assistant-common'; import { chatCompleteRoute } from './chat_complete_route'; import { licensingMock } from '@kbn/licensing-plugin/server/mocks'; diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/routes/post_actions_connector_execute.test.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/routes/post_actions_connector_execute.test.ts index 4ebed573ba4b1..1959b81c88861 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/routes/post_actions_connector_execute.test.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/routes/post_actions_connector_execute.test.ts @@ -15,9 +15,11 @@ import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; import { coreMock } from '@kbn/core/server/mocks'; import { INVOKE_ASSISTANT_ERROR_EVENT } from '../lib/telemetry/event_based_telemetry'; import { PassThrough } from 'stream'; -import { getConversationResponseMock } from '../ai_assistant_data_clients/conversations/update_conversation.test'; import { actionsClientMock } from '@kbn/actions-plugin/server/actions_client/actions_client.mock'; -import { getFindAnonymizationFieldsResultWithSingleHit } from '../__mocks__/response'; +import { + getConversationResponseMock, + getFindAnonymizationFieldsResultWithSingleHit, +} from '../__mocks__/response'; import type { ExecuteConnectorRequestBody } from '@kbn/elastic-assistant-common'; import { defaultAssistantFeatures } from '@kbn/elastic-assistant-common'; import { licensingMock } from '@kbn/licensing-plugin/server/mocks'; diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/routes/user_conversations/bulk_actions_route.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/routes/user_conversations/bulk_actions_route.ts index d33a959eccff5..f9b2508013fab 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/routes/user_conversations/bulk_actions_route.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/routes/user_conversations/bulk_actions_route.ts @@ -209,7 +209,7 @@ export const bulkActionConversationsRoute = ( documentsToUpdate: body.update?.map((c) => transformToUpdateScheme(changedAt, c)), authenticatedUser, getUpdateScript: (document: UpdateConversationSchema) => - getUpdateScript({ conversation: document, isPatch: true }), + getUpdateScript({ conversation: document }), }); const created = docsCreated.length > 0 diff --git a/x-pack/solutions/security/test/security_solution_api_integration/package.json b/x-pack/solutions/security/test/security_solution_api_integration/package.json index d7ba87487267b..099f6b944f2d4 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/package.json +++ b/x-pack/solutions/security/test/security_solution_api_integration/package.json @@ -92,6 +92,13 @@ "genai_ad_schedules:basic:server:ess": "npm run initialize-server:genai:basic_essentials attack_discovery/schedules ess", "genai_ad_schedules:basic:runner:ess": "npm run run-tests:genai:basic_essentials attack_discovery/schedules ess essEnv", + "genai_conversations:server:serverless": "npm run initialize-server:genai:trial_complete conversations serverless", + "genai_conversations:runner:serverless": "npm run run-tests:genai:trial_complete conversations serverless serverlessEnv", + "genai_conversations:qa:serverless": "npm run run-tests:genai:trial_complete conversations serverless qaPeriodicEnv", + "genai_conversations:qa:serverless:release": "npm run run-tests:genai:trial_complete conversations serverless qaEnv", + "genai_conversations:server:ess": "npm run initialize-server:genai:trial_complete conversations ess", + "genai_conversations:runner:ess": "npm run run-tests:genai:trial_complete conversations ess essEnv", + "genai_kb_entries:server:serverless": "npm run initialize-server:genai:trial_complete knowledge_base/entries serverless", "genai_kb_entries:runner:serverless": "npm run run-tests:genai:trial_complete knowledge_base/entries serverless serverlessEnv", "genai_kb_entries:qa:serverless": "npm run run-tests:genai:trial_complete knowledge_base/entries serverless qaPeriodicEnv", diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/mocks/conversations.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/mocks/conversations.ts new file mode 100644 index 0000000000000..d7de2191fe0df --- /dev/null +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/mocks/conversations.ts @@ -0,0 +1,39 @@ +/* + * 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 { ConversationCreateProps } from '@kbn/elastic-assistant-common'; + +export const getSimpleConversation = ( + overrides?: Partial +): ConversationCreateProps => { + return { + title: 'Getting Started with Elastic Security', + category: 'assistant', + messages: [ + { + timestamp: '2025-08-26T14:33:23.125Z', + role: 'user', + content: 'hello there', + }, + { + timestamp: '2025-08-26T14:33:25.200Z', + role: 'assistant', + isError: false, + traceData: { + transactionId: '321321321', + traceId: '123123123', + }, + content: 'Hello! How can I assist you with Elastic Security today?', + }, + ], + apiConfig: { + actionTypeId: '.gen-ai', + connectorId: 'gpt-4-1', + }, + ...overrides, + }; +}; diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/mocks/index.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/mocks/index.ts new file mode 100644 index 0000000000000..542be953c668d --- /dev/null +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/mocks/index.ts @@ -0,0 +1,8 @@ +/* + * 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 * from './conversations'; diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/trial_license_complete_tier/bulk_actions/bulk_actions.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/trial_license_complete_tier/bulk_actions/bulk_actions.ts new file mode 100644 index 0000000000000..d220ed004f0f0 --- /dev/null +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/trial_license_complete_tier/bulk_actions/bulk_actions.ts @@ -0,0 +1,100 @@ +/* + * 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 type { FtrProviderContext } from '../../../../../ftr_provider_context'; +import { deleteAllConversations, getConversationBadRequestError } from '../../utils/helpers'; +import type { PartialPerformBulkActionRequestBody } from '../../utils/apis'; +import { getConversationsApis } from '../../utils/apis'; +import { getSimpleConversation } from '../../mocks'; + +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertest'); + const log = getService('log'); + + describe('@ess @serverless @serverlessQA Bulk Actions - Common', () => { + beforeEach(async () => { + await deleteAllConversations({ supertest, log }); + }); + + describe('Bulk Update', () => { + describe('Happy path', () => { + it('should update a conversation', async () => { + const apis = getConversationsApis({ supertest }); + + const conversationToCreate = getSimpleConversation(); + const { id, updatedAt, ...restCreatedConversation } = await apis.create({ + conversation: conversationToCreate, + }); + + const conversationToUpdate = { id, title: 'Updated Conversation' }; + const bulkActions: PartialPerformBulkActionRequestBody = { + update: [conversationToUpdate], + }; + const result = await apis.bulk({ bulkActions }); + + expect(result).toEqual({ + success: true, + conversations_count: 1, + attributes: { + results: { + updated: [ + expect.objectContaining({ + ...restCreatedConversation, + ...conversationToUpdate, + }), + ], + created: [], + deleted: [], + skipped: [], + }, + summary: { + failed: 0, + succeeded: 1, + skipped: 0, + total: 1, + }, + }, + }); + }); + }); + + describe('Errors handling', () => { + it('should return a `Internal Server Error` error if conversation does not exist', async () => { + const apis = getConversationsApis({ supertest }); + + const conversationToCreate = getSimpleConversation(); + const createdConversation = await apis.create({ conversation: conversationToCreate }); + + const conversationToUpdate = { + id: createdConversation.id, + title: 'Updated Conversation', + }; + const bulkActions: PartialPerformBulkActionRequestBody = { + update: [{ ...conversationToUpdate, id: 'fake-conversation-1' }], + }; + const result = await apis.bulk({ bulkActions, expectedHttpCode: 500 }); + + expect(result.error).toEqual('Internal Server Error'); + expect(result.message).toEqual('Bulk edit failed'); + }); + + it('should return a `Bad Request` error if `id` attribute is `undefined`', async () => { + const apis = getConversationsApis({ supertest }); + + const conversationToUpdate = { title: 'Updated Conversation' }; + const bulkActions: PartialPerformBulkActionRequestBody = { + update: [conversationToUpdate], + }; + const result = await apis.bulk({ bulkActions, expectedHttpCode: 400 }); + + expect(result).toEqual(getConversationBadRequestError('update.0.id')); + }); + }); + }); + }); +}; diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/trial_license_complete_tier/bulk_actions/bulk_actions_ess.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/trial_license_complete_tier/bulk_actions/bulk_actions_ess.ts new file mode 100644 index 0000000000000..ae871ab412e72 --- /dev/null +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/trial_license_complete_tier/bulk_actions/bulk_actions_ess.ts @@ -0,0 +1,135 @@ +/* + * 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_AI_ASSISTANT_CONVERSATIONS_URL_BULK_ACTION } from '@kbn/elastic-assistant-common'; +import type { FtrProviderContext } from '../../../../../ftr_provider_context'; +import { + deleteAllConversations, + getMissingAssistantKibanaPrivilegesError, +} from '../../utils/helpers'; +import type { PartialPerformBulkActionRequestBody } from '../../utils/apis'; +import { getConversationsApis } from '../../utils/apis'; +import { getSimpleConversation } from '../../mocks'; +import { noKibanaPrivileges, secOnlySpace2 } from '../../../utils/auth/users'; + +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const log = getService('log'); + + describe('@ess Bulk Actions - ESS', () => { + let createdConversation: any; + const kibanaSpace1 = 'space1'; + + beforeEach(async () => { + await deleteAllConversations({ supertest, log }); + await deleteAllConversations({ supertest, log, kibanaSpace: kibanaSpace1 }); + + // Create a new conversation with the "super user" credentials + const apisSuperuser = getConversationsApis({ supertest }); + createdConversation = await apisSuperuser.create({ + conversation: getSimpleConversation(), + kibanaSpace: kibanaSpace1, + }); + }); + + describe('Bulk Update', () => { + describe('Happy path', () => { + it('should update a conversation by `id` in a non-default space', async () => { + const apisSuperuser = getConversationsApis({ supertest }); + + const { id, updatedAt, ...restCreatedConversation } = createdConversation; + + const conversationToUpdate = { id, title: 'Updated Conversation' }; + const bulkActions: PartialPerformBulkActionRequestBody = { + update: [conversationToUpdate], + }; + const result = await apisSuperuser.bulk({ bulkActions, kibanaSpace: kibanaSpace1 }); + + expect(result).toEqual({ + success: true, + conversations_count: 1, + attributes: { + results: { + updated: [ + expect.objectContaining({ + ...restCreatedConversation, + ...conversationToUpdate, + }), + ], + created: [], + deleted: [], + skipped: [], + }, + summary: { + failed: 0, + succeeded: 1, + skipped: 0, + total: 1, + }, + }, + }); + }); + }); + + describe('RBAC', () => { + it('should not be able to update a conversation without `assistant` kibana privileges', async () => { + const apisNoPrivileges = getConversationsApis({ + supertest: supertestWithoutAuth, + user: noKibanaPrivileges, + }); + + const conversationToUpdate = { + id: createdConversation.id, + title: 'Updated Conversation', + }; + const bulkActions: PartialPerformBulkActionRequestBody = { + update: [conversationToUpdate], + }; + const result = await apisNoPrivileges.bulk({ + bulkActions, + kibanaSpace: kibanaSpace1, + expectedHttpCode: 403, + }); + + expect(result).toEqual( + getMissingAssistantKibanaPrivilegesError({ + routeDetails: `POST ${ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL_BULK_ACTION}`, + }) + ); + }); + + it('should not be able to update a conversation in a space without kibana privileges for that space', async () => { + const apisOnlySpace2 = getConversationsApis({ + supertest: supertestWithoutAuth, + user: secOnlySpace2, + }); + + const conversationToUpdate = { + id: createdConversation.id, + title: 'Updated Conversation', + }; + const bulkActions: PartialPerformBulkActionRequestBody = { + update: [conversationToUpdate], + }; + const result = await apisOnlySpace2.bulk({ + bulkActions, + kibanaSpace: kibanaSpace1, + expectedHttpCode: 403, + }); + + expect(result).toEqual( + getMissingAssistantKibanaPrivilegesError({ + routeDetails: `POST ${ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL_BULK_ACTION}`, + }) + ); + }); + }); + }); + }); +}; diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/trial_license_complete_tier/bulk_actions/bulk_actions_serverless.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/trial_license_complete_tier/bulk_actions/bulk_actions_serverless.ts new file mode 100644 index 0000000000000..62173872954b4 --- /dev/null +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/trial_license_complete_tier/bulk_actions/bulk_actions_serverless.ts @@ -0,0 +1,151 @@ +/* + * 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 { ROLES } from '@kbn/security-solution-plugin/common/test'; +import { ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL_BULK_ACTION } from '@kbn/elastic-assistant-common'; +import type { FtrProviderContext } from '../../../../../ftr_provider_context'; +import { + deleteAllConversations, + getMissingAssistantKibanaPrivilegesError, +} from '../../utils/helpers'; +import type { PartialPerformBulkActionRequestBody } from '../../utils/apis'; +import { getConversationsApis } from '../../utils/apis'; +import { getSimpleConversation } from '../../mocks'; +import { noKibanaPrivileges, securitySolutionOnlyAllSpace2 } from '../../../utils/auth/roles'; + +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertest'); + const log = getService('log'); + const utils = getService('securitySolutionUtils'); + + describe('@serverless Bulk Actions - Serverless', () => { + let createdConversation: any; + const kibanaSpace1 = 'space1'; + + beforeEach(async () => { + await deleteAllConversations({ supertest, log }); + await deleteAllConversations({ supertest, log, kibanaSpace: kibanaSpace1 }); + + // Create a new enabled conversation with the "super user" credentials + const apisSuperuser = getConversationsApis({ supertest }); + createdConversation = await apisSuperuser.create({ + conversation: getSimpleConversation(), + kibanaSpace: kibanaSpace1, + }); + }); + + describe('Bulk Update', () => { + describe('Happy path for predefined users', () => { + const roles = [ + 'viewer', + 'editor', + ROLES.t1_analyst, + ROLES.t2_analyst, + ROLES.t3_analyst, + ROLES.rule_author, + ROLES.soc_manager, + ROLES.detections_admin, + ROLES.platform_engineer, + ]; + + roles.forEach((role) => { + it(`should update a conversation in a non-default space with the role "${role}"`, async () => { + const testAgent = await utils.createSuperTest(role); + + const apis = getConversationsApis({ supertest: testAgent }); + + const { id, updatedAt, ...restCreatedConversation } = createdConversation; + const conversationToUpdate = { id, title: 'Updated Conversation' }; + const bulkActions: PartialPerformBulkActionRequestBody = { + update: [conversationToUpdate], + }; + const result = await apis.bulk({ bulkActions, kibanaSpace: kibanaSpace1 }); + + expect(result).toEqual({ + success: true, + conversations_count: 1, + attributes: { + results: { + updated: [ + expect.objectContaining({ + ...restCreatedConversation, + ...conversationToUpdate, + }), + ], + created: [], + deleted: [], + skipped: [], + }, + summary: { + failed: 0, + succeeded: 1, + skipped: 0, + total: 1, + }, + }, + }); + }); + }); + }); + + describe('RBAC', () => { + it('should not be able to update a conversation without `assistant` kibana privileges', async () => { + const superTest = await utils.createSuperTestWithCustomRole(noKibanaPrivileges); + + const apisNoPrivileges = getConversationsApis({ supertest: superTest }); + + const conversationToUpdate = { + id: createdConversation.id, + title: 'Updated Conversation', + }; + const bulkActions: PartialPerformBulkActionRequestBody = { + update: [conversationToUpdate], + }; + const result = await apisNoPrivileges.bulk({ + bulkActions, + kibanaSpace: kibanaSpace1, + expectedHttpCode: 403, + }); + + expect(result).toEqual( + getMissingAssistantKibanaPrivilegesError({ + routeDetails: `POST ${ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL_BULK_ACTION}`, + }) + ); + }); + + it('should not be able to update a conversation in a space without kibana privileges for that space', async () => { + const superTest = await utils.createSuperTestWithCustomRole( + securitySolutionOnlyAllSpace2 + ); + + const apisOnlySpace2 = getConversationsApis({ supertest: superTest }); + + const conversationToUpdate = { + id: createdConversation.id, + title: 'Updated Conversation', + }; + const bulkActions: PartialPerformBulkActionRequestBody = { + update: [conversationToUpdate], + }; + const result = await apisOnlySpace2.bulk({ + bulkActions, + kibanaSpace: kibanaSpace1, + expectedHttpCode: 403, + }); + + expect(result).toEqual( + getMissingAssistantKibanaPrivilegesError({ + routeDetails: `POST ${ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL_BULK_ACTION}`, + }) + ); + }); + }); + }); + }); +}; diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/trial_license_complete_tier/bulk_actions/index.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/trial_license_complete_tier/bulk_actions/index.ts new file mode 100644 index 0000000000000..8adf446de89c3 --- /dev/null +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/trial_license_complete_tier/bulk_actions/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 type { FtrProviderContext } from '../../../../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + loadTestFile(require.resolve('./bulk_actions')); + loadTestFile(require.resolve('./bulk_actions_ess')); + loadTestFile(require.resolve('./bulk_actions_serverless')); +} diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/trial_license_complete_tier/configs/ess.config.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/trial_license_complete_tier/configs/ess.config.ts new file mode 100644 index 0000000000000..7bfd5bbbbc6f5 --- /dev/null +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/trial_license_complete_tier/configs/ess.config.ts @@ -0,0 +1,58 @@ +/* + * 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 { FtrConfigProviderContext } from '@kbn/test'; +import { PRECONFIGURED_BEDROCK_ACTION } from '../../../../../config/shared'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const functionalConfig = await readConfigFile( + require.resolve('../../../../../config/ess/config.base.trial') + ); + + return { + ...functionalConfig.getAll(), + kbnTestServer: { + ...functionalConfig.get('kbnTestServer'), + serverArgs: [ + ...functionalConfig + .get('kbnTestServer.serverArgs') + // ssl: false as ML vocab API is broken with SSL enabled + .filter( + (a: string) => + !( + a.startsWith('--elasticsearch.hosts=') || + a.startsWith('--elasticsearch.ssl.certificateAuthorities=') + ) + ), + '--elasticsearch.hosts=http://localhost:9220', + '--coreApp.allowDynamicConfigOverrides=true', + `--xpack.actions.preconfigured=${JSON.stringify(PRECONFIGURED_BEDROCK_ACTION)}`, + ], + }, + testFiles: [require.resolve('..')], + junit: { + reportName: 'GenAI - Conversations Tests - ESS Env - Trial License', + }, + // ssl: false as ML vocab API is broken with SSL enabled + servers: { + ...functionalConfig.get('servers'), + elasticsearch: { + ...functionalConfig.get('servers.elasticsearch'), + protocol: 'http', + }, + }, + esTestCluster: { + ...functionalConfig.get('esTestCluster'), + ssl: false, + esJavaOpts: '-Xms4g -Xmx4g', + }, + mochaOpts: { + ...functionalConfig.get('mochaOpts'), + timeout: 360000 * 2, + }, + }; +} diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/trial_license_complete_tier/configs/serverless.config.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/trial_license_complete_tier/configs/serverless.config.ts new file mode 100644 index 0000000000000..2539e91a7602a --- /dev/null +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/trial_license_complete_tier/configs/serverless.config.ts @@ -0,0 +1,25 @@ +/* + * 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 { PRECONFIGURED_BEDROCK_ACTION } from '../../../../../config/shared'; +import { createTestConfig } from '../../../../../config/serverless/config.base'; + +export default createTestConfig({ + kbnTestServerArgs: [ + `--xpack.securitySolutionServerless.productTypes=${JSON.stringify([ + { product_line: 'security', product_tier: 'complete' }, + { product_line: 'endpoint', product_tier: 'complete' }, + { product_line: 'cloud', product_tier: 'complete' }, + ])}`, + `--xpack.actions.preconfigured=${JSON.stringify(PRECONFIGURED_BEDROCK_ACTION)}`, + '--coreApp.allowDynamicConfigOverrides=true', + ], + testFiles: [require.resolve('..')], + junit: { + reportName: 'GenAI - Conversations Tests - Serverless Env - Complete Tier', + }, +}); diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/trial_license_complete_tier/index.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/trial_license_complete_tier/index.ts new file mode 100644 index 0000000000000..5cf7e20bf41af --- /dev/null +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/trial_license_complete_tier/index.ts @@ -0,0 +1,24 @@ +/* + * 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 { FtrProviderContext } from '../../../../ftr_provider_context'; +import { createSpacesAndUsers, deleteSpacesAndUsers } from '../../utils/auth'; + +export default function ({ loadTestFile, getService }: FtrProviderContext) { + describe('GenAI - Conversations APIs', function () { + before(async () => { + await createSpacesAndUsers(getService); + }); + + after(async () => { + await deleteSpacesAndUsers(getService); + }); + + loadTestFile(require.resolve('./bulk_actions')); + loadTestFile(require.resolve('./update')); + }); +} diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/trial_license_complete_tier/update/index.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/trial_license_complete_tier/update/index.ts new file mode 100644 index 0000000000000..274e2113f9e28 --- /dev/null +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/trial_license_complete_tier/update/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 type { FtrProviderContext } from '../../../../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + loadTestFile(require.resolve('./update')); + loadTestFile(require.resolve('./update_ess')); + loadTestFile(require.resolve('./update_serverless')); +} diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/trial_license_complete_tier/update/update.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/trial_license_complete_tier/update/update.ts new file mode 100644 index 0000000000000..bfce6401168a5 --- /dev/null +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/trial_license_complete_tier/update/update.ts @@ -0,0 +1,85 @@ +/* + * 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 type { FtrProviderContext } from '../../../../../ftr_provider_context'; +import { + deleteAllConversations, + getConversationBadRequestError, + getConversationNotFoundError, +} from '../../utils/helpers'; +import { getConversationsApis } from '../../utils/apis'; +import { getSimpleConversation } from '../../mocks'; + +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertest'); + const log = getService('log'); + + describe('@ess @serverless @serverlessQA Update - Common', () => { + beforeEach(async () => { + await deleteAllConversations({ supertest, log }); + }); + + describe('Happy path', () => { + it('should update a conversation', async () => { + const apis = getConversationsApis({ supertest }); + + const conversationToCreate = getSimpleConversation(); + const createdConversation = await apis.create({ conversation: conversationToCreate }); + + const conversationToUpdate = { id: createdConversation.id, title: 'Updated Conversation' }; + const updatedConversation = await apis.update({ + id: createdConversation.id, + conversation: conversationToUpdate, + }); + + expect(updatedConversation).toEqual( + expect.objectContaining({ + ...createdConversation, + ...conversationToUpdate, + // Use latest `updateAt` value + updatedAt: updatedConversation.updatedAt, + }) + ); + }); + }); + + describe('Errors handling', () => { + it('should return `Not Found` error if conversation does not exist', async () => { + const apis = getConversationsApis({ supertest }); + + const conversationToCreate = getSimpleConversation(); + const createdConversation = await apis.create({ conversation: conversationToCreate }); + + const conversationToUpdate = { id: createdConversation.id, title: 'Updated Conversation' }; + const result = await apis.update({ + id: 'fake-conversation-1', + conversation: conversationToUpdate, + expectedHttpCode: 404, + }); + + expect(result).toEqual(getConversationNotFoundError('fake-conversation-1')); + }); + + it('should return a `Bad Request` error if `id` attribute is `undefined`', async () => { + const apis = getConversationsApis({ supertest }); + + const conversationToCreate = getSimpleConversation(); + const createdConversation = await apis.create({ conversation: conversationToCreate }); + + const conversationToUpdate = { title: 'Updated Conversation' }; + const result = await apis.update({ + id: createdConversation.id, + conversation: conversationToUpdate, + expectedHttpCode: 400, + }); + + expect(result).toEqual(getConversationBadRequestError('id')); + }); + }); + }); +}; diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/trial_license_complete_tier/update/update_ess.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/trial_license_complete_tier/update/update_ess.ts new file mode 100644 index 0000000000000..5a2fbf6878551 --- /dev/null +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/trial_license_complete_tier/update/update_ess.ts @@ -0,0 +1,106 @@ +/* + * 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_AI_ASSISTANT_CONVERSATIONS_URL } from '@kbn/elastic-assistant-common'; +import type { FtrProviderContext } from '../../../../../ftr_provider_context'; +import { + deleteAllConversations, + getMissingAssistantKibanaPrivilegesError, +} from '../../utils/helpers'; +import { getConversationsApis } from '../../utils/apis'; +import { getSimpleConversation } from '../../mocks'; +import { noKibanaPrivileges, secOnlySpace2 } from '../../../utils/auth/users'; + +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const log = getService('log'); + + describe('@ess Update - ESS', () => { + let createdConversation: any; + const kibanaSpace1 = 'space1'; + + beforeEach(async () => { + await deleteAllConversations({ supertest, log }); + await deleteAllConversations({ supertest, log, kibanaSpace: kibanaSpace1 }); + + // Create a new conversation with the "super user" credentials + const apisSuperuser = getConversationsApis({ supertest }); + createdConversation = await apisSuperuser.create({ + conversation: getSimpleConversation(), + kibanaSpace: kibanaSpace1, + }); + }); + + describe('Happy path', () => { + it('should update a conversation by `id` in a non-default space', async () => { + const apisSuperuser = getConversationsApis({ supertest }); + + const conversationToUpdate = { id: createdConversation.id, title: 'Updated Conversation' }; + const updatedConversation = await apisSuperuser.update({ + id: createdConversation.id, + conversation: conversationToUpdate, + kibanaSpace: kibanaSpace1, + }); + + expect(updatedConversation).toEqual( + expect.objectContaining({ + ...createdConversation, + ...conversationToUpdate, + // Use latest `updateAt` value + updatedAt: updatedConversation.updatedAt, + }) + ); + }); + }); + + describe('RBAC', () => { + it('should not be able to update a conversation without `assistant` kibana privileges', async () => { + const apisNoPrivileges = getConversationsApis({ + supertest: supertestWithoutAuth, + user: noKibanaPrivileges, + }); + + const conversationToUpdate = { id: createdConversation.id, title: 'Updated Conversation' }; + const result = await apisNoPrivileges.update({ + id: createdConversation.id, + conversation: conversationToUpdate, + kibanaSpace: kibanaSpace1, + expectedHttpCode: 403, + }); + + expect(result).toEqual( + getMissingAssistantKibanaPrivilegesError({ + routeDetails: `PUT ${ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL}/${createdConversation.id}`, + }) + ); + }); + + it('should not be able to update a conversation in a space without kibana privileges for that space', async () => { + const apisOnlySpace2 = getConversationsApis({ + supertest: supertestWithoutAuth, + user: secOnlySpace2, + }); + + const conversationToUpdate = { id: createdConversation.id, title: 'Updated Conversation' }; + const result = await apisOnlySpace2.update({ + id: createdConversation.id, + conversation: conversationToUpdate, + kibanaSpace: kibanaSpace1, + expectedHttpCode: 403, + }); + + expect(result).toEqual( + getMissingAssistantKibanaPrivilegesError({ + routeDetails: `PUT ${ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL}/${createdConversation.id}`, + }) + ); + }); + }); + }); +}; diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/trial_license_complete_tier/update/update_serverless.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/trial_license_complete_tier/update/update_serverless.ts new file mode 100644 index 0000000000000..0d83816867636 --- /dev/null +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/trial_license_complete_tier/update/update_serverless.ts @@ -0,0 +1,124 @@ +/* + * 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 { ROLES } from '@kbn/security-solution-plugin/common/test'; +import { ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL } from '@kbn/elastic-assistant-common'; +import type { FtrProviderContext } from '../../../../../ftr_provider_context'; +import { + deleteAllConversations, + getMissingAssistantKibanaPrivilegesError, +} from '../../utils/helpers'; +import { getConversationsApis } from '../../utils/apis'; +import { getSimpleConversation } from '../../mocks'; +import { noKibanaPrivileges, securitySolutionOnlyAllSpace2 } from '../../../utils/auth/roles'; + +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertest'); + const log = getService('log'); + const utils = getService('securitySolutionUtils'); + + describe('@serverless Update - Serverless', () => { + let createdConversation: any; + const kibanaSpace1 = 'space1'; + + beforeEach(async () => { + await deleteAllConversations({ supertest, log }); + await deleteAllConversations({ supertest, log, kibanaSpace: kibanaSpace1 }); + + // Create a new enabled conversation with the "super user" credentials + const apisSuperuser = getConversationsApis({ supertest }); + createdConversation = await apisSuperuser.create({ + conversation: getSimpleConversation(), + kibanaSpace: kibanaSpace1, + }); + }); + + describe('Happy path for predefined users', () => { + const roles = [ + 'viewer', + 'editor', + ROLES.t1_analyst, + ROLES.t2_analyst, + ROLES.t3_analyst, + ROLES.rule_author, + ROLES.soc_manager, + ROLES.detections_admin, + ROLES.platform_engineer, + ]; + + roles.forEach((role) => { + it(`should update a conversation in a non-default space with the role "${role}"`, async () => { + const testAgent = await utils.createSuperTest(role); + + const apis = getConversationsApis({ supertest: testAgent }); + + const conversationToUpdate = { + id: createdConversation.id, + title: 'Updated Conversation', + }; + const updatedConversation = await apis.update({ + id: createdConversation.id, + conversation: conversationToUpdate, + kibanaSpace: kibanaSpace1, + }); + + expect(updatedConversation).toEqual( + expect.objectContaining({ + ...createdConversation, + ...conversationToUpdate, + // Use latest `updateAt` value + updatedAt: updatedConversation.updatedAt, + }) + ); + }); + }); + }); + + describe('RBAC', () => { + it('should not be able to update a conversation without `assistant` kibana privileges', async () => { + const superTest = await utils.createSuperTestWithCustomRole(noKibanaPrivileges); + + const apisNoPrivileges = getConversationsApis({ supertest: superTest }); + + const conversationToUpdate = { id: createdConversation.id, title: 'Updated Conversation' }; + const result = await apisNoPrivileges.update({ + id: createdConversation.id, + conversation: conversationToUpdate, + kibanaSpace: kibanaSpace1, + expectedHttpCode: 403, + }); + + expect(result).toEqual( + getMissingAssistantKibanaPrivilegesError({ + routeDetails: `PUT ${ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL}/${createdConversation.id}`, + }) + ); + }); + + it('should not be able to update a conversation in a space without kibana privileges for that space', async () => { + const superTest = await utils.createSuperTestWithCustomRole(securitySolutionOnlyAllSpace2); + + const apisOnlySpace2 = getConversationsApis({ supertest: superTest }); + + const conversationToUpdate = { id: createdConversation.id, title: 'Updated Conversation' }; + const result = await apisOnlySpace2.update({ + id: createdConversation.id, + conversation: conversationToUpdate, + kibanaSpace: kibanaSpace1, + expectedHttpCode: 403, + }); + + expect(result).toEqual( + getMissingAssistantKibanaPrivilegesError({ + routeDetails: `PUT ${ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL}/${createdConversation.id}`, + }) + ); + }); + }); + }); +}; diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/utils/apis.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/utils/apis.ts new file mode 100644 index 0000000000000..9966c969f085c --- /dev/null +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/utils/apis.ts @@ -0,0 +1,219 @@ +/* + * 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 SuperTest from 'supertest'; +import { + ELASTIC_HTTP_VERSION_HEADER, + X_ELASTIC_INTERNAL_ORIGIN_REQUEST, +} from '@kbn/core-http-common'; +import { replaceParams } from '@kbn/openapi-common/shared'; +import type { + BulkActionBase, + ConversationCreateProps, + ConversationUpdateProps, + FindConversationsRequestQuery, +} from '@kbn/elastic-assistant-common'; +import { + API_VERSIONS, + ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL, + ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL_BULK_ACTION, + ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL_BY_ID, + ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL_FIND, +} from '@kbn/elastic-assistant-common'; + +import type { User } from '../../utils/auth/types'; +import { routeWithNamespace } from '../../../../config/services/detections_response'; + +/** + * Source: Partial version of the PerformBulkActionRequestBody + * Used for testing bad request error handling when user does not pass required attributes + */ +export interface PartialPerformBulkActionRequestBody { + delete?: Partial; + create?: Partial[]; + update?: Partial[]; +} + +const configureTest = ({ + test, + user, + internal, +}: { + test: SuperTest.Test; + user?: User; + internal?: boolean; +}) => { + const configuredTest = test + .set('kbn-xsrf', 'true') + .set(ELASTIC_HTTP_VERSION_HEADER, internal ? API_VERSIONS.internal.v1 : API_VERSIONS.public.v1) + .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana'); + if (user) { + configuredTest.auth(user.username, user.password); + } + return configuredTest; +}; + +export const getConversationsApis = ({ + user, + supertest, +}: { + supertest: SuperTest.Agent; + user?: User; +}) => { + return { + /** + * Creates a Conversation + * @param param0 + * @returns + */ + create: async ({ + conversation, + kibanaSpace = 'default', + expectedHttpCode = 200, + }: { + conversation: ConversationCreateProps; + kibanaSpace?: string; + expectedHttpCode?: number; + }) => { + const route = routeWithNamespace(ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL, kibanaSpace); + const configuredTest = configureTest({ test: supertest.post(route), user }); + const response = await configuredTest.send(conversation).expect(expectedHttpCode); + + return response.body; + }, + + /** + * Finds Conversations + * @param param0 + * @returns + */ + find: async ({ + query, + kibanaSpace = 'default', + expectedHttpCode = 200, + }: { + query: FindConversationsRequestQuery; + kibanaSpace?: string; + expectedHttpCode?: number; + }) => { + const route = routeWithNamespace(ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL_FIND, kibanaSpace); + const configuredTest = configureTest({ test: supertest.get(route), user }); + const response = await configuredTest.query(query).expect(expectedHttpCode); + + return response.body; + }, + + /** + * Gets a Conversation + */ + get: async ({ + id, + kibanaSpace = 'default', + expectedHttpCode = 200, + }: { + id: string; + kibanaSpace?: string; + expectedHttpCode?: number; + }) => { + const route = routeWithNamespace( + replaceParams(ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL_BY_ID, { id }), + kibanaSpace + ); + const configuredTest = configureTest({ test: supertest.get(route), user }); + const response = await configuredTest.expect(expectedHttpCode); + + return response.body; + }, + + /** + * Updates a Conversation + */ + update: async ({ + id, + conversation, + kibanaSpace = 'default', + expectedHttpCode = 200, + }: { + id: string; + conversation: Partial; + kibanaSpace?: string; + expectedHttpCode?: number; + }) => { + const route = routeWithNamespace( + replaceParams(ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL_BY_ID, { id }), + kibanaSpace + ); + const configuredTest = configureTest({ test: supertest.put(route), user }); + const response = await configuredTest.send(conversation).expect(expectedHttpCode); + + return response.body; + }, + + /** + * Deletes a Conversation + */ + delete: async ({ + id, + kibanaSpace = 'default', + expectedHttpCode = 200, + }: { + id: string; + kibanaSpace?: string; + expectedHttpCode?: number; + }) => { + const route = routeWithNamespace( + replaceParams(ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL_BY_ID, { id }), + kibanaSpace + ); + const configuredTest = configureTest({ test: supertest.delete(route), user }); + const response = await configuredTest.expect(expectedHttpCode); + + return response.body; + }, + + /** + * Deletes all Conversations + */ + deleteAll: async ({ + kibanaSpace = 'default', + expectedHttpCode = 200, + }: { + kibanaSpace?: string; + expectedHttpCode?: number; + }) => { + const route = routeWithNamespace(ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL, kibanaSpace); + const configuredTest = configureTest({ test: supertest.delete(route), user }); + const response = await configuredTest.expect(expectedHttpCode); + + return response.body; + }, + + /** + * Performs bulk actions + * @param param0 + * @returns + */ + bulk: async ({ + bulkActions, + kibanaSpace = 'default', + expectedHttpCode = 200, + }: { + bulkActions: PartialPerformBulkActionRequestBody; + kibanaSpace?: string; + expectedHttpCode?: number; + }) => { + const route = routeWithNamespace( + ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL_BULK_ACTION, + kibanaSpace + ); + const configuredTest = configureTest({ test: supertest.post(route), user, internal: true }); + const response = await configuredTest.send(bulkActions).expect(expectedHttpCode); + + return response.body; + }, + }; +}; diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/utils/check_conversation_does_not_exist.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/utils/check_conversation_does_not_exist.ts new file mode 100644 index 0000000000000..73067ccd5b275 --- /dev/null +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/utils/check_conversation_does_not_exist.ts @@ -0,0 +1,31 @@ +/* + * 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 type { FtrProviderContext } from '../../../../ftr_provider_context'; +import { getConversationsApis } from './apis'; +import { getConversationNotFoundError } from './helpers'; + +export const checkIfConversationDoesNotExist = async ({ + getService, + id, + kibanaSpace, +}: { + getService: FtrProviderContext['getService']; + id: string; + kibanaSpace?: string; +}) => { + const supertest = getService('supertest'); + const apisSuperuser = getConversationsApis({ supertest }); + const result = await apisSuperuser.get({ + id, + kibanaSpace, + expectedHttpCode: 404, + }); + expect(result).toEqual(getConversationNotFoundError(id)); +}; diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/utils/check_conversation_exists.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/utils/check_conversation_exists.ts new file mode 100644 index 0000000000000..e423c6e87a341 --- /dev/null +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/utils/check_conversation_exists.ts @@ -0,0 +1,26 @@ +/* + * 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 type { FtrProviderContext } from '../../../../ftr_provider_context'; +import { getConversationsApis } from './apis'; + +export const checkIfConversationExists = async ({ + getService, + id, + kibanaSpace, +}: { + getService: FtrProviderContext['getService']; + id: string; + kibanaSpace?: string; +}) => { + const supertest = getService('supertest'); + const apisSuperuser = getConversationsApis({ supertest }); + const conversation = await apisSuperuser.get({ id, kibanaSpace }); + expect(conversation).toEqual(expect.objectContaining({ id })); +}; diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/utils/helpers.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/utils/helpers.ts new file mode 100644 index 0000000000000..7d6042d120c77 --- /dev/null +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/conversations/utils/helpers.ts @@ -0,0 +1,95 @@ +/* + * 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 { ToolingLog } from '@kbn/tooling-log'; +import type SuperTest from 'supertest'; + +import { countDownTest } from '../../../../config/services/detections_response'; +import { getConversationsApis } from './apis'; +import { getSimpleConversation } from '../mocks'; + +export const createConversations = async ({ + count, + kibanaSpace = 'default', + supertest, +}: { + count: number; + kibanaSpace?: string; + supertest: SuperTest.Agent; +}) => { + const conversationsToCreate = new Array(count) + .fill(0) + .map((_, index) => getSimpleConversation({ title: `Test Conversation - ${index}` })); + const createdConversations = await Promise.all( + conversationsToCreate.map((conversation) => { + const conversationApis = getConversationsApis({ supertest }); + return conversationApis.create({ conversation, kibanaSpace }); + }) + ); + return { conversationsToCreate, createdConversations }; +}; + +export const deleteAllConversations = async ({ + kibanaSpace = 'default', + log, + supertest, +}: { + kibanaSpace?: string; + log: ToolingLog; + supertest: SuperTest.Agent; +}): Promise => { + const conversationApis = getConversationsApis({ supertest }); + await countDownTest( + async () => { + const { data, total } = await conversationApis.find({ + query: { page: 1, per_page: 100 }, + kibanaSpace, + }); + + await Promise.all( + (data as Array<{ id: string }>).map(({ id }) => { + return conversationApis.delete({ id, kibanaSpace }); + }) + ); + + return { + passed: total - data.length === 0, + }; + }, + 'deleteAllConversations', + log, + 50, + 1000 + ); +}; + +export const getConversationNotFoundError = (conversationId: string) => { + return { + message: `conversation id: "${conversationId}" not found`, + status_code: 404, + }; +}; + +export const getConversationBadRequestError = (attributeName: string) => { + return { + error: 'Bad Request', + message: `[request body]: ${attributeName}: Required`, + statusCode: 400, + }; +}; + +export const getMissingAssistantKibanaPrivilegesError = ({ + routeDetails, +}: { + routeDetails: string; +}) => { + return { + error: 'Forbidden', + message: `API [${routeDetails}] is unauthorized for user, this action is granted by the Kibana privileges [elasticAssistant]`, + statusCode: 403, + }; +};