From d3273d0ee7ae6e9de70f6b7f7f3ffa3c58d4182b Mon Sep 17 00:00:00 2001 From: Josh Mock Date: Tue, 1 Sep 2026 14:53:04 -0400 Subject: [PATCH 1/2] fix(kibana): send an empty object when no body is provided on POST, PUT, PATCH and DELETE --- codegen/functional/kb.ts | 15 +--- src/kb/request-builder.ts | 12 +++- test/kb/request-builder.test.ts | 122 ++++++++++++++++++++++++++++++++ 3 files changed, 135 insertions(+), 14 deletions(-) diff --git a/codegen/functional/kb.ts b/codegen/functional/kb.ts index bdb12a88..931fc931 100644 --- a/codegen/functional/kb.ts +++ b/codegen/functional/kb.ts @@ -385,13 +385,10 @@ 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", - "misc_post_security_role_query.yml", // @elastic/schemas defect: // Upstream bugs tracked at: @@ -495,14 +492,6 @@ 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", - "agent_builder_mcp_post.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 // unparseable string), failing input validation before the request. "elastic_agents_get_fleet_agent_status_data.yml", 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), From b7532340a091e311d0db2f93ac414dc7be0ceb2d Mon Sep 17 00:00:00 2001 From: Josh Mock Date: Tue, 1 Sep 2026 15:54:39 -0400 Subject: [PATCH 2/2] update skip descriptions with new cause --- codegen/functional/kb.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/codegen/functional/kb.ts b/codegen/functional/kb.ts index 931fc931..2fdc3ca7 100644 --- a/codegen/functional/kb.ts +++ b/codegen/functional/kb.ts @@ -390,6 +390,13 @@ const skippedFilesServerless = new Set([ "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: // Upstream bugs tracked at: // https://github.com/elastic/schemas-js/issues/77 @@ -492,6 +499,15 @@ const skippedFilesStack = new Set([ "security_osquery_api_osquery_update_packs.yml", "security_osquery_api_osquery_update_saved_query.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_detections_api_search_alerts.yml", + // Array/oneOf query or body fields are mis-serialized (emitted as null or an // unparseable string), failing input validation before the request. "elastic_agents_get_fleet_agent_status_data.yml",