diff --git a/codegen/functional/kb.ts b/codegen/functional/kb.ts index bdb12a88..2fdc3ca7 100644 --- a/codegen/functional/kb.ts +++ b/codegen/functional/kb.ts @@ -385,12 +385,16 @@ const skippedFilesServerless = new Set([ "fleet_uninstall_tokens_post_fleet_uninstall_tokens_agentpolicyid_rotate.yml", "message_signing_service_post_fleet_message_signing_service_rotate_key_pair.yml", - // CLI (de)serialization defects: empty body sent as null, array param not - // serialized as an array, or a non-JSON response the client cannot parse. - "agent_builder_consumption.yml", - "agent_builder_mcp_post.yml", + // CLI (de)serialization defects: array param not serialized as an array, + // or a non-JSON response the client cannot parse. "elastic_agent_policies_get_fleet_kubernetes_download.yml", "elastic_agents_get_fleet_agent_status_data.yml", + + // Not fixed by empty-body normalisation: mcp_post needs a JSON-RPC payload and + // returns an event-stream (-32700 Parse error); consumption 404s (route absent); + // security_role_query returns total 0 (no queryable roles in this env). + "agent_builder_mcp_post.yml", + "agent_builder_consumption.yml", "misc_post_security_role_query.yml", // @elastic/schemas defect: @@ -495,12 +499,13 @@ const skippedFilesStack = new Set([ "security_osquery_api_osquery_update_packs.yml", "security_osquery_api_osquery_update_saved_query.yml", - // CLI serializes an empty/optional request body as `null`; Kibana rejects it - // ("expected a plain object value, but found [null]"). - "agent_builder_consumption.yml", + // Not fixed by empty-body normalisation: mcp_post needs a JSON-RPC payload and + // returns an event-stream (-32700 Parse error); consumption 404s (route absent); + // security_role_query returns total 0 (no queryable roles in this env); + // search_alerts rejects an empty body ("value must have at least 1 children"). "agent_builder_mcp_post.yml", + "agent_builder_consumption.yml", "misc_post_security_role_query.yml", - "security_ai_assistant_api_delete_all_conversations.yml", "security_detections_api_search_alerts.yml", // Array/oneOf query or body fields are mis-serialized (emitted as null or an diff --git a/src/kb/request-builder.ts b/src/kb/request-builder.ts index b60b9e7a..5b095767 100644 --- a/src/kb/request-builder.ts +++ b/src/kb/request-builder.ts @@ -66,12 +66,22 @@ export function buildKibanaRequestParams ( // because these endpoints require siblings alongside the file (e.g. saved-objects // resolve-import-errors needs `retries`). const fields = isPlainObject(body) ? body : undefined - if (fields != null && MULTIPART_ENDPOINTS.has(`${def.namespace} ${def.name}`)) { + const isMultipart = MULTIPART_ENDPOINTS.has(`${def.namespace} ${def.name}`) + if (fields != null && isMultipart) { params.multipartFields = Object.fromEntries( Object.entries(fields).map(([key, value]) => [key, typeof value === 'string' ? value : String(value)]) ) } else if (body !== undefined) { params.body = body + } else if ( + // POST/PUT/PATCH/DELETE with schema-defined body properties must send at minimum `{}`. + // Kibana treats a missing body as `null` for these endpoints and rejects with + // "expected a plain object value, but found [null]". GET/HEAD never carry a body. + def.method !== 'GET' && def.method !== 'HEAD' && + !isMultipart && + Object.values(props).some((p) => p['x-found-in'] === 'body' || p['x-found-in'] === undefined) + ) { + params.body = {} } return params diff --git a/test/kb/request-builder.test.ts b/test/kb/request-builder.test.ts index d3af68e1..f126f485 100644 --- a/test/kb/request-builder.test.ts +++ b/test/kb/request-builder.test.ts @@ -117,6 +117,128 @@ describe('buildKibanaRequestParams', () => { }) }) +describe('buildKibanaRequestParams empty-body normalisation (CLI-1)', () => { + it('sends {} for a POST with optional body fields when none are provided', () => { + const def: KbApiDefinition = { + name: 'post-thing', + namespace: 'widgets', + description: 'Post a thing', + method: 'POST', + path: '/api/widgets', + input: { + type: 'object', + properties: { + note: { type: 'string', 'x-found-in': 'body' }, + }, + }, + } + const result = buildKibanaRequestParams(def, parsed()) + assert.deepEqual(result.body, {}, 'POST with no body args must send {} not null/undefined') + }) + + it('sends {} for a DELETE with optional body fields when none are provided', () => { + const def: KbApiDefinition = { + name: 'delete-thing', + namespace: 'widgets', + description: 'Delete things', + method: 'DELETE', + path: '/api/widgets', + input: { + type: 'object', + properties: { + excludedIds: { type: 'array', items: { type: 'string' }, 'x-found-in': 'body' }, + }, + }, + } + const result = buildKibanaRequestParams(def, parsed()) + assert.deepEqual(result.body, {}, 'DELETE with optional body must send {} not omit') + }) + + it('sends {} for a PATCH with optional body fields when none are provided', () => { + const def: KbApiDefinition = { + name: 'patch-thing', + namespace: 'widgets', + description: 'Patch a thing', + method: 'PATCH', + path: '/api/widgets/1', + input: { + type: 'object', + properties: { + note: { type: 'string', 'x-found-in': 'body' }, + }, + }, + } + const result = buildKibanaRequestParams(def, parsed()) + assert.deepEqual(result.body, {}, 'PATCH with no body args must send {}') + }) + + it('does not set body for a GET with body-routed properties', () => { + // GET requests must never carry a body regardless of property routing + const def: KbApiDefinition = { + name: 'get-thing', + namespace: 'widgets', + description: 'Get a thing', + method: 'GET', + path: '/api/widgets', + input: { + type: 'object', + properties: { + note: { type: 'string', 'x-found-in': 'body' }, + }, + }, + } + const result = buildKibanaRequestParams(def, parsed()) + assert.equal(result.body, undefined, 'GET must not send a body') + }) + + it('does not set body for a multipart endpoint with no body fields provided', () => { + const def: KbApiDefinition = { + name: 'post-saved-objects-import', + namespace: 'saved-objects', + description: 'Import saved objects', + method: 'POST', + path: '/api/saved_objects/_import', + input: { + type: 'object', + properties: { + file: { type: 'string', 'x-found-in': 'body' }, + }, + }, + } + // With no file provided, multipart endpoints should have neither body nor multipartFields + const result = buildKibanaRequestParams(def, parsed()) + assert.equal(result.body, undefined, 'empty multipart endpoint must not send JSON body') + assert.equal(result.multipartFields, undefined, 'empty multipart endpoint must not send empty form') + }) + + it('sends {} for a POST with x-body-root field and no input, using a real definition', async () => { + const { loadAllKbApis } = await import('../../src/kb/apis.ts') + const apis = await loadAllKbApis() + const def = apis.find((d) => d.namespace === 'misc' && d.name === 'post-security-role-query') + assert.ok(def != null, 'expected misc post-security-role-query in manifest') + const result = buildKibanaRequestParams(def, parsed()) + assert.deepEqual(result.body, {}, 'bodyless misc POST must send {} not null/undefined') + }) + + it('sends {} for search-alerts with no args, using a real definition', async () => { + const { loadAllKbApis } = await import('../../src/kb/apis.ts') + const apis = await loadAllKbApis() + const def = apis.find((d) => d.namespace === 'security-detections-api' && d.name === 'search-alerts') + assert.ok(def != null, 'expected security-detections-api search-alerts in manifest') + const result = buildKibanaRequestParams(def, parsed()) + assert.deepEqual(result.body, {}, 'search-alerts with no args must send {} not null/undefined') + }) + + it('sends {} for delete-all-conversations (DELETE with optional body) using a real definition', async () => { + const { loadAllKbApis } = await import('../../src/kb/apis.ts') + const apis = await loadAllKbApis() + const def = apis.find((d) => d.namespace === 'security-ai-assistant-api' && d.name === 'delete-all-conversations') + assert.ok(def != null, 'expected security-ai-assistant-api delete-all-conversations in manifest') + const result = buildKibanaRequestParams(def, parsed()) + assert.deepEqual(result.body, {}, 'DELETE with optional body must send {} not null/undefined') + }) +}) + describe('buildKibanaRequestParams path param requiredness (BUG A regression)', () => { // ponytail: no real Kibana definition currently has an optional path param // (0 of 555 upstream definitions exercise this — see test/kb/register.test.ts),