diff --git a/.eslintrc.js b/.eslintrc.js index af44f330c171b..b77bbde1cc596 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -410,20 +410,15 @@ const RESTRICTED_IMPORTS = [ const AXIOS_LEGACY_CONSUMERS = [ '.buildkite/**/*.{js,mjs,ts,tsx,jsx}', 'packages/kbn-ci-stats-performance-metrics/**/*.{js,mjs,ts,tsx}', - 'packages/kbn-failed-test-reporter-cli/**/*.{js,mjs,ts,tsx}', 'packages/kbn-generate/**/*.{js,mjs,ts,tsx}', 'src/dev/build/lib/**/*.{js,mjs,ts,tsx}', 'src/dev/build/tasks/**/*.{js,mjs,ts,tsx}', 'src/dev/prs/**/*.{js,mjs,ts,tsx}', 'src/platform/packages/private/kbn-ci-stats-reporter/**/*.{js,mjs,ts,tsx}', - 'src/platform/packages/private/kbn-journeys/**/*.{js,mjs,ts,tsx}', 'src/platform/packages/shared/kbn-connector-specs/**/*.{js,mjs,ts,tsx}', 'src/platform/packages/shared/kbn-cypress-test-helper/**/*.{js,mjs,ts,tsx}', 'src/platform/packages/shared/kbn-dev-utils/src/axios/**/*.{js,mjs,ts,tsx}', - 'src/platform/packages/shared/kbn-kbn-client/**/*.{js,mjs,ts,tsx}', 'src/platform/packages/shared/kbn-mcp-dev-server/**/*.{js,mjs,ts,tsx}', - 'src/platform/packages/shared/kbn-test-saml-auth/**/*.{js,mjs,ts,tsx}', - 'src/platform/test/api_integration/apis/telemetry/**/*.{js,mjs,ts,tsx}', 'x-pack/examples/alerting_example/server/rule_types/**/*.{js,mjs,ts,tsx}', 'x-pack/packages/kbn-synthetics-private-location/**/*.{js,mjs,ts,tsx}', 'x-pack/platform/packages/shared/kbn-data-forge/**/*.{js,mjs,ts,tsx}', @@ -447,7 +442,6 @@ const AXIOS_LEGACY_CONSUMERS = [ 'x-pack/platform/test/alerting_api_integration/common/plugins/alerts/server/sub_action_connector.ts', 'x-pack/platform/test/alerting_api_integration/security_and_spaces/group4/tests/alerting/mustache_templates.ts', 'x-pack/platform/test/alerting_api_integration/spaces_only/tests/alerting/group4/mustache_templates.ts', - 'x-pack/platform/test/api_integration/services/**/*.{js,mjs,ts,tsx}', 'x-pack/platform/test/fleet_api_integration/**/*.{js,mjs,ts,tsx}', 'x-pack/platform/test/fleet_cypress/agent.ts', 'x-pack/platform/test/fleet_cypress/artifact_manager.ts', diff --git a/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/existing_failed_test_issues.test.ts b/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/existing_failed_test_issues.test.ts index 329bcc912b226..03312fe60f7e9 100644 --- a/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/existing_failed_test_issues.test.ts +++ b/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/existing_failed_test_issues.test.ts @@ -20,15 +20,14 @@ const log = new ToolingLog(); const writer = new ToolingLogCollectingWriter(); log.setWriters([writer]); +const fetchMock = jest.spyOn(global, 'fetch'); + afterEach(() => { writer.messages.length = 0; jest.clearAllMocks(); }); -jest.mock('axios', () => ({ - request: jest.fn(), -})); -const Axios = jest.requireMock('axios'); +const jsonResponse = (data: unknown) => new Response(JSON.stringify(data), { status: 200 }); const mockTestFailure: Omit = { failure: '', @@ -41,12 +40,15 @@ const mockTestFailure: Omit = { it('captures a list of failed test issue, loads the bodies for each issue, and only fetches what is needed', async () => { const existing = new ExistingFailedTestIssues(log); - Axios.request.mockImplementation(({ data }: any) => ({ - data: { - existingIssues: data.failures - .filter((t: any) => t.classname.includes('foo')) + fetchMock.mockImplementation(async (_input, init) => { + const body = JSON.parse(init?.body as string) as { + failures: Array<{ classname: string; name: string }>; + }; + return jsonResponse({ + existingIssues: body.failures + .filter((t) => t.classname.includes('foo')) .map( - (t: any, i: any): FailedTestIssue => ({ + (t, i): FailedTestIssue => ({ classname: t.classname, name: t.name, github: { @@ -57,8 +59,8 @@ it('captures a list of failed test issue, loads the bodies for each issue, and o }, }) ), - }, - })); + }); + }); const fooFailure: TestFailure = { ...mockTestFailure, @@ -98,72 +100,42 @@ it('captures a list of failed test issue, loads the bodies for each issue, and o " debg loaded 1 existing test issues", ] `); - expect(Axios.request).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Object { - "allowAbsoluteUrls": false, - "baseURL": "https://ci-stats.kibana.dev", - "data": Object { - "failures": Array [ - Object { - "classname": "foo classname", - "name": "foo test", - }, - ], - }, - "method": "POST", - "url": "/v1/find_failed_test_issues", - }, - ], - Array [ - Object { - "allowAbsoluteUrls": false, - "baseURL": "https://ci-stats.kibana.dev", - "data": Object { - "failures": Array [ - Object { - "classname": "bar classname", - "name": "bar test", - }, - ], - }, - "method": "POST", - "url": "/v1/find_failed_test_issues", - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Object { - "data": Object { - "existingIssues": Array [ - Object { - "classname": "foo classname", - "github": Object { - "body": "FAILURE: foo classname/foo test", - "htmlUrl": "htmlurl(foo classname/foo test)", - "nodeId": "nodeid(foo classname/foo test)", - "number": 21, - }, - "name": "foo test", - }, - ], + + expect(fetchMock).toHaveBeenCalledTimes(2); + // Argument shape: each call should be (url, init) with method POST and a JSON body + // describing the failure to look up. Snapshot the call args after parsing the body. + const calls = fetchMock.mock.calls.map(([url, init]) => ({ + url: typeof url === 'string' ? url : url.toString(), + method: init?.method, + body: JSON.parse(init?.body as string), + })); + expect(calls).toMatchInlineSnapshot(` + Array [ + Object { + "body": Object { + "failures": Array [ + Object { + "classname": "foo classname", + "name": "foo test", }, - }, + ], }, - Object { - "type": "return", - "value": Object { - "data": Object { - "existingIssues": Array [], + "method": "POST", + "url": "https://ci-stats.kibana.dev/v1/find_failed_test_issues", + }, + Object { + "body": Object { + "failures": Array [ + Object { + "classname": "bar classname", + "name": "bar test", }, - }, + ], }, - ], - } + "method": "POST", + "url": "https://ci-stats.kibana.dev/v1/find_failed_test_issues", + }, + ] `); }); @@ -193,12 +165,15 @@ describe('Scout failures', () => { it('matches Scout failures by name only, ignoring target differences', async () => { const existing = new ExistingFailedTestIssues(log); - Axios.request.mockImplementation(({ data }: any) => ({ - data: { - existingIssues: data.failures - .filter((t: any) => t.name === 'scout test name') + fetchMock.mockImplementation(async (_input, init) => { + const body = JSON.parse(init?.body as string) as { + failures: Array<{ classname: string; name: string }>; + }; + return jsonResponse({ + existingIssues: body.failures + .filter((t) => t.name === 'scout test name') .map( - (t: any): FailedTestIssue => ({ + (t): FailedTestIssue => ({ classname: t.classname || 'scout suite', name: t.name, github: { @@ -209,8 +184,8 @@ describe('Scout failures', () => { }, }) ), - }, - })); + }); + }); // First Scout failure with target chrome const scoutFailure1: TestFailure & { id: string; target: string; location: string } = { @@ -249,11 +224,7 @@ describe('Scout failures', () => { it('correctly identifies seen Scout failures by name only', async () => { const existing = new ExistingFailedTestIssues(log); - Axios.request.mockImplementation(() => ({ - data: { - existingIssues: [], - }, - })); + fetchMock.mockImplementation(async () => jsonResponse({ existingIssues: [] })); // First Scout failure with target chrome const scoutFailure1: TestFailure & { id: string; target: string; location: string } = { @@ -283,16 +254,19 @@ describe('Scout failures', () => { await existing.loadForFailures([scoutFailure1, scoutFailure2]); // Should only make one API call (for the first failure, second is already seen) - expect(Axios.request).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(1); }); it('distinguishes between Scout and FTR failures correctly', async () => { const existing = new ExistingFailedTestIssues(log); - Axios.request.mockImplementation(({ data }: any) => ({ - data: { - existingIssues: data.failures.map( - (t: any, i: number): FailedTestIssue => ({ + fetchMock.mockImplementation(async (_input, init) => { + const body = JSON.parse(init?.body as string) as { + failures: Array<{ classname: string; name: string }>; + }; + return jsonResponse({ + existingIssues: body.failures.map( + (t, i): FailedTestIssue => ({ classname: t.classname, name: t.name, github: { @@ -303,8 +277,8 @@ describe('Scout failures', () => { }, }) ), - }, - })); + }); + }); const scoutFailure: TestFailure & { id: string; target: string; location: string } = { ...mockTestFailure, @@ -337,11 +311,7 @@ describe('Scout failures', () => { it('returns undefined for Scout failures when no matching issue exists', async () => { const existing = new ExistingFailedTestIssues(log); - Axios.request.mockImplementation(() => ({ - data: { - existingIssues: [], - }, - })); + fetchMock.mockImplementation(async () => jsonResponse({ existingIssues: [] })); const scoutFailure: TestFailure & { id: string; target: string; location: string } = { ...mockTestFailure, diff --git a/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/existing_failed_test_issues.ts b/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/existing_failed_test_issues.ts index 055271cb8eb10..a060b48b9bef4 100644 --- a/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/existing_failed_test_issues.ts +++ b/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/existing_failed_test_issues.ts @@ -9,9 +9,7 @@ import { setTimeout } from 'timers/promises'; -import { isAxiosRequestError, isAxiosResponseError } from '@kbn/dev-utils'; import type { ToolingLog } from '@kbn/tooling-log'; -import Axios from 'axios'; import type { TestFailure } from './get_failures'; import type { GithubIssueMini } from './github_api'; @@ -141,35 +139,40 @@ export class ExistingFailedTestIssues { while (true) { attempt += 1; + let response: Response; try { - const resp = await Axios.request({ + response = await fetch(`${BASE_URL}/v1/find_failed_test_issues`, { method: 'POST', - baseURL: BASE_URL, - allowAbsoluteUrls: false, - url: '/v1/find_failed_test_issues', - data: { + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ failures: failures.map((f) => ({ classname: f.classname, name: f.name, })), - }, + }), }); - - return resp.data.existingIssues; } catch (error: unknown) { - if ( - attempt < maxAttempts && - ((isAxiosResponseError(error) && error.response.status >= 500) || - isAxiosRequestError(error)) - ) { - this.log.error(error); + // Network-level error (fetch throws TypeError for DNS/connection failures). + if (attempt < maxAttempts) { + this.log.error(error as Error); this.log.warning(`Failure talking to ci-stats, waiting ${attempt} before retrying`); await setTimeout(attempt * 1000); continue; } - throw error; } + + if (!response.ok) { + // Server error: retry on 5xx, throw immediately on 4xx. + if (attempt < maxAttempts && response.status >= 500) { + this.log.warning(`Failure talking to ci-stats, waiting ${attempt} before retrying`); + await setTimeout(attempt * 1000); + continue; + } + throw new Error(`${response.status}:${await response.text()}`); + } + + return ((await response.json()) as FindFailedTestIssuesResponse).existingIssues; } } diff --git a/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/github_api.ts b/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/github_api.ts index 9f8b3f93f278a..abc105d098d0e 100644 --- a/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/github_api.ts +++ b/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/github_api.ts @@ -9,9 +9,6 @@ import Url from 'url'; -import type { AxiosRequestConfig, AxiosInstance, AxiosHeaderValue } from 'axios'; -import Axios, { AxiosHeaders } from 'axios'; -import { isAxiosResponseError, isAxiosRequestError } from '@kbn/dev-utils'; import type { ToolingLog } from '@kbn/tooling-log'; const BASE_URL = 'https://api.github.com/repos/elastic/kibana/'; @@ -35,17 +32,19 @@ export interface GithubIssueMini { node_id: GithubIssue['node_id']; } -type RequestOptions = AxiosRequestConfig & { +interface RequestOptions { + method: string; + url: string; + data?: unknown; safeForDryRun?: boolean; maxAttempts?: number; - attempt?: number; -}; +} export class GithubApi { private readonly log: ToolingLog; private readonly token: string | undefined; private readonly dryRun: boolean; - private readonly x: AxiosInstance; + private readonly defaultHeaders: Record; private requestCount: number = 0; /** @@ -65,12 +64,10 @@ export class GithubApi { throw new TypeError('token parameter is required'); } - this.x = Axios.create({ - headers: { - ...(this.token ? { Authorization: `token ${this.token}` } : {}), - 'User-Agent': 'elastic/kibana#failed_test_reporter', - }, - }); + this.defaultHeaders = { + ...(this.token ? { Authorization: `token ${this.token}` } : {}), + 'User-Agent': 'elastic/kibana#failed_test_reporter', + }; } getRequestCount() { @@ -132,7 +129,7 @@ export class GithubApi { ): Promise<{ status: number; statusText: string; - headers: Record; + headers: Headers; data: T; }> { const executeRequest = !this.dryRun || options.safeForDryRun; @@ -147,40 +144,52 @@ export class GithubApi { return { status: 200, statusText: 'OK', - headers: new AxiosHeaders(), + headers: new Headers(), data: dryRunResponse, }; } + this.requestCount += 1; + + let response: Response; try { - this.requestCount += 1; - return await this.x.request(options); + response = await fetch(options.url, { + method: options.method, + headers: { + ...this.defaultHeaders, + ...(options.data !== undefined ? { 'Content-Type': 'application/json' } : {}), + }, + body: options.data !== undefined ? JSON.stringify(options.data) : undefined, + }); } catch (error) { - const unableToReachGithub = isAxiosRequestError(error); - const githubApiFailed = isAxiosResponseError(error) && error.response.status >= 500; - const errorResponseLog = - isAxiosResponseError(error) && - `[${error.config?.method} ${error.config?.url}] ${error.response.status} ${error.response.statusText} Error`; - - if ((unableToReachGithub || githubApiFailed) && attempt < maxAttempts) { + // Network-level error (DNS, connection refused, etc.). + if (attempt < maxAttempts) { const waitMs = 1000 * attempt; - - if (errorResponseLog) { - this.log.error(`${errorResponseLog}: waiting ${waitMs}ms to retry`); - } else { - this.log.error(`Unable to reach github, waiting ${waitMs}ms to retry`); - } - + this.log.error(`Unable to reach github, waiting ${waitMs}ms to retry`); await new Promise((resolve) => setTimeout(resolve, waitMs)); continue; } + throw error; + } - if (errorResponseLog) { - throw new Error(`${errorResponseLog}: ${JSON.stringify(error.response.data)}`); + if (!response.ok) { + const errorResponseLog = `[${options.method} ${options.url}] ${response.status} ${response.statusText} Error`; + if (response.status >= 500 && attempt < maxAttempts) { + const waitMs = 1000 * attempt; + this.log.error(`${errorResponseLog}: waiting ${waitMs}ms to retry`); + await new Promise((resolve) => setTimeout(resolve, waitMs)); + continue; } - throw error; + throw new Error(`${errorResponseLog}: ${await response.text()}`); } + + return { + status: response.status, + statusText: response.statusText, + headers: response.headers, + data: (await response.json()) as T, + }; } } } diff --git a/packages/kbn-failed-test-reporter-cli/moon.yml b/packages/kbn-failed-test-reporter-cli/moon.yml index 36a0ef2e1bfdc..cb05b5a134e6a 100644 --- a/packages/kbn-failed-test-reporter-cli/moon.yml +++ b/packages/kbn-failed-test-reporter-cli/moon.yml @@ -20,7 +20,6 @@ dependsOn: - '@kbn/ci-stats-reporter' - '@kbn/dev-cli-runner' - '@kbn/dev-cli-errors' - - '@kbn/dev-utils' - '@kbn/tooling-log' - '@kbn/ftr-screenshot-filename' - '@kbn/jest-serializers' diff --git a/packages/kbn-failed-test-reporter-cli/tsconfig.json b/packages/kbn-failed-test-reporter-cli/tsconfig.json index 02941d445044c..217f802dba93b 100644 --- a/packages/kbn-failed-test-reporter-cli/tsconfig.json +++ b/packages/kbn-failed-test-reporter-cli/tsconfig.json @@ -14,7 +14,6 @@ "@kbn/ci-stats-reporter", "@kbn/dev-cli-runner", "@kbn/dev-cli-errors", - "@kbn/dev-utils", "@kbn/tooling-log", "@kbn/ftr-screenshot-filename", "@kbn/jest-serializers", diff --git a/src/platform/packages/private/kbn-journeys/journey/journey_ftr_harness.ts b/src/platform/packages/private/kbn-journeys/journey/journey_ftr_harness.ts index b7fea41fb9807..51431803548cc 100644 --- a/src/platform/packages/private/kbn-journeys/journey/journey_ftr_harness.ts +++ b/src/platform/packages/private/kbn-journeys/journey/journey_ftr_harness.ts @@ -22,7 +22,6 @@ import { X_ELASTIC_INTERNAL_ORIGIN_REQUEST, } from '@kbn/core-http-common'; -import type { AxiosError } from 'axios'; import type { Auth, Es, EsArchiver, KibanaServer, Retry } from '../services'; import { getInputDelays } from '../services/input_delays'; import { KibanaUrl } from '../services/kibana_url'; @@ -96,7 +95,7 @@ export class JourneyFtrHarness { body: { telemetry: { labels } }, }); } catch (error) { - const statusCode = (error as AxiosError).response?.status; + const statusCode = (error as { status?: number }).status; if (statusCode === 404) { throw new Error( `Failed to update labels, supported Kibana version is 8.11.0+ and must be started with "coreApp.allowDynamicConfigOverrides:true"` diff --git a/src/platform/packages/private/kbn-journeys/services/auth.ts b/src/platform/packages/private/kbn-journeys/services/auth.ts index d6dd0486d4a44..512101d96cf19 100644 --- a/src/platform/packages/private/kbn-journeys/services/auth.ts +++ b/src/platform/packages/private/kbn-journeys/services/auth.ts @@ -10,8 +10,6 @@ import Url from 'url'; import { format } from 'util'; -import type { AxiosResponse } from 'axios'; -import axios from 'axios'; import { FtrService } from './ftr_context_provider'; export interface Credentials { @@ -19,8 +17,14 @@ export interface Credentials { password: string; } -function extractCookieValue(authResponse: AxiosResponse) { - return authResponse.headers['set-cookie']?.[0].toString().split(';')[0].split('sid=')[1] ?? ''; +function extractCookieValue(headers: Headers) { + // Headers.getSetCookie() is the Node 22 / undici API; fall back to Headers.get for + // older runtimes (jsdom in the test config). + const headersWithGetSetCookie = headers as Headers & { getSetCookie?: () => string[] }; + const firstSetCookie = headersWithGetSetCookie.getSetCookie + ? headersWithGetSetCookie.getSetCookie()[0] + : headers.get('set-cookie'); + return firstSetCookie?.split(';')[0].split('sid=')[1] ?? ''; } export class AuthService extends FtrService { private readonly config = this.ctx.getService('config'); @@ -41,15 +45,8 @@ export class AuthService extends FtrService { const version = await this.kibanaServer.version.get(); this.log.info('fetching auth cookie from', loginUrl.href); - const authResponse = await axios.request({ - url: loginUrl.href, - method: 'post', - data: { - providerType: 'basic', - providerName: provider, - currentURL: new URL('/login?next=%2F', baseUrl).href, - params: credentials ?? { username: this.getUsername(), password: this.getPassword() }, - }, + const authResponse = await fetch(loginUrl, { + method: 'POST', headers: { 'content-type': 'application/json', 'kbn-version': version, @@ -57,8 +54,13 @@ export class AuthService extends FtrService { 'sec-fetch-site': 'same-origin', 'x-elastic-internal-origin': 'Kibana', }, - validateStatus: () => true, - maxRedirects: 0, + body: JSON.stringify({ + providerType: 'basic', + providerName: provider, + currentURL: new URL('/login?next=%2F', baseUrl).href, + params: credentials ?? { username: this.getUsername(), password: this.getPassword() }, + }), + redirect: 'manual', }); if (authResponse.status !== 200) { @@ -67,15 +69,15 @@ export class AuthService extends FtrService { ); } - const cookie = extractCookieValue(authResponse); + const cookie = extractCookieValue(authResponse.headers); if (cookie) { this.log.info('captured auth cookie'); } else { this.log.error( format('unable to determine auth cookie from response', { status: `${authResponse.status} ${authResponse.statusText}`, - body: authResponse.data, - headers: authResponse.headers, + body: await authResponse.text(), + headers: Object.fromEntries(authResponse.headers.entries()), }) ); diff --git a/src/platform/packages/shared/kbn-cypress-test-helper/src/services/stack_services.ts b/src/platform/packages/shared/kbn-cypress-test-helper/src/services/stack_services.ts index 10ec0f10bcdb8..155b98c7148e6 100644 --- a/src/platform/packages/shared/kbn-cypress-test-helper/src/services/stack_services.ts +++ b/src/platform/packages/shared/kbn-cypress-test-helper/src/services/stack_services.ts @@ -12,8 +12,7 @@ import type { ToolingLog } from '@kbn/tooling-log'; import type { KbnClientOptions } from '@kbn/test'; import { KbnClient } from '@kbn/test'; import pRetry from 'p-retry'; -import type { ReqOptions } from '@kbn/kbn-client'; -import { type AxiosResponse } from 'axios'; +import type { KbnClientResponse, ReqOptions } from '@kbn/kbn-client'; import type { ClientOptions } from '@elastic/elasticsearch/lib/client'; import fs from 'fs'; import { CA_CERT_PATH } from '@kbn/dev-utils'; @@ -92,7 +91,7 @@ class KbnClientExtended extends KbnClient { this.apiKey = apiKey; } - async request(options: ReqOptions): Promise> { + async request(options: ReqOptions): Promise> { const headers: ReqOptions['headers'] = { ...(options.headers ?? {}), }; diff --git a/src/platform/packages/shared/kbn-ftr-common-functional-services/services/security/role.ts b/src/platform/packages/shared/kbn-ftr-common-functional-services/services/security/role.ts index 7c143f582a2c3..5f4d19f0ae35e 100644 --- a/src/platform/packages/shared/kbn-ftr-common-functional-services/services/security/role.ts +++ b/src/platform/packages/shared/kbn-ftr-common-functional-services/services/security/role.ts @@ -25,7 +25,7 @@ export class Role { method: 'GET', }) .catch((e) => { - throw new Error(util.inspect(e.axiosError.response, true)); + throw new Error(util.inspect({ status: e.status, message: e.message }, true)); }); if (status !== 200) { throw new Error( @@ -49,7 +49,7 @@ export class Role { retries: 0, }) .catch((e) => { - throw new Error(util.inspect(e.axiosError.response, true)); + throw new Error(util.inspect({ status: e.status, message: e.message }, true)); }); if (status !== 204) { throw new Error( diff --git a/src/platform/packages/shared/kbn-kbn-client/moon.yml b/src/platform/packages/shared/kbn-kbn-client/moon.yml index 2012b718d5785..62c2a06fa42c9 100644 --- a/src/platform/packages/shared/kbn-kbn-client/moon.yml +++ b/src/platform/packages/shared/kbn-kbn-client/moon.yml @@ -19,7 +19,6 @@ project: dependsOn: - '@kbn/core-saved-objects-api-server' - '@kbn/dev-cli-errors' - - '@kbn/dev-utils' - '@kbn/repo-info' - '@kbn/tooling-log' tags: diff --git a/src/platform/packages/shared/kbn-kbn-client/src/kbn_client/index.ts b/src/platform/packages/shared/kbn-kbn-client/src/kbn_client/index.ts index b230b5c040b61..c1083dfce96d9 100644 --- a/src/platform/packages/shared/kbn-kbn-client/src/kbn_client/index.ts +++ b/src/platform/packages/shared/kbn-kbn-client/src/kbn_client/index.ts @@ -9,7 +9,7 @@ export * from './kbn_client'; export { uriencode } from './kbn_client_requester'; -export type { ReqOptions } from './kbn_client_requester'; +export type { KbnClientResponse, ReqOptions } from './kbn_client_requester'; export { KbnClientRequesterError } from './kbn_client_requester_error'; export { KbnClientSavedObjects } from './kbn_client_saved_objects'; export type { UiSettingValues } from './kbn_client_ui_settings'; diff --git a/src/platform/packages/shared/kbn-kbn-client/src/kbn_client/kbn_client_import_export.ts b/src/platform/packages/shared/kbn-kbn-client/src/kbn_client/kbn_client_import_export.ts index 9b2ca2092085c..27a18c89386d8 100644 --- a/src/platform/packages/shared/kbn-kbn-client/src/kbn_client/kbn_client_import_export.ts +++ b/src/platform/packages/shared/kbn-kbn-client/src/kbn_client/kbn_client_import_export.ts @@ -12,11 +12,10 @@ import Fs from 'fs/promises'; import { existsSync } from 'fs'; import Path from 'path'; -import FormData from 'form-data'; -import { isAxiosResponseError } from '@kbn/dev-utils'; import { createFailError } from '@kbn/dev-cli-errors'; import type { ToolingLog } from '@kbn/tooling-log'; import { REPO_ROOT } from '@kbn/repo-info'; +import { KbnClientRequesterError } from './kbn_client_requester_error'; import type { KbnClientRequester, ReqOptions } from './kbn_client_requester'; import { uriencode } from './kbn_client_requester'; @@ -63,8 +62,18 @@ export class KbnClientImportExport { const objects = await parseArchive(src); this.log.info('importing', objects.length, 'saved objects', { space: options?.space }); + // Use the native (WHATWG) FormData + Blob so fetch handles the multipart + // boundary itself. The legacy `form-data` package produced a Node Readable + // stream with a hand-rolled boundary, that worked with axios but is brittle + // with undici's fetch (the request helper would JSON.stringify the stream). const formData = new FormData(); - formData.append('file', objects.map((obj) => JSON.stringify(obj)).join('\n'), 'import.ndjson'); + formData.append( + 'file', + new Blob([objects.map((obj) => JSON.stringify(obj)).join('\n')], { + type: 'application/ndjson', + }), + 'import.ndjson' + ); const query = options?.createNewCopies ? { createNewCopies: true } : { overwrite: true }; @@ -74,7 +83,6 @@ export class KbnClientImportExport { path: '/api/saved_objects/_import', query, body: formData, - headers: formData.getHeaders(), }); if (resp.data.success) { @@ -159,15 +167,13 @@ export class KbnClientImportExport { path: space ? uriencode`/s/${space}` + options.path : options.path, }); } catch (error) { - if (!isAxiosResponseError(error)) { - throw error; + // Translate KbnClientRequesterError into a "expected" CLI failure so the + // dev tooling doesn't dump a noisy stack trace for plain HTTP errors. The + // request URL and response body are already part of `error.message`. + if (error instanceof KbnClientRequesterError && error.status !== undefined) { + throw createFailError(error.message); } - - throw createFailError( - `${error.response.status} resp: ${inspect(error.response.data)}\nreq: ${inspect( - error.config - )}` - ); + throw error; } } } diff --git a/src/platform/packages/shared/kbn-kbn-client/src/kbn_client/kbn_client_requester.test.ts b/src/platform/packages/shared/kbn-kbn-client/src/kbn_client/kbn_client_requester.test.ts index 3c4b19ef550a1..4b821aa93fb8f 100644 --- a/src/platform/packages/shared/kbn-kbn-client/src/kbn_client/kbn_client_requester.test.ts +++ b/src/platform/packages/shared/kbn-kbn-client/src/kbn_client/kbn_client_requester.test.ts @@ -7,7 +7,9 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { pathWithSpace, redactUrl } from './kbn_client_requester'; +import { ToolingLog } from '@kbn/tooling-log'; +import { KbnClientRequester, pathWithSpace, redactUrl } from './kbn_client_requester'; +import { KbnClientRequesterError } from './kbn_client_requester_error'; describe('KBN Client Requester Functions', () => { it('pathWithSpace() adds a space to the path', () => { @@ -44,3 +46,148 @@ describe('KBN Client Requester Functions', () => { ); }); }); + +describe('KbnClientRequester.request()', () => { + const log = new ToolingLog(); + const fetchMock = jest.spyOn(global, 'fetch'); + + afterEach(() => { + jest.clearAllMocks(); + }); + + // Sibling FTR-test consumers read `error.status` on the caught error. Pin + // the contract so we notice if it regresses again. + it('throws KbnClientRequesterError with .status on non-2xx', async () => { + // `mockImplementation` rather than `mockResolvedValue` so each retry/call + // gets a fresh Response — fetch's Response body is single-consume. + fetchMock.mockImplementation( + async () => + new Response(JSON.stringify({ statusCode: 404, message: 'not found' }), { status: 404 }) + ); + + const requester = new KbnClientRequester(log, { url: 'http://localhost:5620' }); + + let caught: unknown; + try { + await requester.request({ method: 'GET', path: '/api/missing', retries: 0 }); + } catch (e) { + caught = e; + } + + expect(caught).toBeInstanceOf(KbnClientRequesterError); + expect((caught as KbnClientRequesterError).status).toBe(404); + // The thrown error has the rich message format that includes status text + + // response body (FTR error logs depend on this). + expect((caught as Error).message).toMatch(/404/); + }); + + // The fetch wrapper must NOT JSON.stringify FormData / streams, and must + // NOT override the caller's content-type when they pass one (otherwise + // multipart uploads hit `415 Unsupported Media Type`). + it('passes FormData bodies through without JSON serialization', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 })); + + const requester = new KbnClientRequester(log, { url: 'http://localhost:5620' }); + const form = new FormData(); + form.append('file', new Blob(['hello'], { type: 'text/plain' }), 'hello.txt'); + + await requester.request({ method: 'POST', path: '/api/upload', body: form, retries: 0 }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const init = fetchMock.mock.calls[0][1] as RequestInit; + expect(init.body).toBe(form); + // We never set `content-type: application/json` when the body is FormData; + // fetch fills in the multipart boundary automatically. + const headers = init.headers as Record; + expect(headers['content-type']).toBeUndefined(); + }); + + // FTR connector tests (http.ts / webhook.ts) call `kbnClient.resolveUrl()` + // and then `extractCredentialsFromUrl()` on the result. Strip credentials + // from the URL we pass to fetch but keep them on the public `resolveUrl()`. + it('resolveUrl() keeps user:pass credentials in the URL', () => { + const requester = new KbnClientRequester(log, { + url: 'http://elastic:changeme@localhost:5620', + }); + expect(requester.resolveUrl('/api/foo')).toBe('http://elastic:changeme@localhost:5620/api/foo'); + }); + + it('strips credentials from the URL passed to fetch and forwards them as Basic auth', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 })); + + const requester = new KbnClientRequester(log, { + url: 'http://elastic:changeme@localhost:5620', + }); + await requester.request({ method: 'GET', path: '/api/foo', retries: 0 }); + + const calledUrl = fetchMock.mock.calls[0][0] as string; + expect(calledUrl).not.toContain('elastic:changeme'); + const init = fetchMock.mock.calls[0][1] as RequestInit; + const headers = init.headers as Record; + // 'elastic:changeme' base64-encoded is 'ZWxhc3RpYzpjaGFuZ2VtZQ==' + expect(headers.Authorization).toBe('Basic ZWxhc3RpYzpjaGFuZ2VtZQ=='); + }); + + // 429s and other retry-after responses send the hint as a `Retry-After` HTTP header. + // Consumers (kbn-evals retry loop) need `.headers` on the thrown error to honor it. + it('attaches response headers to KbnClientRequesterError on non-2xx', async () => { + fetchMock.mockResolvedValue( + new Response('rate limited', { + status: 429, + headers: { 'retry-after': '42' }, + }) + ); + + const requester = new KbnClientRequester(log, { url: 'http://localhost:5620' }); + + let caught: unknown; + try { + await requester.request({ method: 'GET', path: '/api/foo', retries: 0 }); + } catch (e) { + caught = e; + } + + expect(caught).toBeInstanceOf(KbnClientRequesterError); + expect((caught as KbnClientRequesterError).status).toBe(429); + expect((caught as KbnClientRequesterError).headers?.get('retry-after')).toBe('42'); + }); + + // `ignoreErrors: [404]` is heavily used by FTR API services (e.g. + // Scout's data_views.get) that destructure `.data` from the response. + // axios returned the body even on the ignored status; preserve that so + // `response.data.foo` access on a 404 doesn't throw. + it('returns parsed body on a non-2xx status listed in ignoreErrors', async () => { + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ message: 'not found', data_view: null }), { status: 404 }) + ); + + const requester = new KbnClientRequester(log, { url: 'http://localhost:5620' }); + const result = await requester.request<{ message: string; data_view: unknown }>({ + method: 'GET', + path: '/api/data_views/data_view/missing', + ignoreErrors: [404], + retries: 0, + }); + + expect(result.status).toBe(404); + expect(result.data.message).toBe('not found'); + expect(result.data.data_view).toBeNull(); + }); + + it('JSON-encodes plain object bodies and sets content-type', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 })); + + const requester = new KbnClientRequester(log, { url: 'http://localhost:5620' }); + await requester.request({ + method: 'POST', + path: '/api/save', + body: { hello: 'world' }, + retries: 0, + }); + + const init = fetchMock.mock.calls[0][1] as RequestInit; + expect(init.body).toBe(JSON.stringify({ hello: 'world' })); + const headers = init.headers as Record; + expect(headers['content-type']).toBe('application/json'); + }); +}); diff --git a/src/platform/packages/shared/kbn-kbn-client/src/kbn_client/kbn_client_requester.ts b/src/platform/packages/shared/kbn-kbn-client/src/kbn_client/kbn_client_requester.ts index fbce63a942892..ababf70d4eeb6 100644 --- a/src/platform/packages/shared/kbn-kbn-client/src/kbn_client/kbn_client_requester.ts +++ b/src/platform/packages/shared/kbn-kbn-client/src/kbn_client/kbn_client_requester.ts @@ -8,23 +8,34 @@ */ import Url from 'url'; -import Https from 'https'; import Qs from 'querystring'; +import { Readable } from 'stream'; -import type { AxiosResponse, ResponseType } from 'axios'; -import Axios from 'axios'; -import { isAxiosRequestError, isAxiosResponseError } from '@kbn/dev-utils'; +import { Agent, type Dispatcher } from 'undici'; import type { ToolingLog } from '@kbn/tooling-log'; import { KbnClientRequesterError } from './kbn_client_requester_error'; -const isConcliftOnGetError = (error: any) => { - return ( - isAxiosResponseError(error) && error.config?.method === 'GET' && error.response.status === 409 - ); -}; +/** + * Type of the response body expected from the server, which determines how the body is parsed and returned. Mirrors the + * accepted `responseType` values from axios, but adapted to the native fetch API's parsing methods. + */ +export type ResponseType = 'arraybuffer' | 'blob' | 'document' | 'json' | 'text' | 'stream'; -const isIgnorableError = (error: any, ignorableErrors: number[] = []) => { - return isAxiosResponseError(error) && ignorableErrors.includes(error.response.status); +/** + * Envelope returned by `KbnClientRequester.request()`. Mirrors the subset of + * `AxiosResponse` that callers in this package consumed (`.data`, `.status`, + * `.headers`, `.statusText`) so existing call sites that destructure + * `const { data } = ...` keep working. + */ +export interface KbnClientResponse { + data: T; + status: number; + statusText: string; + headers: Headers; +} + +const isConflictOnGetError = (error: unknown, method: string) => { + return error instanceof KbnClientRequesterError && error.status === 409 && method === 'GET'; }; /** @@ -84,81 +95,200 @@ const delay = (ms: number) => setTimeout(resolve, ms); }); +/** + * Whether `body` should be JSON.stringify'd before being sent. Returns `false` for body types fetch + * knows how to serialize itself (strings, buffers, Node/Web streams, FormData, Blob, URLSearchParams). + */ +const isJsonShapedBody = (body: unknown): boolean => { + return !( + body === null || + typeof body !== 'object' || + body instanceof ArrayBuffer || + ArrayBuffer.isView(body) || + body instanceof FormData || + body instanceof Blob || + body instanceof URLSearchParams || + body instanceof Readable || + body instanceof ReadableStream + ); +}; + interface Options { url: string; certificateAuthorities?: Buffer[]; } export class KbnClientRequester { + // `url` retains any `user:pass@` from the original config - `resolveUrl()` is + // a public API used by FTR tests (e.g. http connector tests) that pluck + // credentials out of it. The stripped version below is only for fetch(). private readonly url: string; - private readonly httpsAgent: Https.Agent | null; + private readonly urlForFetch: string; + private readonly authorization?: string; + private readonly dispatcher: Dispatcher | null; constructor(private readonly log: ToolingLog, options: Options) { this.url = options.url; - this.httpsAgent = - Url.parse(options.url).protocol === 'https:' - ? new Https.Agent({ - ca: options.certificateAuthorities, - rejectUnauthorized: false, - }) + + // Unlike high-level HTTP clients such as axios, the native fetch rejects URLs that carry + // `user:pass@host` credentials, so we strip them here and translate to a Basic auth header + // that's applied to every outgoing request. + const parsed = new URL(options.url); + if (parsed.username || parsed.password) { + this.authorization = `Basic ${Buffer.from( + `${decodeURIComponent(parsed.username)}:${decodeURIComponent(parsed.password)}` + ).toString('base64')}`; + parsed.username = ''; + parsed.password = ''; + } + this.urlForFetch = parsed.toString(); + + this.dispatcher = + parsed.protocol === 'https:' + ? new Agent({ connect: { ca: options.certificateAuthorities, rejectUnauthorized: false } }) : null; } - private pickUrl() { - return this.url; + public resolveUrl(relativeUrl = '/') { + return this.resolveUrlInternal(this.url, relativeUrl); } - public resolveUrl(relativeUrl: string = '/') { - let baseUrl = this.pickUrl(); - if (!baseUrl.endsWith('/')) { - baseUrl += '/'; - } - const relative = relativeUrl.startsWith('/') ? relativeUrl.slice(1) : relativeUrl; - return Url.resolve(baseUrl, relative); + private resolveUrlInternal(baseUrl: string, relativeUrl = '/') { + return Url.resolve( + baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`, + relativeUrl.startsWith('/') ? relativeUrl.slice(1) : relativeUrl + ); } - async request(options: ReqOptions): Promise> { - const url = this.resolveUrl(options.path); - const redacted = redactUrl(url); + async request(options: ReqOptions): Promise> { + const url = this.resolveUrlInternal(this.urlForFetch, options.path); + const queryString = options.query ? `?${Qs.stringify(options.query)}` : ''; + const fullUrl = url + queryString; let attempt = 0; const maxAttempts = options.retries ?? DEFAULT_MAX_ATTEMPTS; const msgOrThrow = errMsg({ - redacted, + redacted: url, maxAttempts, requestedRetries: options.retries !== undefined, - failedToGetResponseSvc: (error: Error) => isAxiosRequestError(error), + // Network-level errors (DNS, connection refused, etc.) surface as TypeError from fetch. + failedToGetResponseSvc: (error: Error) => error instanceof TypeError, ...options, }); while (true) { attempt += 1; try { - this.log.debug(`Requesting url (redacted): [${redacted}]`); - return await Axios.request(buildRequest(url, this.httpsAgent, options)); + this.log.debug(`Requesting url (redacted): [${url}]`); + + // Unlike high-level HTTP clients such as axios, the native fetch doesn't pick a + // serialization strategy automatically, and we should do this manually: plain objects + // will become JSON, `form-data` streams will go through as multipart with the right + // boundary, strings will stay as is. + const isJsonBody = isJsonShapedBody(options.body); + const hasExplicitContentType = + options.headers !== undefined && + Object.keys(options.headers).some((k) => k.toLowerCase() === 'content-type'); + + const headers = { + ...(this.authorization ? { Authorization: this.authorization } : {}), + ...options.headers, + 'kbn-xsrf': 'kbn-client', + 'x-elastic-internal-origin': 'kbn-client', + ...(isJsonBody && !hasExplicitContentType ? { 'content-type': 'application/json' } : {}), + }; + + const body = + options.body === undefined + ? undefined + : isJsonBody + ? JSON.stringify(options.body) + : (options.body as BodyInit); + + const response = await fetch(fullUrl, { + method: options.method, + headers, + body, + signal: options.signal, + ...(this.dispatcher ? { dispatcher: this.dispatcher } : {}), + } as RequestInit); + + if (!response.ok) { + if (options.ignoreErrors?.includes(response.status)) { + // Caller asked us to silently swallow this status (e.g. 404 on delete). Preserve the + // current contract so callers that destructure `.data` keep working. + return { + data: await readBody(response, options.responseType), + status: response.status, + statusText: response.statusText, + headers: response.headers, + }; + } + + throw new KbnClientRequesterError( + `[${options.method} ${url}] ${response.status} ${ + response.statusText + } -- ${await response.text()}`, + { status: response.status, headers: response.headers } + ); + } + + return { + data: await readBody(response, options.responseType), + status: response.status, + statusText: response.statusText, + headers: response.headers, + }; } catch (error) { - const statusCode = isAxiosResponseError(error) ? error.response.status : 'N/A'; - const errorCause = error.code || error.message || 'Unknown error'; - const responseBody = isAxiosResponseError(error) - ? JSON.stringify(error.response.data, null, 2) - : 'No response body'; - const errorDetails = `Status: ${statusCode}, Cause: ${errorCause}, Response: ${responseBody}`; + const errorStatus = + error instanceof KbnClientRequesterError && error.status !== undefined + ? error.status + : 'N/A'; + const errorCause = + (error as { code?: string }).code || (error as Error).message || 'Unknown error'; + const errorDetails = `Status: ${errorStatus}, Cause: ${errorCause}`; this.log.debug(`Request failed - ${errorDetails}, Attempt: ${attempt}/${maxAttempts}`); - if (isIgnorableError(error, options.ignoreErrors)) return error.response; if (attempt < maxAttempts) { await delay(1000 * attempt); continue; } + throw new KbnClientRequesterError( `${msgOrThrow(attempt, error)} -- ${errorDetails} -- and ran out of retries`, - error + { + status: error instanceof KbnClientRequesterError ? error.status : undefined, + headers: error instanceof KbnClientRequesterError ? error.headers : undefined, + cause: error, + } ); } } } } +async function readBody(response: Response, responseType: ResponseType | undefined): Promise { + switch (responseType) { + case 'text': + return (await response.text()) as unknown as T; + case 'arraybuffer': + return (await response.arrayBuffer()) as unknown as T; + case 'blob': + return (await response.blob()) as unknown as T; + case 'stream': + return response.body as unknown as T; + default: + // Default 'json' (and undefined), matches axios's auto-JSON-parse, but tolerate empty bodies + // as some endpoints return 200 with no content. + const text = await response.text(); + try { + return JSON.parse(text) as T; + } catch { + return text as unknown as T; + } + } +} + export function errMsg({ redacted, requestedRetries, @@ -174,7 +304,7 @@ export function errMsg({ failedToGetResponseSvc: (x: Error) => boolean; }) { return function errMsgOrReThrow(attempt: number, _: any) { - const result = isConcliftOnGetError(_) + const result = isConflictOnGetError(_, method) ? `Conflict on GET (path=${path}, attempt=${attempt}/${maxAttempts})` : requestedRetries || failedToGetResponseSvc(_) ? `[${ @@ -190,28 +320,3 @@ export function redactUrl(_: string): string { const url = new URL(_); return url.password ? `${url.protocol}//${url.host}${url.pathname}` : _; } - -export function buildRequest( - url: any, - httpsAgent: Https.Agent | null, - { method, body, query, headers, responseType }: any -) { - return { - method, - url, - data: body, - params: query, - headers: { - ...headers, - 'kbn-xsrf': 'kbn-client', - 'x-elastic-internal-origin': 'kbn-client', - }, - httpsAgent, - responseType, - // work around https://github.com/axios/axios/issues/2791 - transformResponse: responseType === 'text' ? [(x: any) => x] : undefined, - maxContentLength: 30000000, - maxBodyLength: 30000000, - paramsSerializer: (params: any) => Qs.stringify(params), - }; -} diff --git a/src/platform/packages/shared/kbn-kbn-client/src/kbn_client/kbn_client_requester_error.test.ts b/src/platform/packages/shared/kbn-kbn-client/src/kbn_client/kbn_client_requester_error.test.ts index b7222195c8651..0f4ac61acdba3 100644 --- a/src/platform/packages/shared/kbn-kbn-client/src/kbn_client/kbn_client_requester_error.test.ts +++ b/src/platform/packages/shared/kbn-kbn-client/src/kbn_client/kbn_client_requester_error.test.ts @@ -7,23 +7,24 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { AxiosError } from 'axios'; import { KbnClientRequesterError } from './kbn_client_requester_error'; describe('KbnClientRequesterError', () => { - it('preserves status when cleaning axios errors (even after stripping response)', () => { - const original = new AxiosError('Not Found', 'ERR_BAD_REQUEST', undefined, undefined, { - status: 404, - statusText: 'Not Found', - headers: {}, - config: {} as any, - data: { statusCode: 404 }, - }); + it('exposes status and cause directly', () => { + const cause = new Error('Not Found'); + const wrapped = new KbnClientRequesterError('wrapper message', { status: 404, cause }); - const wrapped = new KbnClientRequesterError('wrapper message', original); + expect(wrapped.status).toBe(404); + expect(wrapped.cause).toBe(cause); + expect(wrapped.name).toBe('KbnClientRequesterError'); + expect(wrapped.message).toBe('wrapper message'); + }); + + it('works without status or cause', () => { + const wrapped = new KbnClientRequesterError('plain message'); - expect(wrapped.axiosError).toBeDefined(); - expect(wrapped.axiosError!.status).toBe(404); - expect((wrapped.axiosError as any).response).toBeUndefined(); + expect(wrapped.status).toBeUndefined(); + expect(wrapped.cause).toBeUndefined(); + expect(wrapped.message).toBe('plain message'); }); }); diff --git a/src/platform/packages/shared/kbn-kbn-client/src/kbn_client/kbn_client_requester_error.ts b/src/platform/packages/shared/kbn-kbn-client/src/kbn_client/kbn_client_requester_error.ts index e4fe348c4588c..5a5a8b7914b7a 100644 --- a/src/platform/packages/shared/kbn-kbn-client/src/kbn_client/kbn_client_requester_error.ts +++ b/src/platform/packages/shared/kbn-kbn-client/src/kbn_client/kbn_client_requester_error.ts @@ -7,27 +7,19 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { AxiosError, isAxiosError } from 'axios'; - +/** + * Error thrown by `KbnClientRequester` when an HTTP request fails after retries. + * Exposes `.status` directly so callers can branch on the HTTP code (e.g. treat + * 404 as "not found"), and `.headers` so callers can read response headers like + * `Retry-After` for backoff. The underlying error is attached via `Error.cause`. + */ export class KbnClientRequesterError extends Error { - axiosError?: AxiosError; - constructor(message: string, error: unknown) { - super(message); + status?: number; + headers?: Headers; + constructor(message: string, options?: { status?: number; headers?: Headers; cause?: unknown }) { + super(message, options?.cause !== undefined ? { cause: options.cause } : undefined); this.name = 'KbnClientRequesterError'; - if (isAxiosError(error)) this.axiosError = clean(error); - } -} -function clean(error: AxiosError): AxiosError { - const originalStatus = error.status ?? error.response?.status; - const _ = AxiosError.from(error); - // We strip `response` to avoid keeping large bodies around, but some callers - // depend on `status` to branch (e.g. treating 404 as "not found"). - if (_.status == null && originalStatus != null) { - _.status = originalStatus; + this.status = options?.status; + this.headers = options?.headers; } - delete _.cause; - delete _.config; - delete _.request; - delete _.response; - return _; } diff --git a/src/platform/packages/shared/kbn-kbn-client/tsconfig.json b/src/platform/packages/shared/kbn-kbn-client/tsconfig.json index 6fa2cb0fee686..a1320bf3bdbe3 100644 --- a/src/platform/packages/shared/kbn-kbn-client/tsconfig.json +++ b/src/platform/packages/shared/kbn-kbn-client/tsconfig.json @@ -16,7 +16,6 @@ "kbn_references": [ "@kbn/core-saved-objects-api-server", "@kbn/dev-cli-errors", - "@kbn/dev-utils", "@kbn/repo-info", "@kbn/tooling-log" ] diff --git a/src/platform/packages/shared/kbn-test-saml-auth/src/fetch_kibana_version.test.ts b/src/platform/packages/shared/kbn-test-saml-auth/src/fetch_kibana_version.test.ts index 8a6775d9a72bf..0cb1bbfa5352b 100644 --- a/src/platform/packages/shared/kbn-test-saml-auth/src/fetch_kibana_version.test.ts +++ b/src/platform/packages/shared/kbn-test-saml-auth/src/fetch_kibana_version.test.ts @@ -7,12 +7,10 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import axios from 'axios'; import { ToolingLog } from '@kbn/tooling-log'; import { fetchKibanaVersionHeaderString } from './fetch_kibana_version'; -jest.mock('axios'); -const mockedAxios = axios as jest.Mocked; +const mockedFetch = jest.spyOn(global, 'fetch'); describe('fetchKibanaVersionHeaderString', () => { const log = new ToolingLog(); @@ -22,11 +20,11 @@ describe('fetchKibanaVersionHeaderString', () => { }); test('returns version.number and appends -SNAPSHOT when build_snapshot is true', async () => { - mockedAxios.request.mockResolvedValue({ - data: { - version: { number: '9.0.0', build_snapshot: true }, - }, - }); + mockedFetch.mockResolvedValue( + new Response(JSON.stringify({ version: { number: '9.0.0', build_snapshot: true } }), { + status: 200, + }) + ); const v = await fetchKibanaVersionHeaderString( 'https://localhost:5601', @@ -36,36 +34,33 @@ describe('fetchKibanaVersionHeaderString', () => { ); expect(v).toBe('9.0.0-SNAPSHOT'); - expect(mockedAxios.request).toHaveBeenCalledTimes(1); - expect(mockedAxios.request).toHaveBeenCalledWith( - expect.objectContaining({ - method: 'GET', - auth: { username: 'elastic', password: 'changeme' }, - validateStatus: expect.any(Function), - }) - ); - const callUrl = mockedAxios.request.mock.calls[0][0].url as string; - expect(callUrl).toContain('/api/status'); - expect(callUrl).toContain('v8format=true'); + expect(mockedFetch).toHaveBeenCalledTimes(1); + const [callUrl, callInit] = mockedFetch.mock.calls[0]; + expect(callInit?.method).toBe('GET'); + const expectedAuth = `Basic ${Buffer.from('elastic:changeme').toString('base64')}`; + expect((callInit?.headers as Record).Authorization).toBe(expectedAuth); + const callUrlString = callUrl instanceof URL ? callUrl.toString() : String(callUrl); + expect(callUrlString).toContain('/api/status'); + expect(callUrlString).toContain('v8format=true'); }); test('throws when version is missing from response body', async () => { - mockedAxios.request.mockResolvedValue({ data: {} }); + mockedFetch.mockResolvedValue(new Response('{}', { status: 200 })); await expect( fetchKibanaVersionHeaderString('http://localhost:5601', 'u', 'p', log) ).rejects.toThrow(/Unable to get version from Kibana/); - expect(mockedAxios.request).toHaveBeenCalledTimes(1); + expect(mockedFetch).toHaveBeenCalledTimes(1); }); - test('propagates axios errors after a single attempt', async () => { - mockedAxios.request.mockRejectedValue(new Error('network down')); + test('propagates fetch errors after a single attempt', async () => { + mockedFetch.mockRejectedValue(new Error('network down')); await expect( fetchKibanaVersionHeaderString('http://localhost:5601', 'u', 'p', log) ).rejects.toThrow('network down'); - expect(mockedAxios.request).toHaveBeenCalledTimes(1); + expect(mockedFetch).toHaveBeenCalledTimes(1); }); }); diff --git a/src/platform/packages/shared/kbn-test-saml-auth/src/fetch_kibana_version.ts b/src/platform/packages/shared/kbn-test-saml-auth/src/fetch_kibana_version.ts index 8898826da419a..22415dcfc8ecc 100644 --- a/src/platform/packages/shared/kbn-test-saml-auth/src/fetch_kibana_version.ts +++ b/src/platform/packages/shared/kbn-test-saml-auth/src/fetch_kibana_version.ts @@ -7,8 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import https from 'https'; -import axios from 'axios'; +import { Agent } from 'undici'; import type { ToolingLog } from '@kbn/tooling-log'; interface KibanaStatusResponse { @@ -33,27 +32,26 @@ export async function fetchKibanaVersionHeaderString( url.searchParams.set('v8format', 'true'); const isHttps = url.protocol === 'https:'; - const httpsAgent = isHttps - ? new https.Agent({ - rejectUnauthorized: false, - }) - : undefined; + const dispatcher = isHttps ? new Agent({ connect: { rejectUnauthorized: false } }) : undefined; log.debug(`Fetching Kibana version from ${url.origin}/api/status`); - const response = await axios.request({ + const response = await fetch(url, { method: 'GET', - url: url.toString(), - auth: { username, password }, headers: { + Authorization: `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`, 'kbn-xsrf': 'kbn-client', 'x-elastic-internal-origin': 'kbn-client', }, - httpsAgent, - validateStatus: (status: number) => status === 200 || status === 503, - }); + ...(dispatcher ? { dispatcher } : {}), + } as RequestInit); - const data = response.data; + // 200 (running) and 503 (initializing) both expose `version`, any other status is unexpected. + if (response.status !== 200 && response.status !== 503) { + throw new Error(`${response.status}:${await response.text()}`); + } + + const data = (await response.json()) as KibanaStatusResponse; if (!data?.version) { throw new Error( `Unable to get version from Kibana, invalid response from server: ${JSON.stringify(data)}` diff --git a/src/platform/packages/shared/kbn-test-saml-auth/src/saml_auth.test.ts b/src/platform/packages/shared/kbn-test-saml-auth/src/saml_auth.test.ts index b2b3abdb6d214..4a3e2b90d820c 100644 --- a/src/platform/packages/shared/kbn-test-saml-auth/src/saml_auth.test.ts +++ b/src/platform/packages/shared/kbn-test-saml-auth/src/saml_auth.test.ts @@ -8,9 +8,7 @@ */ import { ToolingLog } from '@kbn/tooling-log'; -import axios from 'axios'; -jest.mock('axios'); import { createCloudSession, createSAMLRequest, @@ -18,13 +16,24 @@ import { finishSAMLHandshake, } from './saml_auth'; -const axiosRequestMock = jest.spyOn(axios, 'request'); -const axiosGetMock = jest.spyOn(axios, 'get'); +const fetchMock = jest.spyOn(global, 'fetch'); jest.mock('timers/promises', () => ({ setTimeout: jest.fn(() => Promise.resolve()), })); +const jsonResponse = (data: unknown, status = 200) => + new Response(JSON.stringify(data), { status }); + +const responseWithSetCookie = (data: unknown, setCookies: string[], status: number) => + // jsdom's Headers polyfill doesn't preserve multiple set-cookie entries when set + // via the array-of-tuples form, so the helper joins them into a single header value. + // Tests in this file only ever set one cookie, so the join is equivalent. + new Response(JSON.stringify(data), { + status, + headers: { 'set-cookie': setCookies.join(', ') }, + }); + describe('saml_auth', () => { const log = new ToolingLog(); @@ -34,10 +43,7 @@ describe('saml_auth', () => { }); test('returns token value', async () => { - axiosRequestMock.mockResolvedValueOnce({ - data: { token: 'mocked_token' }, - status: 200, - }); + fetchMock.mockResolvedValueOnce(jsonResponse({ token: 'mocked_token' }, 200)); const sessionToken = await createCloudSession({ hostname: 'cloud', @@ -46,17 +52,14 @@ describe('saml_auth', () => { log, }); expect(sessionToken).toBe('mocked_token'); - expect(axiosRequestMock).toBeCalledTimes(1); + expect(fetchMock).toBeCalledTimes(1); }); test('retries until response has the token value', async () => { - axiosRequestMock - .mockResolvedValueOnce({ data: { message: 'no token' }, status: 503 }) - .mockResolvedValueOnce({ data: { message: 'no token' }, status: 503 }) - .mockResolvedValueOnce({ - data: { token: 'mocked_token' }, - status: 200, - }); + fetchMock + .mockResolvedValueOnce(jsonResponse({ message: 'no token' }, 503)) + .mockResolvedValueOnce(jsonResponse({ message: 'no token' }, 503)) + .mockResolvedValueOnce(jsonResponse({ token: 'mocked_token' }, 200)); const sessionToken = await createCloudSession( { @@ -72,11 +75,12 @@ describe('saml_auth', () => { ); expect(sessionToken).toBe('mocked_token'); - expect(axiosRequestMock).toBeCalledTimes(3); + expect(fetchMock).toBeCalledTimes(3); }); test('retries and throws error when response code is not 200', async () => { - axiosRequestMock.mockResolvedValue({ data: { message: 'no token' }, status: 503 }); + // Each retry consumes the body, so we need a fresh Response per call. + fetchMock.mockImplementation(async () => jsonResponse({ message: 'no token' }, 503)); await expect( createCloudSession( @@ -94,14 +98,14 @@ describe('saml_auth', () => { ).rejects.toThrow( `Failed to create the new cloud session: 'POST https://cloud/api/v1/saas/auth/_login' returned 503` ); - expect(axiosRequestMock).toBeCalledTimes(2); + expect(fetchMock).toBeCalledTimes(2); }); test('retries and throws error when response has no token value', async () => { - axiosRequestMock.mockResolvedValue({ - data: { user_id: 1234, okta_session_id: 5678, authenticated: false }, - status: 200, - }); + // Each retry consumes the body, so we need a fresh Response per call. + fetchMock.mockImplementation(async () => + jsonResponse({ user_id: 1234, okta_session_id: 5678, authenticated: false }, 200) + ); await expect( createCloudSession( @@ -119,11 +123,11 @@ describe('saml_auth', () => { ).rejects.toThrow( `Failed to create the new cloud session: token is missing in response data\n{"user_id":"REDACTED","okta_session_id":"REDACTED","authenticated":false}` ); - expect(axiosRequestMock).toBeCalledTimes(3); + expect(fetchMock).toBeCalledTimes(3); }); test(`throws error when retry 'attemptsCount' is below 1`, async () => { - axiosRequestMock.mockResolvedValue({ data: { message: 'no token' }, status: 503 }); + fetchMock.mockResolvedValue(jsonResponse({ message: 'no token' }, 503)); await expect( createCloudSession( @@ -144,10 +148,9 @@ describe('saml_auth', () => { }); test(`should fail without retry when response has 'mfa_required: true'`, async () => { - axiosRequestMock.mockResolvedValue({ - data: { user_id: 12345, authenticated: false, mfa_required: true }, - status: 200, - }); + fetchMock.mockResolvedValue( + jsonResponse({ user_id: 12345, authenticated: false, mfa_required: true }, 200) + ); await expect( createCloudSession( @@ -165,7 +168,7 @@ describe('saml_auth', () => { ).rejects.toThrow( 'Failed to create the new cloud session: MFA must be disabled for the test account' ); - expect(axiosRequestMock).toBeCalledTimes(1); + expect(fetchMock).toBeCalledTimes(1); }); }); @@ -175,14 +178,13 @@ describe('saml_auth', () => { }); test('returns { location, sid }', async () => { - axiosRequestMock.mockResolvedValue({ - data: { - location: 'https://cloud.test/saml?SAMLRequest=fVLLbtswEPwVgXe9K6%2F', - }, - headers: { - 'set-cookie': [`sid=Fe26.2**1234567890; Secure; HttpOnly; Path=/`], - }, - }); + fetchMock.mockResolvedValue( + responseWithSetCookie( + { location: 'https://cloud.test/saml?SAMLRequest=fVLLbtswEPwVgXe9K6%2F' }, + [`sid=Fe26.2**1234567890; Secure; HttpOnly; Path=/`], + 200 + ) + ); const response = await createSAMLRequest('https://kbn.test.co', '8.12.0', log); expect(response).toStrictEqual({ @@ -192,43 +194,36 @@ describe('saml_auth', () => { }); test(`throws error when response has no 'set-cookie' header`, async () => { - axiosRequestMock.mockResolvedValue({ - data: { - location: 'https://cloud.test/saml?SAMLRequest=fVLLbtswEPwVgXe9K6%2F', - }, - headers: {}, - }); + fetchMock.mockResolvedValue( + jsonResponse({ location: 'https://cloud.test/saml?SAMLRequest=fVLLbtswEPwVgXe9K6%2F' }, 200) + ); - expect(createSAMLRequest('https://kbn.test.co', '8.12.0', log)).rejects.toThrow( + await expect(createSAMLRequest('https://kbn.test.co', '8.12.0', log)).rejects.toThrow( /Failed to parse cookie from SAML response headers: no 'set-cookie' header, response.data:/ ); }); test('throws error when location is not a valid url', async () => { - axiosRequestMock.mockResolvedValue({ - data: { - location: 'http/.test', - }, - headers: { - 'set-cookie': [`sid=Fe26.2**1234567890; Secure; HttpOnly; Path=/`], - }, - }); + fetchMock.mockResolvedValue( + responseWithSetCookie( + { location: 'http/.test' }, + [`sid=Fe26.2**1234567890; Secure; HttpOnly; Path=/`], + 200 + ) + ); - expect(createSAMLRequest('https://kbn.test.co', '8.12.0', log)).rejects.toThrow( + await expect(createSAMLRequest('https://kbn.test.co', '8.12.0', log)).rejects.toThrow( `Location from Kibana SAML request is not a valid url: http/.test` ); }); test('throws error when response has no location', async () => { const data = { error: 'mocked error' }; - axiosRequestMock.mockResolvedValue({ - data, - headers: { - 'set-cookie': [`sid=Fe26.2**1234567890; Secure; HttpOnly; Path=/`], - }, - }); + fetchMock.mockResolvedValue( + responseWithSetCookie(data, [`sid=Fe26.2**1234567890; Secure; HttpOnly; Path=/`], 200) + ); - expect(createSAMLRequest('https://kbn.test.co', '8.12.0', log)).rejects.toThrow( + await expect(createSAMLRequest('https://kbn.test.co', '8.12.0', log)).rejects.toThrow( `Failed to get location from SAML response data: ${JSON.stringify(data)}` ); }); @@ -249,18 +244,24 @@ describe('saml_auth', () => { }; test('returns valid saml response', async () => { - axiosGetMock.mockResolvedValueOnce({ - data: `Test`, - }); + fetchMock.mockResolvedValueOnce( + new Response( + `Test`, + { status: 200 } + ) + ); const actualResponse = await createSAMLResponse(createSAMLResponseParams); expect(actualResponse).toBe('PD94bWluc2U+'); }); test('throws error when failed to parse SAML response value', async () => { - axiosGetMock.mockResolvedValueOnce({ - data: `Test`, - }); + fetchMock.mockResolvedValueOnce( + new Response( + `Test`, + { status: 200 } + ) + ); await expect(createSAMLResponse(createSAMLResponseParams)).rejects .toThrowError(`Failed to parse SAML response value.\nMost likely the 'viewer@elastic.co' user has no access to the cloud deployment. @@ -286,63 +287,57 @@ https://kbn.test.co in the same window.`); }); it('should return cookie on 302 response', async () => { - axiosRequestMock.mockResolvedValue({ - status: 302, - headers: { - 'set-cookie': [`sid=${cookieStr}; Secure; HttpOnly; Path=/`], - }, - }); + fetchMock.mockResolvedValue( + responseWithSetCookie({}, [`sid=${cookieStr}; Secure; HttpOnly; Path=/`], 302) + ); const response = await finishSAMLHandshake(params); expect(response.key).toEqual('sid'); expect(response.value).toEqual(cookieStr); - expect(axiosRequestMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(1); }); it('should throw an error on 4xx response without retrying', async () => { - axiosRequestMock.mockResolvedValue({ status: 401 }); + fetchMock.mockResolvedValue(new Response(null, { status: 401 })); await expect(finishSAMLHandshake(params)).rejects.toThrow( 'SAML callback failed: expected 302, got 401' ); - expect(axiosRequestMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(1); }); it('should retry on 5xx response and succeed on 302 response', async () => { - axiosRequestMock - .mockResolvedValueOnce({ status: 503 }) // First attempt fails (5xx), retrying - .mockResolvedValueOnce({ - status: 302, - headers: { - 'set-cookie': [`sid=${cookieStr}; Secure; HttpOnly; Path=/`], - }, - }); // Second attempt succeeds + fetchMock + .mockResolvedValueOnce(new Response(null, { status: 503 })) + .mockResolvedValueOnce( + responseWithSetCookie({}, [`sid=${cookieStr}; Secure; HttpOnly; Path=/`], 302) + ); const response = await finishSAMLHandshake(params); expect(response.key).toEqual('sid'); expect(response.value).toEqual(cookieStr); - expect(axiosRequestMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenCalledTimes(2); }); it('should retry on 5xx response and fail after max attempts', async () => { const attemptsCount = retryCount + 1; - axiosRequestMock.mockResolvedValue({ status: 503 }); + fetchMock.mockResolvedValue(new Response(null, { status: 503 })); await expect(finishSAMLHandshake(params)).rejects.toThrow( `Retry failed after ${attemptsCount} attempts: SAML callback failed: expected 302, got 503` ); - expect(axiosRequestMock).toHaveBeenCalledTimes(attemptsCount); + expect(fetchMock).toHaveBeenCalledTimes(attemptsCount); }); it('should stop retrying if a later response is 4xx', async () => { - axiosRequestMock - .mockResolvedValueOnce({ status: 503 }) // First attempt fails (5xx), retrying - .mockResolvedValueOnce({ status: 400 }); // Second attempt gets a 4xx (stop retrying) + fetchMock + .mockResolvedValueOnce(new Response(null, { status: 503 })) + .mockResolvedValueOnce(new Response(null, { status: 400 })); await expect(finishSAMLHandshake(params)).rejects.toThrow( 'SAML callback failed: expected 302, got 400' ); - expect(axiosRequestMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenCalledTimes(2); }); }); }); diff --git a/src/platform/packages/shared/kbn-test-saml-auth/src/saml_auth.ts b/src/platform/packages/shared/kbn-test-saml-auth/src/saml_auth.ts index 42f93f54ddcaa..e2fd2abf2a516 100644 --- a/src/platform/packages/shared/kbn-test-saml-auth/src/saml_auth.ts +++ b/src/platform/packages/shared/kbn-test-saml-auth/src/saml_auth.ts @@ -10,8 +10,6 @@ import { setTimeout as delay } from 'timers/promises'; import { createSAMLResponse as createMockedSAMLResponse } from '@kbn/mock-idp-utils'; import type { ToolingLog } from '@kbn/tooling-log'; -import type { AxiosResponse } from 'axios'; -import axios from 'axios'; import util from 'util'; import * as cheerio from 'cheerio'; import type { Cookie } from 'tough-cookie'; @@ -44,31 +42,19 @@ export class Session { const REQUEST_TIMEOUT_MS = 60_000; -const cleanException = (url: string, ex: any) => { - if (ex.isAxiosError) { - ex.url = url; - if (ex.response?.data) { - if (ex.response.data?.message) { - ex.response_message = ex.response.data.message; - } else { - ex.data = ex.response.data; - } - } - ex.config = { REDACTED: 'REDACTED' }; - ex.request = { REDACTED: 'REDACTED' }; - ex.response = { REDACTED: 'REDACTED' }; +const getCookieFromResponseHeaders = (headers: Headers, body: string, errorMessage: string) => { + // Headers.getSetCookie() is the Node 22 / undici way to read multi-valued + // set-cookie headers, jsdom's older polyfill exposes only Headers.get() which + // returns the first set-cookie value as a string, so we fall back to that. + const headersWithGetSetCookie = headers as Headers & { getSetCookie?: () => string[] }; + const setCookies = headersWithGetSetCookie.getSetCookie + ? headersWithGetSetCookie.getSetCookie() + : [headers.get('set-cookie')].filter((c): c is string => Boolean(c)); + if (setCookies.length === 0) { + throw new Error(`${errorMessage}: no 'set-cookie' header, response.data: ${body}`); } -}; -const getCookieFromResponseHeaders = (response: AxiosResponse, errorMessage: string) => { - const setCookieHeader = response?.headers['set-cookie']; - if (!setCookieHeader) { - throw new Error( - `${errorMessage}: no 'set-cookie' header, response.data: ${JSON.stringify(response?.data)}` - ); - } - - const cookie = parseCookie(setCookieHeader![0]); + const cookie = parseCookie(setCookies[0]); if (!cookie) { throw new Error(errorMessage); } @@ -93,68 +79,62 @@ export const createCloudSession = async ( ): Promise => { const { hostname, email, password, log } = params; const cloudLoginUrl = getCloudUrl(hostname, '/api/v1/saas/auth/_login'); - let sessionResponse: AxiosResponse | undefined; - const requestConfig = (cloudUrl: string) => { - return { - url: cloudUrl, - method: 'post', - timeout: REQUEST_TIMEOUT_MS, - data: { - email, - password, - }, - headers: { - accept: 'application/json', - 'content-type': 'application/json', - }, - validateStatus: () => true, - maxRedirects: 0, - }; - }; let attemptsLeft = retryParams.attemptsCount; while (attemptsLeft > 0) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); try { - sessionResponse = await axios.request(requestConfig(cloudLoginUrl)); - if (sessionResponse?.status !== 200) { + const sessionResponse = await fetch(cloudLoginUrl, { + method: 'POST', + headers: { + accept: 'application/json', + 'content-type': 'application/json', + }, + body: JSON.stringify({ email, password }), + redirect: 'manual', + signal: controller.signal, + }); + clearTimeout(timeoutId); + if (sessionResponse.status !== 200) { throw new Error( - `Failed to create the new cloud session: 'POST ${cloudLoginUrl}' returned ${sessionResponse?.status}` + `Failed to create the new cloud session: 'POST ${cloudLoginUrl}' returned ${sessionResponse.status}` ); - } else { - const token = sessionResponse?.data?.token as string; - if (token) { - return token; - } else { - const keysToRedact = ['user_id', 'okta_session_id']; - const data = sessionResponse?.data; - if (data !== null && typeof data === 'object') { - Object.keys(data).forEach((key) => { - if (keysToRedact.includes(key)) { - data[key] = 'REDACTED'; - } - }); - - log.error(`Error occurred, cloud login response: \n${JSON.stringify(data)}`); - - // MFA must be disabled for test accounts - if (data.mfa_required === true) { - // Changing MFA configuration requires manual action, skip retry - attemptsLeft = 0; - throw new Error( - `Failed to create the new cloud session: MFA must be disabled for the test account` - ); - } + } + + const data = (await sessionResponse.json()) as Record | null; + const token = data?.token as string | undefined; + if (token) { + return token; + } + + const keysToRedact = ['user_id', 'okta_session_id']; + if (data !== null && typeof data === 'object') { + Object.keys(data).forEach((key) => { + if (keysToRedact.includes(key)) { + data[key] = 'REDACTED'; } + }); + log.error(`Error occurred, cloud login response: \n${JSON.stringify(data)}`); + + // MFA must be disabled for test accounts + if (data.mfa_required === true) { + // Changing MFA configuration requires manual action, skip retry + attemptsLeft = 0; throw new Error( - `Failed to create the new cloud session: token is missing in response data\n${JSON.stringify( - data - )}` + `Failed to create the new cloud session: MFA must be disabled for the test account` ); } } + + throw new Error( + `Failed to create the new cloud session: token is missing in response data\n${JSON.stringify( + data + )}` + ); } catch (ex) { - cleanException(cloudLoginUrl, ex); + clearTimeout(timeoutId); if (--attemptsLeft > 0) { // log only error message log.error(`${ex.message}\nWaiting ${retryParams.attemptDelay} ms before the next attempt`); @@ -176,41 +156,43 @@ export const createCloudSession = async ( }; export const createSAMLRequest = async (kbnUrl: string, kbnVersion: string, log: ToolingLog) => { - let samlResponse: AxiosResponse; const url = kbnUrl + '/internal/security/login'; + let samlResponse: Response; try { - samlResponse = await axios.request({ - url, - method: 'post', - data: { - providerType: 'saml', - providerName: 'cloud-saml-kibana', - currentURL: kbnUrl + '/login?next=%2F"', - }, + samlResponse = await fetch(url, { + method: 'POST', headers: { 'kbn-version': kbnVersion, 'x-elastic-internal-origin': 'Kibana', 'content-type': 'application/json', }, - validateStatus: () => true, - maxRedirects: 0, + body: JSON.stringify({ + providerType: 'saml', + providerName: 'cloud-saml-kibana', + currentURL: kbnUrl + '/login?next=%2F"', + }), + redirect: 'manual', }); } catch (ex) { log.error('Failed to create SAML request'); - cleanException(url, ex); throw ex; } + const bodyText = await samlResponse.text(); const cookie = getCookieFromResponseHeaders( - samlResponse, + samlResponse.headers, + bodyText, 'Failed to parse cookie from SAML response headers' ); - const location = samlResponse?.data?.location as string; + let location: string | undefined; + try { + location = (JSON.parse(bodyText) as { location?: string }).location; + } catch { + // body is not JSON, location stays undefined + } if (!location) { - throw new Error( - `Failed to get location from SAML response data: ${JSON.stringify(samlResponse.data)}` - ); + throw new Error(`Failed to get location from SAML response data: ${bodyText}`); } if (!isValidUrl(location)) { throw new Error(`Location from Kibana SAML request is not a valid url: ${location}`); @@ -220,33 +202,33 @@ export const createSAMLRequest = async (kbnUrl: string, kbnVersion: string, log: export const createSAMLResponse = async (params: SAMLResponseValueParams) => { const { location, ecSession, email, kbnHost, log } = params; - let samlResponse: AxiosResponse; let value: string | undefined; try { - samlResponse = await axios.get(location, { + const samlResponse = await fetch(location, { headers: { Cookie: `ec_session=${ecSession}`, }, - maxRedirects: 0, + redirect: 'manual', }); - const $ = cheerio.load(samlResponse.data); - value = $('input').attr('value'); - } catch (err) { - if (err.isAxiosError) { - const requestId = err?.response?.headers?.['x-request-id'] || 'not found'; - const responseStatus = err?.response?.status; - let logMessage = `Create SAML Response (${location}) failed with status code ${responseStatus}: ${err?.response?.data}`; - - // If response is 3XX, also log the Location header from response - if (responseStatus >= 300 && responseStatus < 400) { - const locationHeader = err?.response?.headers?.location || 'not found'; - logMessage += `.\nLocation: ${locationHeader}`; - } - - logMessage += `.\nX-Request-ID: ${requestId}`; - - log.error(logMessage); + const responseStatus = samlResponse.status; + const requestId = samlResponse.headers.get('x-request-id') || 'not found'; + + if (responseStatus >= 300 && responseStatus < 400) { + const locationHeader = samlResponse.headers.get('location') || 'not found'; + log.error( + `Create SAML Response (${location}) failed with status code ${responseStatus}: ${await samlResponse.text()}.\nLocation: ${locationHeader}.\nX-Request-ID: ${requestId}` + ); + } else if (!samlResponse.ok) { + log.error( + `Create SAML Response (${location}) failed with status code ${responseStatus}: ${await samlResponse.text()}.\nX-Request-ID: ${requestId}` + ); + } else { + value = cheerio + .load(await samlResponse.text())('input') + .attr('value'); } + } catch (err) { + log.error(`Create SAML Response (${location}) failed: ${err.message}`); } if (!value) { @@ -270,27 +252,25 @@ export const finishSAMLHandshake = async ({ }: SAMLCallbackParams) => { const encodedResponse = encodeURIComponent(samlResponse); const url = kbnHost + '/api/security/saml/callback'; - const request = { - url, - method: 'post', - data: `SAMLResponse=${encodedResponse}`, + const requestInit: RequestInit = { + method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', ...(sid ? { Cookie: `sid=${sid}` } : {}), }, - validateStatus: () => true, - maxRedirects: 0, + body: `SAMLResponse=${encodedResponse}`, + redirect: 'manual', }; - let authResponse: AxiosResponse; let attemptsLeft = maxRetryCount + 1; while (attemptsLeft > 0) { try { - authResponse = await axios.request(request); + const authResponse = await fetch(url, requestInit); // SAML callback should return 302 if (authResponse.status === 302) { return getCookieFromResponseHeaders( - authResponse, + authResponse.headers, + await authResponse.text(), 'Failed to get cookie from SAML callback response' ); } @@ -301,7 +281,6 @@ export const finishSAMLHandshake = async ({ }, }); } catch (ex) { - cleanException(kbnHost, ex); // retry for 5xx errors if (ex?.cause?.status >= 500) { if (--attemptsLeft > 0) { @@ -316,7 +295,7 @@ export const finishSAMLHandshake = async ({ } else { // exit for non 5xx errors // Logging the `Cookie: sid=xxxx` header is safe here since it’s an intermediate, non-authenticated cookie that cannot be reused if leaked. - log.error(`Request sent: ${util.inspect(request)}`); + log.error(`Request sent: ${util.inspect({ url, ...requestInit })}`); throw ex; } } @@ -335,23 +314,26 @@ export const getSecurityProfile = async ({ cookie: Cookie; log: ToolingLog; }) => { - let meResponse: AxiosResponse; const url = kbnHost + '/internal/security/me'; + let meResponse: Response; try { - meResponse = (await axios.get(url, { + meResponse = await fetch(url, { headers: { Cookie: cookie.cookieString(), 'x-elastic-internal-origin': 'Kibana', 'content-type': 'application/json', }, - })) as AxiosResponse; + }); } catch (ex) { log.error('Failed to fetch user profile data'); - cleanException(url, ex); throw ex; } - return meResponse.data; + if (!meResponse.ok) { + throw new Error(`${meResponse.status}:${await meResponse.text()}`); + } + + return (await meResponse.json()) as UserProfile; }; export const createCloudSAMLSession = async (params: CloudSamlSessionParams) => { diff --git a/src/platform/test/api_integration/apis/telemetry/opt_in.ts b/src/platform/test/api_integration/apis/telemetry/opt_in.ts index 8f86db3bd42ef..935c83a7d5442 100644 --- a/src/platform/test/api_integration/apis/telemetry/opt_in.ts +++ b/src/platform/test/api_integration/apis/telemetry/opt_in.ts @@ -126,7 +126,7 @@ async function getSavedObjectAttributes( return body.attributes; } catch (err) { - if (err.response?.status === 404) { + if (err.status === 404) { return {}; } throw err; diff --git a/src/platform/test/api_integration/apis/telemetry/telemetry_config.ts b/src/platform/test/api_integration/apis/telemetry/telemetry_config.ts index 96c2d036e4ea4..4fcf3bc79046a 100644 --- a/src/platform/test/api_integration/apis/telemetry/telemetry_config.ts +++ b/src/platform/test/api_integration/apis/telemetry/telemetry_config.ts @@ -7,7 +7,6 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { AxiosError } from 'axios'; import { ELASTIC_HTTP_VERSION_HEADER, X_ELASTIC_INTERNAL_ORIGIN_REQUEST, @@ -29,8 +28,7 @@ export default function telemetryConfigTest({ getService }: FtrProviderContext) try { await kbnClient.savedObjects.delete({ type: TELEMETRY_SO_TYPE, id: TELEMETRY_SO_ID }); } catch (err) { - const is404Error = err instanceof AxiosError && err.response?.status === 404; - if (!is404Error) { + if (err.status !== 404) { throw err; } } diff --git a/x-pack/platform/packages/shared/kbn-evals/src/utils/http_handler_from_kbn_client.ts b/x-pack/platform/packages/shared/kbn-evals/src/utils/http_handler_from_kbn_client.ts index ac24eeff9e325..3f606b8a20cd6 100644 --- a/x-pack/platform/packages/shared/kbn-evals/src/utils/http_handler_from_kbn_client.ts +++ b/x-pack/platform/packages/shared/kbn-evals/src/utils/http_handler_from_kbn_client.ts @@ -5,8 +5,7 @@ * 2.0. */ import type { HttpFetchOptions, HttpFetchOptionsWithPath, HttpHandler } from '@kbn/core/public'; -import type { KbnClient } from '@kbn/kbn-client'; -import { KbnClientRequesterError } from '@kbn/kbn-client'; +import type { KbnClient, KbnClientRequesterError } from '@kbn/kbn-client'; import type { ToolingLog } from '@kbn/tooling-log'; // redefine args type to make it easier to handle in a type-safe way @@ -59,29 +58,20 @@ export function httpHandlerFromKbnClient({ await new Promise((r) => setTimeout(r, ms)); } - function toErrorMessage(err: unknown): string { - if (err instanceof Error) return err.message; - try { - return JSON.stringify(err); - } catch { - return String(err); - } - } - function parseRetryAfterMsFromHeaders( - responseHeaders: Record | undefined + responseHeaders: Headers | undefined ): number | undefined { - if (!responseHeaders) return undefined; - const key = Object.keys(responseHeaders).find((k) => k.toLowerCase() === 'retry-after'); - const value = key ? responseHeaders[key] : undefined; - if (typeof value === 'string') { - const seconds = Number.parseInt(value, 10); - if (Number.isFinite(seconds) && seconds > 0) return seconds * 1000; + const value = responseHeaders?.get('retry-after'); + if (!value) { + return undefined; } - if (typeof value === 'number' && Number.isFinite(value) && value > 0) { - return value * 1000; + + const seconds = Number.parseInt(value, 10); + if (!Number.isFinite(seconds) || seconds <= 0) { + return undefined; } - return undefined; + + return seconds * 1000; } function parseRetryAfterMsFromMessage(message: string): number | undefined { @@ -108,48 +98,47 @@ export function httpHandlerFromKbnClient({ retries: 0, }); // success - const undiciHeaders = new Headers(); - for (const [key, value] of Object.entries(response.headers)) { - if (Array.isArray(value)) { - for (const v of value) undiciHeaders.append(key, v); - } else if (value != null) { - undiciHeaders.set(key, value); - } + if (asResponse) { + // `HttpResponse.request` is required by Core's type. We don't have access to undici's + // underlying outgoing Request, so reconstruct an equivalent stub from the inputs. Strip + // user:pass from the URL because `new Request(...)` rejects URLs with embedded credentials + // (same WHATWG parsing as fetch). + const requestUrl = new URL(kbnClient.resolveUrl(options.path)); + requestUrl.username = ''; + requestUrl.password = ''; + + return { + fetchOptions: options, + request: new Request(requestUrl, { + method, + headers: finalHeaders, + signal: signal || undefined, + }), + body: undefined, + response: new Response(response.data as BodyInit, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }), + }; } - - return asResponse - ? { - fetchOptions: options, - request: response.request!, - body: undefined, - response: new Response(response.data as BodyInit, { - status: response.status, - statusText: response.statusText, - headers: undiciHeaders, - }), - } - : (response.data as any); + return response.data as any; } catch (err) { - // Keep the richest error message possible. - const maybeKbn = err instanceof KbnClientRequesterError ? err.axiosError ?? err : err; - if (err instanceof KbnClientRequesterError && err.axiosError) { - err.axiosError.message = err.message; - } - - const status = (maybeKbn as any)?.status; + // `kbnClient.request` only ever throws `KbnClientRequesterError`. + const error = err as KbnClientRequesterError; + const status = error.status; const shouldRetry = attempt < maxRetries && typeof status === 'number' && retryStatuses.has(status); - lastError = maybeKbn; + lastError = error; if (!shouldRetry) { - throw maybeKbn; + throw error; } - const message = toErrorMessage(maybeKbn); - const responseHeaders = (maybeKbn as any)?.response?.headers ?? (maybeKbn as any)?.headers; const retryAfterMs = - parseRetryAfterMsFromHeaders(responseHeaders) ?? parseRetryAfterMsFromMessage(message); + parseRetryAfterMsFromHeaders(error.headers) ?? + parseRetryAfterMsFromMessage(error.message); // Exponential backoff (1s, 2s, 4s, ...) with jitter, but never sooner than retry-after. const baseBackoffMs = 1000 * Math.pow(2, attempt); diff --git a/x-pack/platform/test/api_integration/services/spaces.ts b/x-pack/platform/test/api_integration/services/spaces.ts index 9be1d8f411010..2e247018f6c39 100644 --- a/x-pack/platform/test/api_integration/services/spaces.ts +++ b/x-pack/platform/test/api_integration/services/spaces.ts @@ -6,8 +6,7 @@ */ import type { Space } from '@kbn/spaces-plugin/common'; -import Axios from 'axios'; -import Https from 'https'; +import { Agent, type Dispatcher } from 'undici'; import { format as formatUrl } from 'url'; import util from 'util'; import Chance from 'chance'; @@ -30,36 +29,76 @@ export function SpacesServiceProvider({ getService }: FtrProviderContext) { const log = getService('log'); const config = getService('config'); const kibanaServer = getService('kibanaServer'); - const url = formatUrl(config.get('servers.kibana')); + const rawUrl = formatUrl(config.get('servers.kibana')); + + // FTR's servers.kibana URL embeds `user:pass@host` credentials. Native fetch rejects URLs with + // embedded credentials, so we strip them out and forward the basic auth via the Authorization + // header instead. + const parsedUrl = new URL(rawUrl); + const authorization = + parsedUrl.username || parsedUrl.password + ? `Basic ${Buffer.from( + `${decodeURIComponent(parsedUrl.username)}:${decodeURIComponent(parsedUrl.password)}` + ).toString('base64')}` + : undefined; + parsedUrl.username = ''; + parsedUrl.password = ''; + + const url = parsedUrl.toString(); // used often in fleet_api_integration tests const TEST_SPACE_1 = 'test1'; const certificateAuthorities = config.get('servers.kibana.certificateAuthorities'); - const httpsAgent: Https.Agent | undefined = certificateAuthorities - ? new Https.Agent({ - ca: certificateAuthorities, - // required for self-signed certificates used for HTTPS FTR testing - rejectUnauthorized: false, + const dispatcher: Dispatcher | undefined = certificateAuthorities + ? new Agent({ + connect: { + ca: certificateAuthorities, + // required for self-signed certificates used for HTTPS FTR testing + rejectUnauthorized: false, + }, }) : undefined; - const axios = Axios.create({ - headers: { - 'kbn-xsrf': 'x-pack/ftr/services/spaces/space', - }, - baseURL: url, - allowAbsoluteUrls: false, - maxRedirects: 0, - validateStatus: () => true, // we do our own validation below and throw better error messages - httpsAgent, - }); + const request = async ( + method: string, + path: string, + body?: unknown + ): Promise<{ data: T; status: number; statusText: string }> => { + const response = await fetch(new URL(path, url), { + method, + headers: { + ...(authorization ? { Authorization: authorization } : {}), + 'kbn-xsrf': 'x-pack/ftr/services/spaces/space', + ...(body !== undefined ? { 'content-type': 'application/json' } : {}), + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + redirect: 'manual', + ...(dispatcher ? { dispatcher } : {}), + }); + + const text = await response.text(); + let data: unknown = text; + if (text) { + try { + data = JSON.parse(text); + } catch { + // body is not JSON, leave as text. + } + } + + return { + data: data as T, + status: response.status, + statusText: response.statusText, + }; + }; return new (class SpacesService { public async create(_space?: SpaceCreate) { const space = { id: chance.guid(), name: 'foo', ..._space }; log.debug(`creating space ${space.id}`); - const { data, status, statusText } = await axios.post('/api/spaces/space', space); + const { data, status, statusText } = await request('POST', '/api/spaces/space', space); if (status !== 200) { throw new Error( @@ -84,7 +123,8 @@ export function SpacesServiceProvider({ getService }: FtrProviderContext) { { overwrite = true }: { overwrite?: boolean } = {} ) { log.debug(`updating space ${id}`); - const { data, status, statusText } = await axios.put( + const { data, status, statusText } = await request( + 'PUT', `/api/spaces/space/${id}?overwrite=${overwrite}`, updatedSpace ); @@ -99,7 +139,7 @@ export function SpacesServiceProvider({ getService }: FtrProviderContext) { public async delete(spaceId: string) { log.debug(`deleting space id: ${spaceId}`); - const { data, status, statusText } = await axios.delete(`/api/spaces/space/${spaceId}`); + const { data, status, statusText } = await request('DELETE', `/api/spaces/space/${spaceId}`); if (status !== 204) { log.debug( @@ -111,7 +151,7 @@ export function SpacesServiceProvider({ getService }: FtrProviderContext) { public async get(id: string) { log.debug(`retrieving space ${id}`); - const { data, status, statusText } = await axios.get(`/api/spaces/space/${id}`); + const { data, status, statusText } = await request('GET', `/api/spaces/space/${id}`); if (status !== 200) { throw new Error( @@ -125,7 +165,7 @@ export function SpacesServiceProvider({ getService }: FtrProviderContext) { public async getAll() { log.debug('retrieving all spaces'); - const { data, status, statusText } = await axios.get('/api/spaces/space'); + const { data, status, statusText } = await request('GET', '/api/spaces/space'); if (status !== 200) { throw new Error( diff --git a/x-pack/platform/test/fleet_api_integration/apis/epm/install_remove_assets.ts b/x-pack/platform/test/fleet_api_integration/apis/epm/install_remove_assets.ts index 8a7ce27bf3e50..43338a9d6a3e1 100644 --- a/x-pack/platform/test/fleet_api_integration/apis/epm/install_remove_assets.ts +++ b/x-pack/platform/test/fleet_api_integration/apis/epm/install_remove_assets.ts @@ -18,7 +18,7 @@ import { skipIfNoDockerRegistry, isDockerRegistryEnabledOrSkipped } from '../../ import { cleanFleetIndices } from '../space_awareness/helpers'; function checkErrorWithResponseDataOrThrow(err: any) { - if (!err?.response?.data) { + if (err?.status === undefined) { throw err; } } @@ -268,7 +268,7 @@ export default function (providerContext: FtrProviderContext) { checkErrorWithResponseDataOrThrow(err); resDashboard = err; } - expect(resDashboard.response.data.statusCode).equal(404); + expect(resDashboard.status).equal(404); let resDashboard2; try { resDashboard2 = await kibanaServer.savedObjects.get({ @@ -279,7 +279,7 @@ export default function (providerContext: FtrProviderContext) { checkErrorWithResponseDataOrThrow(err); resDashboard2 = err; } - expect(resDashboard2.response.data.statusCode).equal(404); + expect(resDashboard2.status).equal(404); let resVis; try { resVis = await kibanaServer.savedObjects.get({ @@ -290,7 +290,7 @@ export default function (providerContext: FtrProviderContext) { checkErrorWithResponseDataOrThrow(err); resVis = err; } - expect(resVis.response.data.statusCode).equal(404); + expect(resVis.status).equal(404); let resSearch; try { resVis = await kibanaServer.savedObjects.get({ @@ -301,7 +301,7 @@ export default function (providerContext: FtrProviderContext) { checkErrorWithResponseDataOrThrow(err); resSearch = err; } - expect(resSearch.response.data.statusCode).equal(404); + expect(resSearch.status).equal(404); let resIndexPattern; try { resIndexPattern = await kibanaServer.savedObjects.get({ @@ -312,7 +312,7 @@ export default function (providerContext: FtrProviderContext) { checkErrorWithResponseDataOrThrow(err); resIndexPattern = err; } - expect(resIndexPattern.response.data.statusCode).equal(404); + expect(resIndexPattern.status).equal(404); let resOsqueryPackAsset; try { resOsqueryPackAsset = await kibanaServer.savedObjects.get({ @@ -323,7 +323,7 @@ export default function (providerContext: FtrProviderContext) { checkErrorWithResponseDataOrThrow(err); resOsqueryPackAsset = err; } - expect(resOsqueryPackAsset.response.data.statusCode).equal(404); + expect(resOsqueryPackAsset.status).equal(404); let resOsquerySavedQuery; try { resOsquerySavedQuery = await kibanaServer.savedObjects.get({ @@ -334,7 +334,7 @@ export default function (providerContext: FtrProviderContext) { checkErrorWithResponseDataOrThrow(err); resOsquerySavedQuery = err; } - expect(resOsquerySavedQuery.response.data.statusCode).equal(404); + expect(resOsquerySavedQuery.status).equal(404); let securityAiPrompt; try { securityAiPrompt = await kibanaServer.savedObjects.get({ @@ -345,7 +345,7 @@ export default function (providerContext: FtrProviderContext) { checkErrorWithResponseDataOrThrow(err); securityAiPrompt = err; } - expect(securityAiPrompt.response.data.statusCode).equal(404); + expect(securityAiPrompt.status).equal(404); }); it('should have removed the saved object', async function () { let res; @@ -358,7 +358,7 @@ export default function (providerContext: FtrProviderContext) { checkErrorWithResponseDataOrThrow(err); res = err; } - expect(res.response.data.statusCode).equal(404); + expect(res.status).equal(404); }); }); @@ -655,7 +655,7 @@ const expectAssetsInstalled = ({ checkErrorWithResponseDataOrThrow(err); resInvalidTypeIndexPattern = err; } - expect(resInvalidTypeIndexPattern.response.data.statusCode).equal(404); + expect(resInvalidTypeIndexPattern.status).equal(404); }); it('should not add fields to the index patterns', async () => { const resIndexPatternLogs = await kibanaServer.savedObjects.get({ diff --git a/x-pack/platform/test/fleet_api_integration/apis/epm/install_remove_kbn_assets_in_space.ts b/x-pack/platform/test/fleet_api_integration/apis/epm/install_remove_kbn_assets_in_space.ts index 140b1f8b13f17..841c026531866 100644 --- a/x-pack/platform/test/fleet_api_integration/apis/epm/install_remove_kbn_assets_in_space.ts +++ b/x-pack/platform/test/fleet_api_integration/apis/epm/install_remove_kbn_assets_in_space.ts @@ -88,7 +88,7 @@ export default function (providerContext: FtrProviderContext) { } catch (err) { resDashboard = err; } - expect(resDashboard.response.data.statusCode).equal(404); + expect(resDashboard.status).equal(404); let resVis; try { @@ -100,7 +100,7 @@ export default function (providerContext: FtrProviderContext) { } catch (err) { resVis = err; } - expect(resVis.response.data.statusCode).equal(404); + expect(resVis.status).equal(404); let resSearch; try { resVis = await kibanaServer.savedObjects.get({ @@ -111,7 +111,7 @@ export default function (providerContext: FtrProviderContext) { } catch (err) { resSearch = err; } - expect(resSearch.response.data.statusCode).equal(404); + expect(resSearch.status).equal(404); }); }); }); diff --git a/x-pack/platform/test/fleet_api_integration/apis/epm/install_update.ts b/x-pack/platform/test/fleet_api_integration/apis/epm/install_update.ts index 90f02831c7a5e..ed2a1ae0e49d7 100644 --- a/x-pack/platform/test/fleet_api_integration/apis/epm/install_update.ts +++ b/x-pack/platform/test/fleet_api_integration/apis/epm/install_update.ts @@ -48,7 +48,7 @@ export default function (providerContext: FtrProviderContext) { } catch (err) { res = err; } - expect(res.response.data.statusCode).equal(404); + expect(res.status).equal(404); }); it('should return 400 if trying to install an out-of-date package', async function () { await supertest @@ -64,7 +64,7 @@ export default function (providerContext: FtrProviderContext) { } catch (err) { res = err; } - expect(res.response.data.statusCode).equal(404); + expect(res.status).equal(404); }); it('should return 200 if trying to force install an out-of-date package', async function () { await supertest diff --git a/x-pack/platform/test/fleet_api_integration/apis/epm/update_assets.ts b/x-pack/platform/test/fleet_api_integration/apis/epm/update_assets.ts index 274347686c589..8bc8a102f591a 100644 --- a/x-pack/platform/test/fleet_api_integration/apis/epm/update_assets.ts +++ b/x-pack/platform/test/fleet_api_integration/apis/epm/update_assets.ts @@ -310,7 +310,7 @@ export default function (providerContext: FtrProviderContext) { } catch (err) { resDashboard2 = err; } - expect(resDashboard2.response.data.statusCode).equal(404); + expect(resDashboard2.status).equal(404); const resVis = await kibanaServer.savedObjects.get({ type: 'visualization', id: 'sample_visualization', @@ -325,7 +325,7 @@ export default function (providerContext: FtrProviderContext) { } catch (err) { resSearch = err; } - expect(resSearch.response.data.statusCode).equal(404); + expect(resSearch.status).equal(404); const resSearch2 = await kibanaServer.savedObjects.get({ type: 'search', id: 'sample_search2', diff --git a/x-pack/solutions/observability/test/api_integration/apis/synthetics/private_location_apis.ts b/x-pack/solutions/observability/test/api_integration/apis/synthetics/private_location_apis.ts index b83e95871774f..95ceed4ae20c4 100644 --- a/x-pack/solutions/observability/test/api_integration/apis/synthetics/private_location_apis.ts +++ b/x-pack/solutions/observability/test/api_integration/apis/synthetics/private_location_apis.ts @@ -78,7 +78,7 @@ export default function ({ getService }: FtrProviderContext) { id: legacyPrivateLocationsSavedObjectId, }); } catch (e) { - expect(e.response.status).to.be(404); + expect(e.status).to.be(404); } }); diff --git a/x-pack/solutions/observability/test/api_integration/apis/uptime/rest/index.ts b/x-pack/solutions/observability/test/api_integration/apis/uptime/rest/index.ts index 47be4686cc831..ab2967b5b8e25 100644 --- a/x-pack/solutions/observability/test/api_integration/apis/uptime/rest/index.ts +++ b/x-pack/solutions/observability/test/api_integration/apis/uptime/rest/index.ts @@ -24,11 +24,14 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { }); } catch (e) { // a 404 just means the doc is already missing - if (e.response.status !== 404) { - const { status, statusText, data, headers, config } = e.response; + if (e.status !== 404) { throw new Error( `error attempting to delete settings:\n${JSON.stringify( - { status, statusText, data, headers, config }, + { + status: e.status, + headers: e.headers ? Object.fromEntries(e.headers.entries()) : undefined, + message: e.message, + }, null, 2 )}` diff --git a/x-pack/solutions/observability/test/api_integration/services/slo.ts b/x-pack/solutions/observability/test/api_integration/services/slo.ts index abf143148bb0f..f14932ad45555 100644 --- a/x-pack/solutions/observability/test/api_integration/services/slo.ts +++ b/x-pack/solutions/observability/test/api_integration/services/slo.ts @@ -60,8 +60,7 @@ export function SloApiProvider({ getService }: FtrProviderContext) { await security.user.delete(username); await security.role.delete(roleName); } catch (error) { - const status = error.response.status; - if (status !== 404) { + if (error.status !== 404) { throw error; } } diff --git a/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/apm/settings/apm_indices/apm_indices.spec.ts b/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/apm/settings/apm_indices/apm_indices.spec.ts index 460fa1acb0a8f..5cb9ff7d6a324 100644 --- a/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/apm/settings/apm_indices/apm_indices.spec.ts +++ b/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/apm/settings/apm_indices/apm_indices.spec.ts @@ -23,7 +23,7 @@ export default function apmIndicesTests({ getService }: DeploymentAgnosticFtrPro id: APM_INDEX_SETTINGS_SAVED_OBJECT_ID, }); } catch (e) { - if (e.response.status !== 404) { + if (e.status !== 404) { throw e; } } diff --git a/x-pack/solutions/observability/test/apm_api_integration/tests/settings/apm_indices/apm_indices.spec.ts b/x-pack/solutions/observability/test/apm_api_integration/tests/settings/apm_indices/apm_indices.spec.ts index 00765948cf3bc..240ad7174fac3 100644 --- a/x-pack/solutions/observability/test/apm_api_integration/tests/settings/apm_indices/apm_indices.spec.ts +++ b/x-pack/solutions/observability/test/apm_api_integration/tests/settings/apm_indices/apm_indices.spec.ts @@ -26,7 +26,7 @@ export default function apmIndicesTests({ getService }: FtrProviderContext) { id: APM_INDEX_SETTINGS_SAVED_OBJECT_ID, }); } catch (e) { - if (e.response.status !== 404) { + if (e.status !== 404) { throw e; } } diff --git a/x-pack/solutions/observability/test/functional/services/uptime/common.ts b/x-pack/solutions/observability/test/functional/services/uptime/common.ts index 4728368472b73..2c4cdc5e71545 100644 --- a/x-pack/solutions/observability/test/functional/services/uptime/common.ts +++ b/x-pack/solutions/observability/test/functional/services/uptime/common.ts @@ -133,11 +133,14 @@ export function UptimeCommonProvider({ getService, getPageObjects }: FtrProvider }); } catch (e) { // a 404 just means the doc is already missing - if (e.response.status !== 404) { - const { status, statusText, data, headers, config } = e.response; + if (e.status !== 404) { throw new Error( `error attempting to delete settings:\n${JSON.stringify( - { status, statusText, data, headers, config }, + { + status: e.status, + headers: e.headers ? Object.fromEntries(e.headers.entries()) : undefined, + message: e.message, + }, null, 2 )}` diff --git a/x-pack/solutions/security/plugins/security_solution/common/endpoint/data_loaders/index_endpoint_hosts.ts b/x-pack/solutions/security/plugins/security_solution/common/endpoint/data_loaders/index_endpoint_hosts.ts index 1ac8470aaeedc..84728f43f3bfe 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/endpoint/data_loaders/index_endpoint_hosts.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/endpoint/data_loaders/index_endpoint_hosts.ts @@ -7,9 +7,8 @@ import type { Client } from '@elastic/elasticsearch'; import { cloneDeep, merge } from 'lodash'; -import type { AxiosResponse } from 'axios'; import { v4 as uuidv4 } from 'uuid'; -import type { KbnClient } from '@kbn/test'; +import type { KbnClient, KbnClientResponse } from '@kbn/test'; import type { BulkRequest, DeleteByQueryResponse } from '@elastic/elasticsearch/lib/api/types'; import type { CreatePackagePolicyResponse, @@ -315,7 +314,7 @@ const fetchKibanaVersion = async (kbnClient: KbnClient) => { (await kbnClient.request({ path: '/api/status', method: 'GET', - })) as AxiosResponse + })) as KbnClientResponse<{ version: { number: string } }> ).data.version.number; if (!version) { diff --git a/x-pack/solutions/security/plugins/security_solution/common/endpoint/data_loaders/index_fleet_endpoint_policy.ts b/x-pack/solutions/security/plugins/security_solution/common/endpoint/data_loaders/index_fleet_endpoint_policy.ts index 8d8528e7bfd22..ed893f157d07f 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/endpoint/data_loaders/index_fleet_endpoint_policy.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/endpoint/data_loaders/index_fleet_endpoint_policy.ts @@ -5,8 +5,7 @@ * 2.0. */ -import type { KbnClient } from '@kbn/test'; -import type { AxiosResponse } from 'axios'; +import type { KbnClient, KbnClientResponse } from '@kbn/test'; import type { AgentPolicy, CreateAgentPolicyRequest, @@ -71,7 +70,7 @@ export const indexFleetEndpointPolicy = usageTracker.track( space_ids: spaceIds, }; - let agentPolicy: AxiosResponse; + let agentPolicy: KbnClientResponse; try { agentPolicy = (await kbnClient @@ -83,7 +82,7 @@ export const indexFleetEndpointPolicy = usageTracker.track( method: 'POST', body: newAgentPolicyData, }) - .catch(wrapErrorAndRejectPromise)) as AxiosResponse; + .catch(wrapErrorAndRejectPromise)) as KbnClientResponse; } catch (error) { throw new Error(`create fleet agent policy failed ${error}`); } @@ -197,7 +196,7 @@ export const deleteIndexedFleetEndpointPolicies = async ( packagePolicyIds: indexData.integrationPolicies.map((policy) => policy.id), }, }) - .catch(wrapErrorAndRejectPromise)) as AxiosResponse + .catch(wrapErrorAndRejectPromise)) as KbnClientResponse ).data; } @@ -219,7 +218,7 @@ export const deleteIndexedFleetEndpointPolicies = async ( force: true, }, }) - .catch(wrapErrorAndRejectPromise)) as AxiosResponse + .catch(wrapErrorAndRejectPromise)) as KbnClientResponse ).data ); } diff --git a/x-pack/solutions/security/plugins/security_solution/common/endpoint/data_loaders/setup_fleet_for_endpoint.ts b/x-pack/solutions/security/plugins/security_solution/common/endpoint/data_loaders/setup_fleet_for_endpoint.ts index e9da1b9a4df8c..d799fc724f37e 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/endpoint/data_loaders/setup_fleet_for_endpoint.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/endpoint/data_loaders/setup_fleet_for_endpoint.ts @@ -5,8 +5,7 @@ * 2.0. */ -import type { AxiosResponse } from 'axios'; -import type { KbnClient } from '@kbn/test'; +import type { KbnClient, KbnClientResponse } from '@kbn/test'; import type { BulkInstallPackageInfo, BulkInstallPackagesResponse, @@ -49,7 +48,7 @@ export const setupFleetForEndpoint = usageTracker.track( headers: { 'Elastic-Api-Version': API_VERSIONS.public.v1 }, method: 'POST', }) - .catch(wrapErrorAndRejectPromise)) as AxiosResponse; + .catch(wrapErrorAndRejectPromise)) as KbnClientResponse; if (!setupResponse.data.isInitialized) { log.error(new Error(JSON.stringify(setupResponse.data, null, 2))); @@ -70,7 +69,7 @@ export const setupFleetForEndpoint = usageTracker.track( 'elastic-api-version': API_VERSIONS.public.v1, }, }) - .catch(wrapErrorAndRejectPromise)) as AxiosResponse; + .catch(wrapErrorAndRejectPromise)) as KbnClientResponse; if (!setupResponse.data.isInitialized) { log.error(new Error(JSON.stringify(setupResponse, null, 2))); @@ -117,7 +116,7 @@ export const installOrUpgradeEndpointFleetPackage = usageTracker.track( 'elastic-api-version': API_VERSIONS.public.v1, }, }) - .catch(wrapErrorAndRejectPromise)) as AxiosResponse; + .catch(wrapErrorAndRejectPromise)) as KbnClientResponse; logger.debug(`Fleet bulk install response:`, installEndpointPackageResp.data); diff --git a/x-pack/solutions/security/plugins/security_solution/common/endpoint/utils/package.ts b/x-pack/solutions/security/plugins/security_solution/common/endpoint/utils/package.ts index b4f995d0c49c3..0ab88c9ac53b8 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/endpoint/utils/package.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/endpoint/utils/package.ts @@ -5,9 +5,7 @@ * 2.0. */ -import type { AxiosResponse } from 'axios'; - -import type { KbnClient } from '@kbn/test'; +import type { KbnClient, KbnClientResponse } from '@kbn/test'; import type { GetInfoResponse } from '@kbn/fleet-plugin/common'; import { API_VERSIONS, epmRouteService } from '@kbn/fleet-plugin/common'; import { usageTracker } from '../data_loaders/usage_tracker'; @@ -21,7 +19,7 @@ export const getEndpointPackageInfo = usageTracker.track( path, headers: { 'Elastic-Api-Version': API_VERSIONS.public.v1 }, method: 'GET', - })) as AxiosResponse + })) as KbnClientResponse ).data.item; if (!endpointPackage) { diff --git a/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/common/fleet_server/fleet_server_services.ts b/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/common/fleet_server/fleet_server_services.ts index 3dd52ed7958b9..c2dd94ac9feda 100644 --- a/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/common/fleet_server/fleet_server_services.ts +++ b/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/common/fleet_server/fleet_server_services.ts @@ -30,8 +30,7 @@ import { fleetServerHostsRoutesService, outputRoutesService, } from '@kbn/fleet-plugin/common/services'; -import axios from 'axios'; -import * as https from 'https'; +import { Agent } from 'undici'; import { CA_TRUSTED_FINGERPRINT, FLEET_SERVER_CERT_PATH, @@ -40,11 +39,10 @@ import { } from '@kbn/dev-utils'; import { maybeCreateDockerNetwork, SERVERLESS_NODES, verifyDockerInstalled } from '@kbn/es'; import { resolve } from 'path'; +import { KbnClientRequesterError } from '@kbn/kbn-client'; import { isServerlessKibanaFlavor } from '../../../../common/endpoint/utils/kibana_status'; import { captureCallingStack, dump, prefixedOutputLogger } from '../utils'; import { createToolingLogger } from '../../../../common/endpoint/data_loaders/utils'; -import type { FormattedAxiosError } from '../../../../common/endpoint/format_axios_error'; -import { catchAxiosErrorFormatAndThrow } from '../../../../common/endpoint/format_axios_error'; import { createAgentPolicy, createIntegrationPolicy, @@ -630,14 +628,14 @@ const addFleetServerHostToFleetSettings = async ( }, body: newFleetHostEntry, }) - .catch(catchAxiosErrorFormatAndThrow) - .catch((error: FormattedAxiosError) => { + .catch((error) => { if ( - error.response.status === 403 && - ((error.response?.data?.message as string) ?? '').includes('disabled') + error instanceof KbnClientRequesterError && + error.status === 403 && + error.message.includes('disabled') ) { log.error(`Attempt to update fleet server host URL in fleet failed with [403: ${ - error.response.data.message + error.message }]. ${chalk.red('Are you running this utility against a Serverless project?')} @@ -707,14 +705,12 @@ const updateFleetElasticsearchOutputHostNames = async ( log.info(`Updating Fleet Settings for Output [${output.name} (${id})]`); - await kbnClient - .request({ - method: 'PUT', - headers: { 'elastic-api-version': '2023-10-31' }, - path: outputRoutesService.getUpdatePath(id), - body: update, - }) - .catch(catchAxiosErrorFormatAndThrow); + await kbnClient.request({ + method: 'PUT', + headers: { 'elastic-api-version': '2023-10-31' }, + path: outputRoutesService.getUpdatePath(id), + body: update, + }); } } } @@ -747,21 +743,24 @@ export const isFleetServerRunning = async ( return pRetry( async () => { - return axios - .request({ - method: 'GET', - url: url.toString(), - responseType: 'json', - // Custom agent to ensure we don't get cert errors - httpsAgent: new https.Agent({ rejectUnauthorized: false }), - }) - .then((response) => { - log.debug( - `Fleet server is up and running at [${fleetServerUrl}]. Status: `, - response.data - ); - }) - .catch(catchAxiosErrorFormatAndThrow); + const response = await fetch(url, { + method: 'GET', + // Custom dispatcher to ensure we don't get cert errors + dispatcher: new Agent({ connect: { rejectUnauthorized: false } }), + } as RequestInit); + + if (!response.ok) { + throw new Error( + `Fleet server status check failed at [${url.toString()}]: ${response.status} ${ + response.statusText + } -- ${await response.text()}` + ); + } + + log.debug( + `Fleet server is up and running at [${fleetServerUrl}]. Status: `, + await response.json() + ); }, { maxTimeout: 10000, diff --git a/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/common/random_policy_id_generator.ts b/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/common/random_policy_id_generator.ts index 3c58f44b3d266..8f18cdbb8d5be 100644 --- a/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/common/random_policy_id_generator.ts +++ b/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/common/random_policy_id_generator.ts @@ -6,8 +6,7 @@ */ import type { ToolingLog } from '@kbn/tooling-log'; -import type { KbnClient } from '@kbn/test'; -import type { AxiosResponse } from 'axios'; +import type { KbnClient, KbnClientResponse } from '@kbn/test'; import { PACKAGE_POLICY_API_ROUTES, PACKAGE_POLICY_SAVED_OBJECT_TYPE, @@ -20,7 +19,7 @@ import { getEndpointPackageInfo } from '../../../common/endpoint/utils/package'; const fetchEndpointPolicies = ( kbnClient: KbnClient -): Promise> => { +): Promise> => { return kbnClient .request({ method: 'GET', diff --git a/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/common/role_and_user_loader.ts b/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/common/role_and_user_loader.ts index 7ad64641e0904..e1bc13b4daa48 100644 --- a/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/common/role_and_user_loader.ts +++ b/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/common/role_and_user_loader.ts @@ -8,10 +8,10 @@ /* eslint-disable max-classes-per-file */ import type { KbnClient } from '@kbn/test'; +import type { KbnClientRequesterError } from '@kbn/kbn-client'; import type { Role } from '@kbn/security-plugin/common'; import type { ToolingLog } from '@kbn/tooling-log'; import { inspect } from 'util'; -import type { AxiosError } from 'axios'; import { cloneDeep } from 'lodash'; import { dump } from './utils'; import type { EndpointSecurityRoleDefinitions } from './roles_users'; @@ -19,8 +19,8 @@ import { getAllEndpointSecurityRoles } from './roles_users'; import { catchAxiosErrorFormatAndThrow } from '../../../common/endpoint/format_axios_error'; import { COMMON_API_HEADERS } from './constants'; -const ignoreHttp409Error = (error: AxiosError) => { - if (error?.response?.status === 409) { +const ignoreHttp409Error = (error: KbnClientRequesterError) => { + if (error?.status === 409) { return; } diff --git a/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/common/spaces.ts b/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/common/spaces.ts index 92affc609bf0c..f201ce19996ea 100644 --- a/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/common/spaces.ts +++ b/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/common/spaces.ts @@ -6,7 +6,6 @@ */ import type { KbnClient } from '@kbn/test'; -import { AxiosError } from 'axios'; import type { ToolingLog } from '@kbn/tooling-log'; import type { Space } from '@kbn/spaces-plugin/common'; import { DEFAULT_SPACE_ID, getSpaceIdFromPath } from '@kbn/spaces-plugin/common'; @@ -38,7 +37,7 @@ export const ensureSpaceIdExists = async ( return true; }) .catch((err) => { - if (err instanceof AxiosError && (err.response?.status ?? err.status) === 404) { + if (err.status === 404) { return false; } diff --git a/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/common/stack_services.ts b/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/common/stack_services.ts index 35a72aa13d06d..55c508ab5d345 100644 --- a/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/common/stack_services.ts +++ b/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/common/stack_services.ts @@ -7,10 +7,9 @@ import { Client, HttpConnection } from '@elastic/elasticsearch'; import type { ToolingLog } from '@kbn/tooling-log'; -import type { KbnClientOptions, ReqOptions } from '@kbn/kbn-client'; +import type { KbnClientOptions, KbnClientResponse, ReqOptions } from '@kbn/kbn-client'; import { KbnClient } from '@kbn/kbn-client'; import pRetry from 'p-retry'; -import { type AxiosResponse } from 'axios'; import type { ClientOptions } from '@elastic/elasticsearch/lib/client'; import fs from 'fs'; import { CA_CERT_PATH } from '@kbn/dev-utils'; @@ -92,7 +91,7 @@ class KbnClientExtended extends KbnClient { this.apiKey = apiKey; } - async request(options: ReqOptions): Promise> { + async request(options: ReqOptions): Promise> { const headers: ReqOptions['headers'] = { ...(options.headers ?? {}), }; diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/ai4dsoc/nlp_cleanup_task/search_ai_lake_tier/task_execution.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/ai4dsoc/nlp_cleanup_task/search_ai_lake_tier/task_execution.ts index 6c668788ea621..676ab1cc9b50a 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/ai4dsoc/nlp_cleanup_task/search_ai_lake_tier/task_execution.ts +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/ai4dsoc/nlp_cleanup_task/search_ai_lake_tier/task_execution.ts @@ -34,7 +34,7 @@ export default ({ getService }: FtrProviderContext) => { logger ); } catch (e) { - expect(e.message).to.eql('Request failed with status code 404'); + expect(e.status).to.eql(404); return; } diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/utils/alerts/migrations/delete_migrations.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/utils/alerts/migrations/delete_migrations.ts index da230c862889c..eaa2331ce6b4d 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/utils/alerts/migrations/delete_migrations.ts +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/utils/alerts/migrations/delete_migrations.ts @@ -43,7 +43,7 @@ export const deleteMigrationsIfExistent = async ({ return res; } catch (e) { // do not throw error when migration already deleted/not found - if (e?.response?.status !== 404) { + if (e?.status !== 404) { throw e; } } diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/nlp_cleanup_task/trial_license_complete_tier/task_execution.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/nlp_cleanup_task/trial_license_complete_tier/task_execution.ts index 0dd03613ace27..68a89de7384c6 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/nlp_cleanup_task/trial_license_complete_tier/task_execution.ts +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/genai/nlp_cleanup_task/trial_license_complete_tier/task_execution.ts @@ -34,7 +34,7 @@ export default ({ getService }: FtrProviderContext) => { logger ); } catch (e) { - expect(e.message).to.eql('Request failed with status code 404'); + expect(e.status).to.eql(404); return; }