diff --git a/.buildkite/pipeline-utils/buildkite/client.ts b/.buildkite/pipeline-utils/buildkite/client.ts index 31ece0b8d5803..66e00921aa44f 100644 --- a/.buildkite/pipeline-utils/buildkite/client.ts +++ b/.buildkite/pipeline-utils/buildkite/client.ts @@ -7,8 +7,6 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { AxiosInstance } from 'axios'; -import axios from 'axios'; import type { ExecSyncOptions } from 'child_process'; import { execFileSync, execSync } from 'child_process'; @@ -162,35 +160,53 @@ export interface BuildkiteWaitStep { } export class BuildkiteClient { - http: AxiosInstance; exec: ExecType; baseUrl: string; + private defaultHeaders: Record; constructor(config: BuildkiteClientConfig = {}) { const BUILDKITE_TOKEN = config.token ?? process.env.BUILDKITE_TOKEN; this.baseUrl = config.baseUrl ?? process.env.BUILDKITE_BASE_URL ?? 'https://api.buildkite.com'; - // const BUILDKITE_AGENT_BASE_URL = - // process.env.BUILDKITE_AGENT_BASE_URL || 'https://agent.buildkite.com/v3'; - // const BUILDKITE_AGENT_TOKEN = process.env.BUILDKITE_AGENT_TOKEN; + this.defaultHeaders = { + Authorization: `Bearer ${BUILDKITE_TOKEN}`, + }; + + this.exec = config.exec ?? execSync; + } + + private async httpGet(path: string): Promise<{ data: T; headers: Headers }> { + const url = `${this.baseUrl}/${path.replace(/^\//, '')}`; + const resp = await fetch(url, { + headers: this.defaultHeaders, + }); - this.http = axios.create({ - baseURL: this.baseUrl, + if (!resp.ok) { + throw new Error(`Buildkite API request failed: ${resp.status} ${resp.statusText}`); + } + + const data = (await resp.json()) as T; + return { data, headers: resp.headers }; + } + + private async httpPost(path: string, body?: unknown): Promise<{ data: T }> { + const url = `${this.baseUrl}/${path.replace(/^\//, '')}`; + const resp = await fetch(url, { + method: 'POST', headers: { - Authorization: `Bearer ${BUILDKITE_TOKEN}`, + 'Content-Type': 'application/json', + ...this.defaultHeaders, }, - allowAbsoluteUrls: false, + body: body ? JSON.stringify(body) : undefined, }); - this.exec = config.exec ?? execSync; + if (!resp.ok) { + throw new Error(`Buildkite API request failed: ${resp.status} ${resp.statusText}`); + } - // this.agentHttp = axios.create({ - // baseURL: BUILDKITE_AGENT_BASE_URL, - // headers: { - // Authorization: `Token ${BUILDKITE_AGENT_TOKEN}`, - // }, - // }); + const data = (await resp.json()) as T; + return { data }; } getBuild = async ( @@ -200,8 +216,8 @@ export class BuildkiteClient { ): Promise => { // TODO properly assemble URL const link = `v2/organizations/elastic/pipelines/${pipelineSlug}/builds/${buildNumber}?include_retried_jobs=${includeRetriedJobs.toString()}`; - const resp = await this.http.get(link); - return resp.data as Build; + const resp = await this.httpGet(link); + return resp.data; }; getBuildsAfterDate = async ( @@ -209,10 +225,10 @@ export class BuildkiteClient { date: string, numberOfBuilds: number ): Promise => { - const response = await this.http.get( + const response = await this.httpGet( `v2/organizations/elastic/pipelines/${pipelineSlug}/builds?created_from=${date}&per_page=${numberOfBuilds}` ); - return response.data as Build[]; + return response.data; }; getBuildForCommit = async (pipelineSlug: string, commit: string): Promise => { @@ -220,10 +236,10 @@ export class BuildkiteClient { throw new Error(`Invalid commit hash: ${commit}, this endpoint works with full SHAs only`); } - const response = await this.http.get( + const response = await this.httpGet( `v2/organizations/elastic/pipelines/${pipelineSlug}/builds?commit=${commit}` ); - const builds = response.data as Build[]; + const builds = response.data; if (builds.length === 0) { return null; } @@ -328,13 +344,14 @@ export class BuildkiteClient { break; } - const resp = await this.http.get(link); + const resp = await this.httpGet(link); link = ''; artifacts.push(resp.data); - if (resp.headers.link) { - const result = parseLinkHeader(resp.headers.link as string, this.baseUrl); + const linkHeader = resp.headers.get('link'); + if (linkHeader) { + const result = parseLinkHeader(linkHeader, this.baseUrl); if (result?.next) { link = result.next; } @@ -364,7 +381,7 @@ export class BuildkiteClient { ): Promise => { const url = `v2/organizations/elastic/pipelines/${pipelineSlug}/builds`; - return (await this.http.post(url, options)).data; + return (await this.httpPost(url, options)).data; }; cancelStep = (stepIdOrKey: string): void => { diff --git a/.buildkite/pipeline-utils/ci-stats/client.ts b/.buildkite/pipeline-utils/ci-stats/client.ts index fb42f8c4776f3..d23a2297a6fe7 100644 --- a/.buildkite/pipeline-utils/ci-stats/client.ts +++ b/.buildkite/pipeline-utils/ci-stats/client.ts @@ -7,8 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { Method, AxiosRequestConfig } from 'axios'; -import axios from 'axios'; +// native fetch - no axios dependency export interface CiStatsClientConfig { baseUrl?: string; @@ -58,9 +57,9 @@ export interface TestGroupRunOrderResponse { interface RequestOptions { path: string; - method?: Method; - params?: AxiosRequestConfig['params']; - body?: AxiosRequestConfig['data']; + method?: string; + params?: Record; + body?: unknown; maxAttempts?: number; } @@ -79,7 +78,7 @@ export class CiStatsClient { } createBuild = async () => { - const resp = await this.request({ + return await this.request({ method: 'POST', path: '/v1/build', body: { @@ -92,8 +91,6 @@ export class CiStatsClient { : [], }, }); - - return resp.data; }; addGitInfo = async (buildId: string) => { @@ -139,15 +136,13 @@ export class CiStatsClient { }; getPrReport = async (buildId: string) => { - const resp = await this.request({ + return await this.request({ method: 'GET', path: `v2/pr_report`, params: { buildId, }, }); - - return resp.data; }; pickTestGroupRunOrder = async (body: { @@ -182,33 +177,67 @@ export class CiStatsClient { console.log('requesting test group run order from ci-stats:'); console.log(JSON.stringify(body, null, 2)); - const resp = await axios.request({ + const url = `${this.baseUrl}/v2/_pick_test_group_run_order`; + const resp = await fetch(url, { method: 'POST', - baseURL: this.baseUrl, - headers: this.defaultHeaders, - url: '/v2/_pick_test_group_run_order', - data: body, + headers: { + 'Content-Type': 'application/json', + ...this.defaultHeaders, + }, + body: JSON.stringify(body), }); - return resp.data; + if (!resp.ok) { + throw new Error(`CI Stats request failed with status ${resp.status}`); + } + + return (await resp.json()) as TestGroupRunOrderResponse; }; - private async request({ method, path, params, body, maxAttempts = 3 }: RequestOptions) { + private async request({ + method, + path, + params, + body, + maxAttempts = 3, + }: RequestOptions): Promise { let attempt = 0; while (true) { attempt += 1; try { - return await axios.request({ - method, - baseURL: this.baseUrl, - url: path, - params, - data: body, - headers: this.defaultHeaders, - }); + const queryString = params ? '?' + new URLSearchParams(params).toString() : ''; + const url = `${this.baseUrl}/${path.replace(/^\//, '')}${queryString}`; + + const fetchOptions: RequestInit = { + method: method ?? 'GET', + headers: { + ...this.defaultHeaders, + ...(body ? { 'Content-Type': 'application/json' } : {}), + }, + }; + + if (body) { + fetchOptions.body = JSON.stringify(body); + } + + const resp = await fetch(url, fetchOptions); + + if (!resp.ok) { + const errorBody = await resp.text(); + let errorMessage: string | undefined; + try { + errorMessage = JSON.parse(errorBody)?.message; + } catch { + // ignore parse error + } + throw new Error(errorMessage ?? `Request failed with status ${resp.status}`); + } + + const text = await resp.text(); + return (text ? JSON.parse(text) : undefined) as T; } catch (error) { - console.error('CI Stats request error:', error?.response?.data?.message); + console.error('CI Stats request error:', (error as Error).message); if (attempt < maxAttempts) { const sec = attempt * 3; diff --git a/.buildkite/scripts/pipelines/chromium_linux_build/issue_feedback/entry.js b/.buildkite/scripts/pipelines/chromium_linux_build/issue_feedback/entry.js index c1aa0eb3b96b7..6cce32098f263 100755 --- a/.buildkite/scripts/pipelines/chromium_linux_build/issue_feedback/entry.js +++ b/.buildkite/scripts/pipelines/chromium_linux_build/issue_feedback/entry.js @@ -12,7 +12,7 @@ const { readFileSync, createWriteStream } = require('fs'); const assert = require('assert'); const { execFile } = require('child_process'); const { finished } = require('stream').promises; -const axios = require('axios'); +const { Readable } = require('stream'); const fg = require('fast-glob'); const AdmZip = require('adm-zip'); @@ -158,10 +158,16 @@ const getSha256Hash = async (filePath) => { process.chdir('chromium'); - const response = await axios.get( + const response = await fetch( 'https://googlechromelabs.github.io/chrome-for-testing/known-good-versions-with-downloads.json' ); + if (!response.ok) { + throw new Error( + `Failed to fetch known good versions: ${response.status} ${response.statusText}` + ); + } + /** * @description list of known good versions of chromium provided by google * @type {{ @@ -169,7 +175,7 @@ const getSha256Hash = async (filePath) => { * versions: VersionDefinition[] * }} */ - const { versions } = response.data; + const { versions } = await response.json(); const chromiumVersion = await $('buildkite-agent', ['meta-data', 'get', 'chromium_version'], { printToScreen: true, @@ -204,13 +210,20 @@ const getSha256Hash = async (filePath) => { const url = new URL(download.url); - const downloadResponse = await axios.get(url.toString(), { responseType: 'stream' }); + const downloadResponse = await fetch(url.toString()); + + if (!downloadResponse.ok) { + throw new Error( + `Failed to download ${url}: ${downloadResponse.status} ${downloadResponse.statusText}` + ); + } const downloadFileName = parse(url.pathname).base; - downloadResponse.data.pipe(createWriteStream(downloadFileName)); + const nodeStream = Readable.fromWeb(downloadResponse.body); + nodeStream.pipe(createWriteStream(downloadFileName)); - await finished(downloadResponse.data); + await finished(nodeStream); console.log(`---Extracting and computing checksum for ${downloadFileName}\n`); diff --git a/.buildkite/scripts/serverless/create_deploy_tag/shared.ts b/.buildkite/scripts/serverless/create_deploy_tag/shared.ts index dad248118d7ce..346e3653bb5e7 100644 --- a/.buildkite/scripts/serverless/create_deploy_tag/shared.ts +++ b/.buildkite/scripts/serverless/create_deploy_tag/shared.ts @@ -7,8 +7,6 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import axios from 'axios'; - import { getExec } from './mock_exec'; import type { GitCommitExtract } from './info_sections/commit_info'; import type { BuildkiteBuildExtract } from './info_sections/build_info'; @@ -93,22 +91,19 @@ export function sendSlackMessage(payload: any) { console.log('No SLACK_WEBHOOK_URL set, not sending slack message'); return Promise.resolve(); } else { - return axios - .post( - process.env.DEPLOY_TAGGER_SLACK_WEBHOOK_URL, - typeof payload === 'string' ? payload : JSON.stringify(payload) - ) - .catch((error) => { - if (axios.isAxiosError(error) && error.response) { - console.error( - "Couldn't send slack message.", - error.response.status, - error.response.statusText, - error.message - ); - } else { - console.error("Couldn't send slack message.", error.message); + const body = typeof payload === 'string' ? payload : JSON.stringify(payload); + return fetch(process.env.DEPLOY_TAGGER_SLACK_WEBHOOK_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }) + .then((response) => { + if (!response.ok) { + console.error("Couldn't send slack message.", response.status, response.statusText); } + }) + .catch((error) => { + console.error("Couldn't send slack message.", (error as Error).message); }); } } diff --git a/.buildkite/scripts/steps/artifacts/validate_cdn_assets.ts b/.buildkite/scripts/steps/artifacts/validate_cdn_assets.ts index b845c7181174f..60dbd1e40b19b 100644 --- a/.buildkite/scripts/steps/artifacts/validate_cdn_assets.ts +++ b/.buildkite/scripts/steps/artifacts/validate_cdn_assets.ts @@ -8,7 +8,6 @@ */ import * as glob from 'glob'; -import axios from 'axios'; const CDN_URL_PREFIX = process.argv[2]; const CDN_ASSETS_FOLDER = process.argv[3]; @@ -48,8 +47,9 @@ async function main() { async function headAssetUrl(assetPath: string) { const testUrl = `${CDN_URL_PREFIX}/${assetPath}`; try { - const response = await axios.head(testUrl, { - timeout: 1000, + const response = await fetch(testUrl, { + method: 'HEAD', + signal: AbortSignal.timeout(1000), }); return { status: response.status, @@ -58,7 +58,7 @@ async function headAssetUrl(assetPath: string) { }; } catch (error) { return { - status: error.response?.status || 0, + status: 0, testUrl, assetPath, }; diff --git a/.buildkite/scripts/steps/cloud/purge_projects.ts b/.buildkite/scripts/steps/cloud/purge_projects.ts index 3182cb955c504..36ca92269c148 100644 --- a/.buildkite/scripts/steps/cloud/purge_projects.ts +++ b/.buildkite/scripts/steps/cloud/purge_projects.ts @@ -8,42 +8,33 @@ */ import { execSync } from 'child_process'; -import axios from 'axios'; import { getKibanaDir } from '#pipeline-utils'; async function getPrProjects() { // BOOKMARK - List of Kibana project types const match = /^(keep.?)?kibana-pr-([0-9]+)-(elasticsearch|security|observability|workplaceai)(?:-(ai_soc|logs_essentials))?$/; - try { - // BOOKMARK - List of Kibana project types - return ( - await Promise.all([ - projectRequest.get('/api/v1/serverless/projects/elasticsearch'), - projectRequest.get('/api/v1/serverless/projects/security'), - projectRequest.get('/api/v1/serverless/projects/observability'), - // TODO handle the new 'workplace ai' project type - https://elastic.slack.com/archives/C5UDAFZQU/p1741692053429579 - ]) - ) - .map((response) => response.data.items) - .flat() - .filter((project) => project.name.match(match)) - .map((project) => { - const [, , prNumber, projectType] = project.name.match(match); - return { - id: project.id, - name: project.name, - prNumber, - type: projectType, - }; - }); - } catch (e) { - if (e.isAxiosError) { - const message = JSON.stringify(e.response.data) || 'unable to fetch projects'; - throw new Error(message); - } - throw e; - } + // BOOKMARK - List of Kibana project types + return ( + await Promise.all([ + projectRequest('/api/v1/serverless/projects/elasticsearch'), + projectRequest('/api/v1/serverless/projects/security'), + projectRequest('/api/v1/serverless/projects/observability'), + // TODO handle the new 'workplace ai' project type - https://elastic.slack.com/archives/C5UDAFZQU/p1741692053429579 + ]) + ) + .map((response) => response.items) + .flat() + .filter((project: any) => project.name.match(match)) + .map((project: any) => { + const [, , prNumber, projectType] = project.name.match(match); + return { + id: project.id, + name: project.name, + prNumber, + type: projectType, + }; + }); } async function deleteProject({ @@ -56,22 +47,13 @@ async function deleteProject({ id: number; name: string; }) { - try { - // TODO handle the new 'workplaceai' project type, and ideally rename 'elasticsearch' to 'search' - await projectRequest.delete(`/api/v1/serverless/projects/${type}/${id}`); + // TODO handle the new 'workplaceai' project type, and ideally rename 'elasticsearch' to 'search' + await projectRequest(`/api/v1/serverless/projects/${type}/${id}`, 'DELETE'); - execSync(`.buildkite/scripts/common/deployment_credentials.sh unset ${name}`, { - cwd: getKibanaDir(), - stdio: 'inherit', - }); - } catch (e) { - if (e.isAxiosError) { - const message = - JSON.stringify(e.response.data) || `unable to delete ${type} project with id ${id}`; - throw new Error(message); - } - throw e; - } + execSync(`.buildkite/scripts/common/deployment_credentials.sh unset ${name}`, { + cwd: getKibanaDir(), + stdio: 'inherit', + }); } async function purgeProjects() { @@ -127,13 +109,38 @@ if (!process.env.PROJECT_API_DOMAIN || !process.env.PROJECT_API_KEY) { console.error('missing project authentication'); process.exit(1); } -const projectRequest = axios.create({ - baseURL: process.env.PROJECT_API_DOMAIN, - headers: { - Authorization: `ApiKey ${process.env.PROJECT_API_KEY}`, - }, - allowAbsoluteUrls: false, -}); + +const PROJECT_API_BASE_URL = process.env.PROJECT_API_DOMAIN; +const PROJECT_API_HEADERS: Record = { + Authorization: `ApiKey ${process.env.PROJECT_API_KEY}`, +}; + +async function projectRequest(path: string, method: string = 'GET'): Promise { + const url = `${PROJECT_API_BASE_URL}${path}`; + const resp = await fetch(url, { + method, + headers: PROJECT_API_HEADERS, + }); + + if (!resp.ok) { + let errorData: any; + try { + errorData = await resp.json(); + } catch { + // ignore parse error + } + const message = errorData + ? JSON.stringify(errorData) + : `Request failed with status ${resp.status}`; + throw new Error(message); + } + + if (method === 'DELETE') { + return; + } + + return await resp.json(); +} purgeProjects().catch((e) => { console.error(e.toString()); diff --git a/.eslintrc.js b/.eslintrc.js index 6a60467ecb0b3..5b6ad790adf96 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -193,6 +193,17 @@ const DEV_PATTERNS = [ ]; /** Restricted imports with suggested alternatives */ +/** + * axios restriction kept separate so connector-framework overrides can exclude it. + * Remove once ConnectorHttpClient migration (Phase 1) is complete. + */ +const AXIOS_RESTRICTION = { + name: 'axios', + message: + 'axios is deprecated. Use native fetch instead. ' + + 'For connectors framework code, this restriction will be lifted once ConnectorHttpClient is available.', +}; + const RESTRICTED_IMPORTS = [ { name: 'lodash', @@ -942,7 +953,7 @@ module.exports = { files: ['**/*.{js,mjs,ts,tsx}'], rules: { '@kbn/eslint/no_wrapped_error_in_logger': 'error', - 'no-restricted-imports': ['error', ...RESTRICTED_IMPORTS], + 'no-restricted-imports': ['error', AXIOS_RESTRICTION, ...RESTRICTED_IMPORTS], '@kbn/eslint/no_deprecated_imports': ['warn', ...DEPRECATED_IMPORTS], 'no-restricted-modules': [ 'error', @@ -2878,6 +2889,23 @@ module.exports = { ], }, }, + /** + * Temporarily allow axios imports in the connectors framework directories. + * This MUST be the last override that sets no-restricted-imports so that + * last-match-wins removes the axios restriction for these paths. + * Remove once ConnectorHttpClient migration (Phase 1) is complete. + */ + { + files: [ + 'x-pack/platform/plugins/shared/actions/server/**/*.{js,mjs,ts,tsx}', + 'x-pack/platform/plugins/shared/stack_connectors/server/**/*.{js,mjs,ts,tsx}', + 'src/platform/packages/shared/kbn-connector-specs/src/**/*.{js,mjs,ts,tsx}', + 'x-pack/platform/test/alerting_api_integration/common/plugins/alerts/server/**/*.{js,mjs,ts,tsx}', + ], + rules: { + 'no-restricted-imports': ['error', ...RESTRICTED_IMPORTS], + }, + }, ], }; diff --git a/packages/kbn-ci-stats-performance-metrics/src/apm_client.ts b/packages/kbn-ci-stats-performance-metrics/src/apm_client.ts index 334ede56ee412..120d67d13739f 100644 --- a/packages/kbn-ci-stats-performance-metrics/src/apm_client.ts +++ b/packages/kbn-ci-stats-performance-metrics/src/apm_client.ts @@ -7,8 +7,6 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { AxiosInstance, AxiosRequestConfig } from 'axios'; -import axios from 'axios'; import type { ToolingLog } from '@kbn/tooling-log'; import { getYearAgoIso } from './utils'; @@ -40,29 +38,39 @@ export interface MainStatisticsResponse { bucketSize: number; } +interface ApmClientConfig { + baseURL?: string; + timeout?: number; + auth?: { + username: string; + password: string; + }; +} + const DEFAULT_BASE_URL = 'https://kibana-ops-e2e-perf.kb.us-central1.gcp.cloud.es.io:9243/internal/apm'; const DEFAULT_CLIENT_TIMEOUT = 120 * 1000; export class ApmClient { - private readonly client: AxiosInstance; + private readonly _baseURL: string; + private readonly _timeout: number; + private readonly _authHeader: string | undefined; private readonly logger: ToolingLog; - constructor(config: AxiosRequestConfig, logger: ToolingLog) { + constructor(config: ApmClientConfig, logger: ToolingLog) { const { baseURL = DEFAULT_BASE_URL, timeout = DEFAULT_CLIENT_TIMEOUT, auth } = config; - this.client = axios.create({ - auth, - baseURL, - timeout, - allowAbsoluteUrls: false, - }); + this._baseURL = baseURL.endsWith('/') ? baseURL.slice(0, -1) : baseURL; + this._timeout = timeout; + this._authHeader = auth + ? `Basic ${Buffer.from(`${auth.username}:${auth.password}`).toString('base64')}` + : undefined; this.logger = logger || console; } - public get baseUrl(): string | undefined { - return this.client.defaults.baseURL; + public get baseUrl(): string { + return this._baseURL; } public async mainStatistics(queryParams: MainStatisticsRequestOptions) { @@ -78,20 +86,36 @@ export class ApmClient { } = queryParams; try { - const responseRaw = await this.client.get( - `services/kibana-frontend/transactions/groups/main_statistics`, - { - params: { - kuery: `labels.ciBuildId:${ciBuildId}`, - environment, - start, - end, - transactionType, - latencyAggregationType, - }, - } - ); - return responseRaw.data; + const searchParams = new URLSearchParams({ + kuery: `labels.ciBuildId:${ciBuildId}`, + environment, + start, + end, + transactionType, + latencyAggregationType, + }); + + const url = `${ + this._baseURL + }/services/kibana-frontend/transactions/groups/main_statistics?${searchParams.toString()}`; + const headers: Record = {}; + if (this._authHeader) { + headers.Authorization = this._authHeader; + } + + const response = await fetch(url, { + method: 'GET', + headers, + signal: AbortSignal.timeout(this._timeout), + }); + + if (!response.ok) { + throw new Error( + `APM request failed with status ${response.status}: ${response.statusText}` + ); + } + + return (await response.json()) as MainStatisticsResponse; } catch (error) { this.logger.error( `Error fetching main statistics from APM, ci build ${ciBuildId}, error message ${error.message}` 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..f4094d842e070 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'; @@ -142,25 +140,33 @@ export class ExistingFailedTestIssues { attempt += 1; try { - const resp = await Axios.request({ + const resp = 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; + if (!resp.ok) { + const error = new Error(`Request failed with status ${resp.status}`); + (error as any).response = { status: resp.status }; + throw error; + } + + const data = (await resp.json()) as FindFailedTestIssuesResponse; + return data.existingIssues; } catch (error: unknown) { + const isResponseError = + error instanceof Error && (error as any).response?.status !== undefined; + const isNetworkError = error instanceof Error && !(error as any).response; + if ( attempt < maxAttempts && - ((isAxiosResponseError(error) && error.response.status >= 500) || - isAxiosRequestError(error)) + ((isResponseError && (error as any).response.status >= 500) || isNetworkError) ) { this.log.error(error); this.log.warning(`Failure talking to ci-stats, waiting ${attempt} before retrying`); 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..71f07e2945a60 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,20 @@ 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 +65,11 @@ 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', + 'Content-Type': 'application/json', + }; } getRequestCount() { @@ -132,7 +131,7 @@ export class GithubApi { ): Promise<{ status: number; statusText: string; - headers: Record; + headers: Record; data: T; }> { const executeRequest = !this.dryRun || options.safeForDryRun; @@ -147,22 +146,63 @@ export class GithubApi { return { status: 200, statusText: 'OK', - headers: new AxiosHeaders(), + headers: {}, data: dryRunResponse, }; } try { this.requestCount += 1; - return await this.x.request(options); - } 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`; + const resp = await fetch(options.url!, { + method: options.method || 'GET', + headers: this.defaultHeaders, + body: options.data ? JSON.stringify(options.data) : undefined, + }); + + if (!resp.ok) { + const respBody = await resp.text().catch(() => ''); + let parsedBody: unknown; + try { + parsedBody = JSON.parse(respBody); + } catch { + parsedBody = respBody; + } + + const error = new Error( + `[${options.method} ${options.url}] ${resp.status} ${resp.statusText} Error` + ); + (error as any).response = { + status: resp.status, + statusText: resp.statusText, + data: parsedBody, + }; + (error as any).config = { method: options.method, url: options.url }; + throw error; + } + + const data = (await resp.json()) as T; + const responseHeaders: Record = {}; + resp.headers.forEach((value, key) => { + responseHeaders[key] = value; + }); - if ((unableToReachGithub || githubApiFailed) && attempt < maxAttempts) { + return { + status: resp.status, + statusText: resp.statusText, + headers: responseHeaders, + data, + }; + } catch (error) { + const hasResponse = !!(error as any)?.response; + const isServerError = hasResponse && (error as any).response.status >= 500; + const isNetworkError = !hasResponse; + const errorResponseLog = hasResponse + ? `[${options.method} ${options.url}] ${(error as any).response.status} ${ + (error as any).response.statusText + } Error` + : undefined; + + if ((isNetworkError || isServerError) && attempt < maxAttempts) { const waitMs = 1000 * attempt; if (errorResponseLog) { @@ -176,7 +216,7 @@ export class GithubApi { } if (errorResponseLog) { - throw new Error(`${errorResponseLog}: ${JSON.stringify(error.response.data)}`); + throw new Error(`${errorResponseLog}: ${JSON.stringify((error as any).response.data)}`); } throw error; 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/packages/kbn-generate/src/lib/validate_elastic_team.ts b/packages/kbn-generate/src/lib/validate_elastic_team.ts index a36c09f799adc..71d2be270029c 100644 --- a/packages/kbn-generate/src/lib/validate_elastic_team.ts +++ b/packages/kbn-generate/src/lib/validate_elastic_team.ts @@ -7,7 +7,6 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import Axios from 'axios'; import type { ValidationResult } from './ask'; interface Body { @@ -18,24 +17,36 @@ interface Body { export async function validateElasticTeam(owner: string): Promise { const slug = owner.startsWith('@') ? owner.slice(1) : owner; - const res = await Axios.get('https://ci-stats.kibana.dev/v1/_validate_kibana_team', { - params: { - slug, - }, - timeout: 5000, - }); + const url = new URL('https://ci-stats.kibana.dev/v1/_validate_kibana_team'); + url.searchParams.set('slug', slug); - if (res.data.match) { - return `@${res.data.match}`; + const controller = new AbortController(); + const timeoutId = globalThis.setTimeout(() => controller.abort(), 5000); + + let res: Response; + try { + res = await fetch(url.toString(), { signal: controller.signal }); + } finally { + clearTimeout(timeoutId); + } + + if (!res.ok) { + throw new Error(`Failed to validate team: ${res.status} ${res.statusText}`); + } + + const data = (await res.json()) as Body; + + if (data.match) { + return `@${data.match}`; } const err = `"${owner}" doesn't match any @elastic team, to override with another valid Github user pass the value with the --owner flag`; - if (!res.data.suggestions?.length) { + if (!data.suggestions?.length) { return { err }; } - const list = res.data.suggestions.map((l) => ` @${l}`); + const list = data.suggestions.map((l) => ` @${l}`); return { err: `${err}\n Did you mean one of these?\n${list.join('\n')}`, }; diff --git a/src/dev/build/lib/download.ts b/src/dev/build/lib/download.ts index d18d4701c0253..fec650fb2d4c3 100644 --- a/src/dev/build/lib/download.ts +++ b/src/dev/build/lib/download.ts @@ -10,11 +10,11 @@ import { openSync, writeSync, unlinkSync, closeSync, statSync } from 'fs'; import { dirname } from 'path'; import { setTimeout } from 'timers/promises'; +import { Readable } from 'stream'; +import type { ReadableStream as WebReadableStream } from 'stream/web'; import chalk from 'chalk'; import { createHash } from 'crypto'; -import Axios from 'axios'; -import { isAxiosResponseError } from '@kbn/dev-utils'; import type { ToolingLog } from '@kbn/tooling-log'; import { mkdirp } from './fs'; @@ -72,11 +72,7 @@ export async function downloadToDisk({ skipChecksumCheck ? '' : chalk.dim(shaAlgorithm) ); - const response = await Axios.request({ - url, - responseType: 'stream', - adapter: 'http', - }); + const response = await fetch(url); if (response.status !== 200) { throw new Error(`Unexpected status code ${response.status} when downloading ${url}`); @@ -85,8 +81,10 @@ export async function downloadToDisk({ const hash = createHash(shaAlgorithm); let bytesWritten = 0; + const nodeStream = Readable.fromWeb(response.body! as unknown as WebReadableStream); + await new Promise((resolve, reject) => { - response.data.on('data', (chunk: Buffer) => { + nodeStream.on('data', (chunk: Buffer) => { if (!skipChecksumCheck) { hash.update(chunk); } @@ -95,8 +93,8 @@ export async function downloadToDisk({ bytesWritten += bytes; }); - response.data.on('error', reject); - response.data.on('end', () => { + nodeStream.on('error', reject); + nodeStream.on('end', () => { if (bytesWritten === 0) { return reject(new Error(`No bytes written when downloading ${url}`)); } @@ -165,19 +163,28 @@ export async function downloadToString({ attempt += 1; log.debug(`[${attempt}/${maxAttempts}] Attempting download to string of [${url}]`); - const resp = await Axios.request({ - url, - method: 'GET', - adapter: 'http', - responseType: 'text', - validateStatus: !expectStatus ? undefined : (status) => status === expectStatus, - }); + const resp = await fetch(url); + + if (expectStatus && resp.status !== expectStatus) { + const body = await resp.text(); + const error = new Error(`Request failed with status code ${resp.status}`); + (error as any).response = { status: resp.status, statusText: resp.statusText, data: body }; + throw error; + } + + if (!resp.ok) { + const body = await resp.text(); + const error = new Error(`Request failed with status code ${resp.status}`); + (error as any).response = { status: resp.status, statusText: resp.statusText, data: body }; + throw error; + } + const data = await resp.text(); log.success(`Downloaded [${url}]`); - return resp.data; + return data; } catch (error) { log.warning(`Download failed: ${error.message}`); - if (isAxiosResponseError(error)) { + if (error.response && error.response.status !== undefined) { log.debug( `[${error.response.status}/${error.response.statusText}] response: ${error.response.data}` ); diff --git a/src/dev/build/lib/integration_tests/download.test.ts b/src/dev/build/lib/integration_tests/download.test.ts index 7b6413d05c7f5..05387a3591073 100644 --- a/src/dev/build/lib/integration_tests/download.test.ts +++ b/src/dev/build/lib/integration_tests/download.test.ts @@ -145,7 +145,7 @@ describe('downloadToDisk', () => { Array [ " debg [1/2] Attempting download of TEST_SERVER_URL sha256", " debg Downloaded 0 bytes to TMP_DIR/__tmp_download_js_test_file__", - " debg Download failed: Request failed with status code 500", + " debg Download failed: Unexpected status code 500 when downloading TEST_SERVER_URL", " debg Deleting downloaded data at TMP_DIR/__tmp_download_js_test_file__", " info Retrying in 0.1 seconds", " debg [2/2] Attempting download of TEST_SERVER_URL sha256", @@ -172,7 +172,7 @@ describe('downloadToDisk', () => { Array [ " debg [1/3] Attempting download of TEST_SERVER_URL sha256", " debg Downloaded 0 bytes to TMP_DIR/__tmp_download_js_test_file__", - " debg Download failed: Request failed with status code 500", + " debg Download failed: Unexpected status code 500 when downloading TEST_SERVER_URL", " debg Deleting downloaded data at TMP_DIR/__tmp_download_js_test_file__", " info Retrying in 0.1 seconds", " debg [2/3] Attempting download of TEST_SERVER_URL sha256", @@ -198,33 +198,33 @@ describe('downloadToDisk', () => { retryDelaySecMultiplier: 0.1, }); await expect(promise).rejects.toMatchInlineSnapshot( - `[AxiosError: Request failed with status code 500]` + `[Error: Unexpected status code 500 when downloading TEST_SERVER_URL]` ); expect(logWritter.messages).toMatchInlineSnapshot(` Array [ " debg [1/5] Attempting download of TEST_SERVER_URL sha256", " debg Downloaded 0 bytes to TMP_DIR/__tmp_download_js_test_file__", - " debg Download failed: Request failed with status code 500", + " debg Download failed: Unexpected status code 500 when downloading TEST_SERVER_URL", " debg Deleting downloaded data at TMP_DIR/__tmp_download_js_test_file__", " info Retrying in 0.1 seconds", " debg [2/5] Attempting download of TEST_SERVER_URL sha256", " debg Downloaded 0 bytes to TMP_DIR/__tmp_download_js_test_file__", - " debg Download failed: Request failed with status code 500", + " debg Download failed: Unexpected status code 500 when downloading TEST_SERVER_URL", " debg Deleting downloaded data at TMP_DIR/__tmp_download_js_test_file__", " info Retrying in 0.2 seconds", " debg [3/5] Attempting download of TEST_SERVER_URL sha256", " debg Downloaded 0 bytes to TMP_DIR/__tmp_download_js_test_file__", - " debg Download failed: Request failed with status code 500", + " debg Download failed: Unexpected status code 500 when downloading TEST_SERVER_URL", " debg Deleting downloaded data at TMP_DIR/__tmp_download_js_test_file__", " info Retrying in 0.30000000000000004 seconds", " debg [4/5] Attempting download of TEST_SERVER_URL sha256", " debg Downloaded 0 bytes to TMP_DIR/__tmp_download_js_test_file__", - " debg Download failed: Request failed with status code 500", + " debg Download failed: Unexpected status code 500 when downloading TEST_SERVER_URL", " debg Deleting downloaded data at TMP_DIR/__tmp_download_js_test_file__", " info Retrying in 0.4 seconds", " debg [5/5] Attempting download of TEST_SERVER_URL sha256", " debg Downloaded 0 bytes to TMP_DIR/__tmp_download_js_test_file__", - " debg Download failed: Request failed with status code 500", + " debg Download failed: Unexpected status code 500 when downloading TEST_SERVER_URL", " debg Deleting downloaded data at TMP_DIR/__tmp_download_js_test_file__", ] `); @@ -271,7 +271,7 @@ describe('downloadToString', () => { maxAttempts: 1, }); await expect(promise).rejects.toMatchInlineSnapshot( - `[AxiosError: Request failed with status code 200]` + `[Error: Request failed with status code 200]` ); expect(logWritter.messages).toMatchInlineSnapshot(` Array [ diff --git a/src/dev/build/tasks/download_cloud_dependencies.ts b/src/dev/build/tasks/download_cloud_dependencies.ts index 841f1a91e0981..03d503bb15fa7 100644 --- a/src/dev/build/tasks/download_cloud_dependencies.ts +++ b/src/dev/build/tasks/download_cloud_dependencies.ts @@ -9,7 +9,6 @@ import Path from 'path'; import del from 'del'; -import Axios from 'axios'; import Fsp from 'fs/promises'; import type { Task } from '../lib'; import { downloadToDisk, downloadToString } from '../lib'; @@ -60,18 +59,28 @@ export const DownloadCloudDependencies: Task = { let manifestUrl = ''; let manifestJSON = null; const buildUrl = `https://${subdomain}.elastic.co/beats/latest/${config.getBuildVersion()}.json`; - const axiosConfigWithNoCacheHeaders = { - headers: { - 'Cache-Control': 'no-cache', - Pragma: 'no-cache', - Expires: '0', - }, + const noCacheHeaders = { + 'Cache-Control': 'no-cache', + Pragma: 'no-cache', + Expires: '0', }; try { - const latest = await Axios.get(buildUrl, axiosConfigWithNoCacheHeaders); - buildId = latest.data.build_id; - manifestUrl = latest.data.manifest_url; - manifestJSON = (await Axios.get(manifestUrl, axiosConfigWithNoCacheHeaders)).data; + const latestResp = await fetch(buildUrl, { headers: noCacheHeaders }); + if (!latestResp.ok) { + throw new Error( + `Failed to fetch ${buildUrl}: ${latestResp.status} ${latestResp.statusText}` + ); + } + const latest = await latestResp.json(); + buildId = latest.build_id; + manifestUrl = latest.manifest_url; + const manifestResp = await fetch(manifestUrl, { headers: noCacheHeaders }); + if (!manifestResp.ok) { + throw new Error( + `Failed to fetch manifest: ${manifestResp.status} ${manifestResp.statusText}` + ); + } + manifestJSON = await manifestResp.json(); if (!(manifestUrl && manifestJSON)) throw new Error('Missing manifest.'); } catch (e) { log.error(`Unable to find Beats artifacts for ${config.getBuildVersion()} at ${buildUrl}.`); diff --git a/src/dev/prs/github_api.ts b/src/dev/prs/github_api.ts index 93f5c34795d66..43ceb17a0c94e 100644 --- a/src/dev/prs/github_api.ts +++ b/src/dev/prs/github_api.ts @@ -7,52 +7,58 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { AxiosError, AxiosResponse, AxiosInstance } from 'axios'; -import axios from 'axios'; - import { createFailError } from '@kbn/dev-cli-errors'; -interface ResponseError extends AxiosError { - request: any; - response: AxiosResponse; +interface ResponseError extends Error { + status: number; + headers: Headers; } const isResponseError = (error: any): error is ResponseError => - error && error.response && error.response.status; + error && typeof error.status === 'number'; const isRateLimitError = (error: any) => isResponseError(error) && - error.response.status === 403 && - `${error.response.headers['X-RateLimit-Remaining']}` === '0'; + error.status === 403 && + `${error.headers.get('X-RateLimit-Remaining')}` === '0'; export class GithubApi { - private api: AxiosInstance; + private baseURL = 'https://api.github.com/'; + private defaultHeaders: Record; constructor(private accessToken?: string) { - this.api = axios.create({ - baseURL: 'https://api.github.com/', - allowAbsoluteUrls: false, - headers: { - Accept: 'application/vnd.github.v3+json', - 'User-Agent': 'kibana/update_prs_cli', - ...(this.accessToken ? { Authorization: `token ${this.accessToken} ` } : {}), - }, - }); + this.defaultHeaders = { + Accept: 'application/vnd.github.v3+json', + 'User-Agent': 'kibana/update_prs_cli', + ...(this.accessToken ? { Authorization: `token ${this.accessToken} ` } : {}), + }; + } + + private async request(path: string): Promise { + const url = `${this.baseURL}${path}`; + const response = await fetch(url, { headers: this.defaultHeaders }); + if (!response.ok) { + const err = new Error(`Request failed with status ${response.status}`) as any; + err.status = response.status; + err.headers = response.headers; + throw err; + } + return response.json(); } async getPrInfo(prNumber: number) { try { - const resp = await this.api.get(`repos/elastic/kibana/pulls/${prNumber}`); - const targetRef: string = resp.data.base && resp.data.base.ref; + const data = await this.request(`repos/elastic/kibana/pulls/${prNumber}`); + const targetRef: string = data.base && data.base.ref; if (!targetRef) { throw new Error('unable to read base ref from pr info'); } - const owner: string = resp.data.head && resp.data.head.user && resp.data.head.user.login; + const owner: string = data.head && data.head.user && data.head.user.login; if (!owner) { throw new Error('unable to read owner info from pr info'); } - const sourceBranch: string = resp.data.head.ref; + const sourceBranch: string = data.head.ref; if (!sourceBranch) { throw new Error('unable to read source branch name from pr info'); } diff --git a/src/platform/packages/private/kbn-ci-stats-reporter/src/ci_stats_reporter.ts b/src/platform/packages/private/kbn-ci-stats-reporter/src/ci_stats_reporter.ts index b54768168db94..0ecbba41927aa 100644 --- a/src/platform/packages/private/kbn-ci-stats-reporter/src/ci_stats_reporter.ts +++ b/src/platform/packages/private/kbn-ci-stats-reporter/src/ci_stats_reporter.ts @@ -14,8 +14,6 @@ import Path from 'path'; import crypto from 'crypto'; import execa from 'execa'; -import type { AxiosRequestConfig } from 'axios'; -import Axios from 'axios'; import { REPO_ROOT, kibanaPackageJson } from '@kbn/repo-info'; import type { Config, CiStatsMetadata } from '@kbn/ci-stats-core'; import { parseConfig } from '@kbn/ci-stats-core'; @@ -112,7 +110,7 @@ interface ReqOptions { path: string; body: any; bodyDesc: string; - query?: AxiosRequestConfig['params']; + query?: Record; timeout?: number; } @@ -362,11 +360,11 @@ export class CiStatsReporter { let attempt = 0; const maxAttempts = 5; - let headers; + const headers: Record = { + 'Content-Type': typeof body === 'string' ? 'text/plain' : 'application/json', + }; if (auth && this.config) { - headers = { - Authorization: `token ${this.config.apiToken}`, - }; + headers.Authorization = `token ${this.config.apiToken}`; } else if (auth) { throw new Error('this.req() shouldnt be called with auth=true if this.config is not defined'); } @@ -375,37 +373,64 @@ export class CiStatsReporter { attempt += 1; try { - const resp = await Axios.request({ - method: 'POST', - url: path, - baseURL: BASE_URL, - allowAbsoluteUrls: false, - headers, - data: body, - params: query, - adapter: 'http', - - // if it can be serialized into a string, send it - maxBodyLength: Infinity, - maxContentLength: Infinity, - timeout, - }); - - return resp.data; - } catch (error) { - if (!error?.request) { - // not an axios error, must be a usage error that we should notify user about + const queryString = query + ? '?' + + new URLSearchParams( + Object.entries(query) + .filter(([, v]) => v !== undefined) + .map(([k, v]) => [k, String(v)]) + ).toString() + : ''; + const url = `${BASE_URL}${path}${queryString}`; + + const controller = new AbortController(); + const timeoutId = globalThis.setTimeout(() => controller.abort(), timeout); + + let resp: Response; + try { + resp = await fetch(url, { + method: 'POST', + headers, + body: typeof body === 'string' ? body : JSON.stringify(body), + signal: controller.signal, + }); + } finally { + clearTimeout(timeoutId); + } + + if (!resp.ok) { + const respBody = await resp.text().catch(() => ''); + let parsedBody: unknown; + try { + parsedBody = JSON.parse(respBody); + } catch { + parsedBody = respBody; + } + + if (resp.status < 500) { + this.log.warning( + `error reporting ${bodyDesc} [status=${resp.status}] [resp=${inspect(parsedBody)}]` + ); + return; + } + + const error = new Error(`Request failed with status ${resp.status}`); + (error as any).response = { status: resp.status, data: parsedBody }; throw error; } - if (error?.response && error.response.status < 500) { - // error response from service was received so warn the user and move on - this.log.warning( - `error reporting ${bodyDesc} [status=${error.response.status}] [resp=${inspect( - error.response.data - )}]` - ); - return; + const text = await resp.text(); + try { + return JSON.parse(text) as T; + } catch { + return text as unknown as T; + } + } catch (error) { + if (error?.name === 'AbortError') { + // timeout - treat as network failure, fall through to retry + } else if (!error?.response) { + // not a response error, must be a usage error that we should notify user about + throw error; } if (attempt === maxAttempts) { @@ -425,7 +450,7 @@ export class CiStatsReporter { `failed to reach ci-stats service, retrying in ${seconds} seconds, [reason=${reason}], [error=${error.message}]` ); - await new Promise((resolve) => setTimeout(resolve, seconds * 1000)); + await new Promise((resolve) => globalThis.setTimeout(resolve, seconds * 1000)); } } } 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..4e8567e717817 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 { response?: { status?: number } }).response?.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..41148aa59d6a4 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,9 @@ 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) { + const setCookie = headers.get('set-cookie'); + return setCookie?.toString().split(';')[0].split('sid=')[1] ?? ''; } export class AuthService extends FtrService { private readonly config = this.ctx.getService('config'); @@ -41,15 +40,14 @@ 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: { + const authResponse = await fetch(loginUrl.href, { + method: 'POST', + body: JSON.stringify({ providerType: 'basic', providerName: provider, currentURL: new URL('/login?next=%2F', baseUrl).href, params: credentials ?? { username: this.getUsername(), password: this.getPassword() }, - }, + }), headers: { 'content-type': 'application/json', 'kbn-version': version, @@ -57,8 +55,7 @@ export class AuthService extends FtrService { 'sec-fetch-site': 'same-origin', 'x-elastic-internal-origin': 'Kibana', }, - validateStatus: () => true, - maxRedirects: 0, + redirect: 'manual', }); if (authResponse.status !== 200) { @@ -67,15 +64,16 @@ export class AuthService extends FtrService { ); } - const cookie = extractCookieValue(authResponse); + const cookie = extractCookieValue(authResponse.headers); if (cookie) { this.log.info('captured auth cookie'); } else { + const body = await authResponse.text(); this.log.error( format('unable to determine auth cookie from response', { status: `${authResponse.status} ${authResponse.statusText}`, - body: authResponse.data, - headers: authResponse.headers, + body, + headers: Object.fromEntries(authResponse.headers.entries()), }) ); diff --git a/src/platform/packages/shared/kbn-cypress-test-helper/src/error/format_axios_error.ts b/src/platform/packages/shared/kbn-cypress-test-helper/src/error/format_axios_error.ts index 55c721e26ad44..e816bb892d1df 100644 --- a/src/platform/packages/shared/kbn-cypress-test-helper/src/error/format_axios_error.ts +++ b/src/platform/packages/shared/kbn-cypress-test-helper/src/error/format_axios_error.ts @@ -7,9 +7,21 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { AxiosError } from 'axios'; import { EndpointError } from './errors'; +/** + * Checks if an error object has HTTP response-like properties (e.g. from KbnClient or axios). + */ +const isHttpRequestError = ( + error: any +): error is Error & { + config?: { method?: string; url?: string; data?: unknown }; + response?: { status?: number; statusText?: string; data?: any }; + status?: number; +} => { + return error instanceof Error && ('response' in error || 'config' in error); +}; + export class FormattedAxiosError extends EndpointError { public readonly request: { method: string; @@ -22,27 +34,28 @@ export class FormattedAxiosError extends EndpointError { data: any; }; - constructor(axiosError: AxiosError) { - const method = axiosError.config?.method ?? ''; - const url = axiosError.config?.url ?? ''; + constructor(httpError: Error & Record) { + const method = httpError.config?.method ?? ''; + const url = httpError.config?.url ?? ''; + const responseData = httpError.response?.data; super( - `${axiosError.message}${ - axiosError?.response?.data ? `: ${JSON.stringify(axiosError?.response?.data)}` : '' - }${url ? `\n(Request: ${method} ${url})` : ''}`, - axiosError + `${httpError.message}${responseData ? `: ${JSON.stringify(responseData)}` : ''}${ + url ? `\n(Request: ${method} ${url})` : '' + }`, + httpError ); this.request = { method, url, - data: axiosError.config?.data ?? '', + data: httpError.config?.data ?? '', }; this.response = { - status: axiosError?.response?.status ?? 0, - statusText: axiosError?.response?.statusText ?? '', - data: axiosError?.response?.data, + status: httpError.response?.status ?? httpError.status ?? 0, + statusText: httpError.response?.statusText ?? '', + data: responseData, }; this.name = this.constructor.name; @@ -62,11 +75,13 @@ export class FormattedAxiosError extends EndpointError { } /** - * Used with `promise.catch()`, it will format the Axios error to a new error and will re-throw + * Used with `promise.catch()`, it will format the error to a new error and will re-throw. + * If the error has HTTP request/response properties (e.g. from KbnClient), it will be + * formatted as a `FormattedAxiosError` with request and response details. * @param error */ export const catchAxiosErrorFormatAndThrow = (error: Error): never => { - if (error instanceof AxiosError) { + if (isHttpRequestError(error)) { throw new FormattedAxiosError(error); } 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..33125e8f6e762 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 { ReqOptions, KbnClientResponse } 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-dev-utils/src/axios/errors.ts b/src/platform/packages/shared/kbn-dev-utils/src/axios/errors.ts index 3fbd3cb7ac7ce..e6eac3023500a 100644 --- a/src/platform/packages/shared/kbn-dev-utils/src/axios/errors.ts +++ b/src/platform/packages/shared/kbn-dev-utils/src/axios/errors.ts @@ -7,14 +7,23 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { AxiosError, AxiosResponse } from 'axios'; - -export interface AxiosRequestError extends AxiosError { +export interface AxiosRequestError { + config: unknown; response: undefined; + message?: string; + code?: string; } -export interface AxiosResponseError extends AxiosError { - response: AxiosResponse; +export interface AxiosResponseError { + config: unknown; + response: { + status: number; + statusText: string; + headers: unknown; + data: T; + }; + message?: string; + code?: string; } export const isAxiosRequestError = (error: any): error is AxiosRequestError => { 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..8aae986645cb5 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 { ReqOptions, KbnClientResponse, KbnClientResponseType } 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_requester.ts b/src/platform/packages/shared/kbn-kbn-client/src/kbn_client/kbn_client_requester.ts index fbce63a942892..728776838cea7 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,43 @@ */ import Url from 'url'; -import Https from 'https'; import Qs from 'querystring'; -import type { AxiosResponse, ResponseType } from 'axios'; -import Axios from 'axios'; -import { isAxiosRequestError, isAxiosResponseError } from '@kbn/dev-utils'; +import { Agent } 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 - ); +export interface KbnClientResponse { + data: T; + status: number; + statusText: string; + headers: Record; +} + +const headersToRecord = (headers: Headers): Record => { + const result: Record = {}; + headers.forEach((value, key) => { + result[key] = value; + }); + return result; +}; + +const isResponseError = ( + error: any +): error is { response: KbnClientResponse; config?: { method: string; url: string } } => { + return error && error.response && error.response.status !== undefined; +}; + +const isRequestError = (error: any): boolean => { + return error && error.config && error.response === undefined; +}; + +const isConflictOnGetError = (error: any) => { + return isResponseError(error) && error.config?.method === 'GET' && error.response.status === 409; }; const isIgnorableError = (error: any, ignorableErrors: number[] = []) => { - return isAxiosResponseError(error) && ignorableErrors.includes(error.response.status); + return isResponseError(error) && ignorableErrors.includes(error.response.status); }; /** @@ -66,6 +86,8 @@ export const uriencode = ( const DEFAULT_MAX_ATTEMPTS = 5; +export type KbnClientResponseType = 'json' | 'text' | 'blob' | 'stream' | 'arraybuffer'; + export interface ReqOptions { description?: string; path: string; @@ -75,7 +97,7 @@ export interface ReqOptions { retries?: number; headers?: Record; ignoreErrors?: number[]; - responseType?: ResponseType; + responseType?: KbnClientResponseType; signal?: AbortSignal; } @@ -91,15 +113,17 @@ interface Options { export class KbnClientRequester { private readonly url: string; - private readonly httpsAgent: Https.Agent | null; + private readonly dispatcher: Agent | null; constructor(private readonly log: ToolingLog, options: Options) { this.url = options.url; - this.httpsAgent = + this.dispatcher = Url.parse(options.url).protocol === 'https:' - ? new Https.Agent({ - ca: options.certificateAuthorities, - rejectUnauthorized: false, + ? new Agent({ + connect: { + ca: options.certificateAuthorities?.map((buf) => buf.toString()), + rejectUnauthorized: false, + }, }) : null; } @@ -117,7 +141,7 @@ export class KbnClientRequester { return Url.resolve(baseUrl, relative); } - async request(options: ReqOptions): Promise> { + async request(options: ReqOptions): Promise> { const url = this.resolveUrl(options.path); const redacted = redactUrl(url); let attempt = 0; @@ -126,7 +150,7 @@ export class KbnClientRequester { redacted, maxAttempts, requestedRetries: options.retries !== undefined, - failedToGetResponseSvc: (error: Error) => isAxiosRequestError(error), + failedToGetResponseSvc: (error: Error) => isRequestError(error), ...options, }); @@ -134,18 +158,42 @@ export class KbnClientRequester { attempt += 1; try { this.log.debug(`Requesting url (redacted): [${redacted}]`); - return await Axios.request(buildRequest(url, this.httpsAgent, options)); + const { fetchUrl, init } = buildRequest(url, this.dispatcher, options); + const response = await fetch(fetchUrl, init as RequestInit); + + if (!response.ok) { + const responseData = await parseResponseBody(response, options.responseType); + const error: any = new Error( + `Request failed with status ${response.status}: ${response.statusText}` + ); + error.response = { + data: responseData, + status: response.status, + statusText: response.statusText, + headers: headersToRecord(response.headers), + }; + error.config = { method: options.method, url }; + throw error; + } + + const data = await parseResponseBody(response, options.responseType); + return { + data, + status: response.status, + statusText: response.statusText, + headers: headersToRecord(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) + const statusCode = isResponseError(error) ? error.response.status : 'N/A'; + const errorCause = (error as any).code || (error as Error).message || 'Unknown error'; + const responseBody = isResponseError(error) ? JSON.stringify(error.response.data, null, 2) : 'No response body'; const errorDetails = `Status: ${statusCode}, Cause: ${errorCause}, Response: ${responseBody}`; this.log.debug(`Request failed - ${errorDetails}, Attempt: ${attempt}/${maxAttempts}`); - if (isIgnorableError(error, options.ignoreErrors)) return error.response; + if (isIgnorableError(error, options.ignoreErrors)) return (error as any).response; if (attempt < maxAttempts) { await delay(1000 * attempt); continue; @@ -159,6 +207,31 @@ export class KbnClientRequester { } } +async function parseResponseBody( + response: Response, + responseType?: KbnClientResponseType +): Promise { + if (responseType === 'text') { + return (await response.text()) as unknown as T; + } + if (responseType === 'blob') { + return (await response.blob()) as unknown as T; + } + if (responseType === 'arraybuffer') { + return (await response.arrayBuffer()) as unknown as T; + } + if (responseType === 'stream') { + return response.body as unknown as T; + } + // Default: parse as JSON, fall back to text if it fails + 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 +247,7 @@ export function errMsg({ failedToGetResponseSvc: (x: Error) => boolean; }) { return function errMsgOrReThrow(attempt: number, _: any) { - const result = isConcliftOnGetError(_) + const result = isConflictOnGetError(_) ? `Conflict on GET (path=${path}, attempt=${attempt}/${maxAttempts})` : requestedRetries || failedToGetResponseSvc(_) ? `[${ @@ -192,26 +265,53 @@ export function redactUrl(_: string): string { } export function buildRequest( - url: any, - httpsAgent: Https.Agent | null, - { method, body, query, headers, responseType }: any -) { - return { + url: string, + dispatcher: Agent | null, + { method, body, query, headers, responseType, signal }: ReqOptions +): { fetchUrl: string; init: RequestInit & { dispatcher?: Agent } } { + const queryString = query ? Qs.stringify(query) : ''; + const fetchUrl = queryString ? `${url}?${queryString}` : url; + + let processedBody: any; + if (body !== undefined) { + // FormData instances (from form-data package) should be passed through directly + if (typeof body === 'object' && typeof body.getBuffer === 'function') { + processedBody = body.getBuffer(); + } else if (typeof body === 'string') { + processedBody = body; + } else { + processedBody = JSON.stringify(body); + } + } + + const mergedHeaders: Record = { + ...headers, + 'kbn-xsrf': 'kbn-client', + 'x-elastic-internal-origin': 'kbn-client', + }; + + // If the body is JSON and no content-type is set, add it + if ( + body !== undefined && + typeof body === 'object' && + typeof body.getBuffer !== 'function' && + !mergedHeaders['content-type'] && + !mergedHeaders['Content-Type'] + ) { + mergedHeaders['content-type'] = 'application/json'; + } + + const init: RequestInit & { dispatcher?: Agent } = { 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), + headers: mergedHeaders, + body: processedBody, + redirect: 'manual', + signal, }; + + if (dispatcher) { + init.dispatcher = dispatcher; + } + + return { fetchUrl, init }; } 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..73b6ac9137aad 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,47 @@ * 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('preserves status from response-like errors', () => { + const original: any = new Error('Not Found'); + original.response = { status: 404, statusText: 'Not Found', data: { statusCode: 404 } }; + original.config = { method: 'GET', url: 'http://localhost/api/test' }; + + const wrapped = new KbnClientRequesterError('wrapper message', original); + + expect(wrapped.responseError).toBeDefined(); + expect(wrapped.responseError!.status).toBe(404); + }); + + it('provides backward-compatible axiosError getter', () => { + const original: any = new Error('Not Found'); + original.response = { status: 404 }; const wrapped = new KbnClientRequesterError('wrapper message', original); expect(wrapped.axiosError).toBeDefined(); expect(wrapped.axiosError!.status).toBe(404); - expect((wrapped.axiosError as any).response).toBeUndefined(); + expect(wrapped.axiosError!.response).toBeUndefined(); + }); + + it('handles errors without response', () => { + const original = new Error('Connection refused'); + + const wrapped = new KbnClientRequesterError('wrapper message', original); + + expect(wrapped.responseError).toBeDefined(); + expect(wrapped.responseError!.status).toBeUndefined(); + expect(wrapped.responseError!.message).toBe('Connection refused'); + }); + + it('handles non-error objects with response', () => { + const original = { response: { status: 500 }, message: 'Server error', code: 'ERR_SERVER' }; + + const wrapped = new KbnClientRequesterError('wrapper message', original); + + expect(wrapped.responseError).toBeDefined(); + expect(wrapped.responseError!.status).toBe(500); }); }); 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..d532301aaf218 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,47 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { AxiosError, isAxiosError } from 'axios'; +export interface KbnClientResponseError { + status?: number; + message: string; + code?: string; +} export class KbnClientRequesterError extends Error { - axiosError?: AxiosError; + responseError?: KbnClientResponseError; + /** + * @deprecated Use `responseError.status` instead. Kept for backward compatibility. + */ + public get axiosError(): { status?: number; response?: undefined } | undefined { + if (!this.responseError) return undefined; + return { status: this.responseError.status }; + } constructor(message: string, error: unknown) { super(message); this.name = 'KbnClientRequesterError'; - if (isAxiosError(error)) this.axiosError = clean(error); + if (isResponseLikeError(error)) { + this.responseError = 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; - } - delete _.cause; - delete _.config; - delete _.request; - delete _.response; - return _; + +interface ResponseLikeError { + message?: string; + code?: string; + response?: { status?: number }; +} + +const isResponseLikeError = (error: unknown): error is ResponseLikeError => { + return ( + error instanceof Error || (typeof error === 'object' && error !== null && 'response' in error) + ); +}; + +function clean(error: ResponseLikeError): KbnClientResponseError { + const status = (error as any).response?.status ?? (error as any).status; + return { + status, + message: error.message ?? 'Unknown error', + code: error.code, + }; } 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..c3a331e090f2a 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 fetchMock = jest.spyOn(global, 'fetch'); describe('fetchKibanaVersionHeaderString', () => { const log = new ToolingLog(); @@ -22,11 +20,13 @@ describe('fetchKibanaVersionHeaderString', () => { }); test('returns version.number and appends -SNAPSHOT when build_snapshot is true', async () => { - mockedAxios.request.mockResolvedValue({ - data: { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValue({ version: { number: '9.0.0', build_snapshot: true }, - }, - }); + }), + } as unknown as Response); const v = await fetchKibanaVersionHeaderString( 'https://localhost:5601', @@ -36,36 +36,40 @@ describe('fetchKibanaVersionHeaderString', () => { ); expect(v).toBe('9.0.0-SNAPSHOT'); - expect(mockedAxios.request).toHaveBeenCalledTimes(1); - expect(mockedAxios.request).toHaveBeenCalledWith( + expect(fetchMock).toHaveBeenCalledTimes(1); + const [callUrl, callOptions] = fetchMock.mock.calls[0]; + expect(callUrl).toContain('/api/status'); + expect(callUrl).toContain('v8format=true'); + expect(callOptions).toEqual( 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'); + const headers = callOptions?.headers as Record; + expect(headers.Authorization).toMatch(/^Basic /); }); test('throws when version is missing from response body', async () => { - mockedAxios.request.mockResolvedValue({ data: {} }); + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValue({}), + } as unknown as Response); await expect( fetchKibanaVersionHeaderString('http://localhost:5601', 'u', 'p', log) ).rejects.toThrow(/Unable to get version from Kibana/); - expect(mockedAxios.request).toHaveBeenCalledTimes(1); + expect(fetchMock).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 () => { + fetchMock.mockRejectedValue(new Error('network down')); await expect( fetchKibanaVersionHeaderString('http://localhost:5601', 'u', 'p', log) ).rejects.toThrow('network down'); - expect(mockedAxios.request).toHaveBeenCalledTimes(1); + expect(fetchMock).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..875fbb8bc2696 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,33 @@ export async function fetchKibanaVersionHeaderString( url.searchParams.set('v8format', 'true'); const isHttps = url.protocol === 'https:'; - const httpsAgent = isHttps - ? new https.Agent({ - rejectUnauthorized: false, + 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.toString(), { 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; + if (response.status !== 200 && response.status !== 503) { + throw new Error( + `Unexpected status ${response.status} from Kibana status API: ${response.statusText}` + ); + } + + const data: KibanaStatusResponse = await response.json(); 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..3776d6cafdb0f 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,51 @@ 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 createMockResponse = (options: { + status?: number; + data?: any; + headers?: Record; + contentType?: string; +}): Response => { + const { status = 200, data, headers = {}, contentType = 'application/json' } = options; + const responseHeaders = new Headers({ 'content-type': contentType, ...headers }); + const body = typeof data === 'string' ? data : JSON.stringify(data); + return { + ok: status >= 200 && status < 300, + status, + statusText: 'OK', + headers: responseHeaders, + json: jest.fn().mockResolvedValue( + typeof data === 'object' + ? data + : (() => { + try { + return JSON.parse(data as string); + } catch { + return data; + } + })() + ), + text: jest.fn().mockResolvedValue(body), + clone: jest.fn(), + body: null, + bodyUsed: false, + arrayBuffer: jest.fn(), + blob: jest.fn(), + formData: jest.fn(), + redirected: false, + type: 'basic', + url: '', + bytes: jest.fn(), + } as unknown as Response; +}; + describe('saml_auth', () => { const log = new ToolingLog(); @@ -34,10 +70,9 @@ describe('saml_auth', () => { }); test('returns token value', async () => { - axiosRequestMock.mockResolvedValueOnce({ - data: { token: 'mocked_token' }, - status: 200, - }); + fetchMock.mockResolvedValueOnce( + createMockResponse({ data: { token: 'mocked_token' }, status: 200 }) + ); const sessionToken = await createCloudSession({ hostname: 'cloud', @@ -46,17 +81,16 @@ describe('saml_auth', () => { log, }); expect(sessionToken).toBe('mocked_token'); - expect(axiosRequestMock).toBeCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(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(createMockResponse({ data: { message: 'no token' }, status: 503 })) + .mockResolvedValueOnce(createMockResponse({ data: { message: 'no token' }, status: 503 })) + .mockResolvedValueOnce( + createMockResponse({ data: { token: 'mocked_token' }, status: 200 }) + ); const sessionToken = await createCloudSession( { @@ -72,11 +106,13 @@ describe('saml_auth', () => { ); expect(sessionToken).toBe('mocked_token'); - expect(axiosRequestMock).toBeCalledTimes(3); + expect(fetchMock).toHaveBeenCalledTimes(3); }); test('retries and throws error when response code is not 200', async () => { - axiosRequestMock.mockResolvedValue({ data: { message: 'no token' }, status: 503 }); + fetchMock.mockResolvedValue( + createMockResponse({ data: { message: 'no token' }, status: 503 }) + ); await expect( createCloudSession( @@ -94,14 +130,16 @@ 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).toHaveBeenCalledTimes(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, - }); + fetchMock.mockResolvedValue( + createMockResponse({ + data: { user_id: 1234, okta_session_id: 5678, authenticated: false }, + status: 200, + }) + ); await expect( createCloudSession( @@ -119,11 +157,13 @@ 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).toHaveBeenCalledTimes(3); }); test(`throws error when retry 'attemptsCount' is below 1`, async () => { - axiosRequestMock.mockResolvedValue({ data: { message: 'no token' }, status: 503 }); + fetchMock.mockResolvedValue( + createMockResponse({ data: { message: 'no token' }, status: 503 }) + ); await expect( createCloudSession( @@ -144,10 +184,12 @@ 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( + createMockResponse({ + data: { user_id: 12345, authenticated: false, mfa_required: true }, + status: 200, + }) + ); await expect( createCloudSession( @@ -165,7 +207,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).toHaveBeenCalledTimes(1); }); }); @@ -175,14 +217,16 @@ 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( + createMockResponse({ + data: { + location: 'https://cloud.test/saml?SAMLRequest=fVLLbtswEPwVgXe9K6%2F', + }, + headers: { + 'set-cookie': `sid=Fe26.2**1234567890; Secure; HttpOnly; Path=/`, + }, + }) + ); const response = await createSAMLRequest('https://kbn.test.co', '8.12.0', log); expect(response).toStrictEqual({ @@ -192,12 +236,13 @@ 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( + createMockResponse({ + data: { + location: 'https://cloud.test/saml?SAMLRequest=fVLLbtswEPwVgXe9K6%2F', + }, + }) + ); 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:/ @@ -205,14 +250,16 @@ describe('saml_auth', () => { }); 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( + createMockResponse({ + data: { + location: 'http/.test', + }, + headers: { + 'set-cookie': `sid=Fe26.2**1234567890; Secure; HttpOnly; Path=/`, + }, + }) + ); expect(createSAMLRequest('https://kbn.test.co', '8.12.0', log)).rejects.toThrow( `Location from Kibana SAML request is not a valid url: http/.test` @@ -221,12 +268,14 @@ describe('saml_auth', () => { 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( + createMockResponse({ + data, + headers: { + 'set-cookie': `sid=Fe26.2**1234567890; Secure; HttpOnly; Path=/`, + }, + }) + ); 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 +298,24 @@ describe('saml_auth', () => { }; test('returns valid saml response', async () => { - axiosGetMock.mockResolvedValueOnce({ - data: `Test`, - }); + fetchMock.mockResolvedValueOnce( + createMockResponse({ + data: `Test`, + contentType: 'text/html', + }) + ); 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( + createMockResponse({ + data: `Test`, + contentType: 'text/html', + }) + ); 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 +341,69 @@ 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( + createMockResponse({ + status: 302, + headers: { + 'set-cookie': `sid=${cookieStr}; Secure; HttpOnly; Path=/`, + }, + contentType: 'text/plain', + }) + ); 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(createMockResponse({ status: 401, contentType: 'text/plain' })); 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(createMockResponse({ status: 503, contentType: 'text/plain' })) // First attempt fails (5xx), retrying + .mockResolvedValueOnce( + createMockResponse({ + status: 302, + headers: { + 'set-cookie': `sid=${cookieStr}; Secure; HttpOnly; Path=/`, + }, + contentType: 'text/plain', + }) + ); // Second attempt succeeds 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(createMockResponse({ status: 503, contentType: 'text/plain' })); 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(createMockResponse({ status: 503, contentType: 'text/plain' })) // First attempt fails (5xx), retrying + .mockResolvedValueOnce(createMockResponse({ status: 400, contentType: 'text/plain' })); // Second attempt gets a 4xx (stop retrying) 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..b348c15114513 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'; @@ -45,13 +43,13 @@ export class Session { const REQUEST_TIMEOUT_MS = 60_000; const cleanException = (url: string, ex: any) => { - if (ex.isAxiosError) { + if (ex.isFetchError) { ex.url = url; - if (ex.response?.data) { - if (ex.response.data?.message) { - ex.response_message = ex.response.data.message; + if (ex.responseData) { + if (ex.responseData?.message) { + ex.response_message = ex.responseData.message; } else { - ex.data = ex.response.data; + ex.data = ex.responseData; } } ex.config = { REDACTED: 'REDACTED' }; @@ -60,15 +58,47 @@ const cleanException = (url: string, ex: any) => { } }; -const getCookieFromResponseHeaders = (response: AxiosResponse, errorMessage: string) => { - const setCookieHeader = response?.headers['set-cookie']; +interface FetchResponse { + status: number; + headers: Headers; + data: any; +} + +const fetchWithTimeout = async ( + url: string, + options: RequestInit & { timeout?: number } = {} +): Promise => { + const { timeout = REQUEST_TIMEOUT_MS, ...fetchOptions } = options; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + let response: Response; + try { + response = await fetch(url, { + ...fetchOptions, + signal: controller.signal, + }); + } finally { + clearTimeout(timeoutId); + } + const contentType = response.headers.get('content-type') || ''; + let data: any; + if (contentType.includes('application/json')) { + data = await response.json(); + } else { + data = await response.text(); + } + return { status: response.status, headers: response.headers, data }; +}; + +const getCookieFromResponseHeaders = (response: FetchResponse, errorMessage: string) => { + const setCookieHeader = response?.headers.get('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(setCookieHeader); if (!cookie) { throw new Error(errorMessage); } @@ -93,29 +123,24 @@ 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 sessionResponse: FetchResponse | undefined; let attemptsLeft = retryParams.attemptsCount; while (attemptsLeft > 0) { try { - sessionResponse = await axios.request(requestConfig(cloudLoginUrl)); + sessionResponse = await fetchWithTimeout(cloudLoginUrl, { + method: 'POST', + timeout: REQUEST_TIMEOUT_MS, + body: JSON.stringify({ + email, + password, + }), + headers: { + accept: 'application/json', + 'content-type': 'application/json', + }, + redirect: 'manual', + }); if (sessionResponse?.status !== 200) { throw new Error( `Failed to create the new cloud session: 'POST ${cloudLoginUrl}' returned ${sessionResponse?.status}` @@ -176,24 +201,22 @@ export const createCloudSession = async ( }; export const createSAMLRequest = async (kbnUrl: string, kbnVersion: string, log: ToolingLog) => { - let samlResponse: AxiosResponse; + let samlResponse: FetchResponse; const url = kbnUrl + '/internal/security/login'; try { - samlResponse = await axios.request({ - url, - method: 'post', - data: { + samlResponse = await fetchWithTimeout(url, { + method: 'POST', + body: JSON.stringify({ providerType: 'saml', providerName: 'cloud-saml-kibana', currentURL: kbnUrl + '/login?next=%2F"', - }, + }), headers: { 'kbn-version': kbnVersion, 'x-elastic-internal-origin': 'Kibana', 'content-type': 'application/json', }, - validateStatus: () => true, - maxRedirects: 0, + redirect: 'manual', }); } catch (ex) { log.error('Failed to create SAML request'); @@ -220,33 +243,36 @@ 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 response = 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}`; + const html = await response.text(); + + if (!response.ok) { + const requestId = response.headers.get('x-request-id') || 'not found'; + const responseStatus = response.status; + let logMessage = `Create SAML Response (${location}) failed with status code ${responseStatus}: ${html}`; // If response is 3XX, also log the Location header from response if (responseStatus >= 300 && responseStatus < 400) { - const locationHeader = err?.response?.headers?.location || 'not found'; + const locationHeader = response.headers.get('location') || 'not found'; logMessage += `.\nLocation: ${locationHeader}`; } logMessage += `.\nX-Request-ID: ${requestId}`; - log.error(logMessage); + } else { + const $ = cheerio.load(html); + value = $('input').attr('value'); } + } catch (err) { + // Network errors or other unexpected failures + log.error(`Create SAML Response (${location}) failed: ${err.message}`); } if (!value) { @@ -270,23 +296,23 @@ 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', + body: `SAMLResponse=${encodedResponse}`, headers: { 'content-type': 'application/x-www-form-urlencoded', ...(sid ? { Cookie: `sid=${sid}` } : {}), }, - validateStatus: () => true, - maxRedirects: 0, + redirect: 'manual', }; - let authResponse: AxiosResponse; + let authResponse: FetchResponse; let attemptsLeft = maxRetryCount + 1; while (attemptsLeft > 0) { try { - authResponse = await axios.request(request); + const rawResponse = await fetch(url, requestInit); + const data = await rawResponse.text(); + authResponse = { status: rawResponse.status, headers: rawResponse.headers, data }; // SAML callback should return 302 if (authResponse.status === 302) { return getCookieFromResponseHeaders( @@ -315,8 +341,8 @@ 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)}`); + // 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(requestInit)}`); throw ex; } } @@ -335,23 +361,24 @@ export const getSecurityProfile = async ({ cookie: Cookie; log: ToolingLog; }) => { - let meResponse: AxiosResponse; const url = kbnHost + '/internal/security/me'; try { - meResponse = (await axios.get(url, { + const response = await fetch(url, { headers: { Cookie: cookie.cookieString(), 'x-elastic-internal-origin': 'Kibana', 'content-type': 'application/json', }, - })) as AxiosResponse; + }); + if (!response.ok) { + throw new Error(`Failed to fetch user profile: ${response.status} ${response.statusText}`); + } + return (await response.json()) as UserProfile; } catch (ex) { log.error('Failed to fetch user profile data'); cleanException(url, ex); throw ex; } - - return meResponse.data; }; export const createCloudSAMLSession = async (params: CloudSamlSessionParams) => { diff --git a/src/platform/plugins/shared/workflows_management/server/connectors/workflows/utils.test.ts b/src/platform/plugins/shared/workflows_management/server/connectors/workflows/utils.test.ts index 042ca6874b762..6832e4e0c0084 100644 --- a/src/platform/plugins/shared/workflows_management/server/connectors/workflows/utils.test.ts +++ b/src/platform/plugins/shared/workflows_management/server/connectors/workflows/utils.test.ts @@ -7,48 +7,42 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { AxiosError } from 'axios'; import { createServiceError } from './utils'; describe('Workflows Utils', () => { describe('createServiceError', () => { - it('should create error with Axios error message', () => { - const axiosError = { - isAxiosError: true, + it('should create error with response error message', () => { + const fetchError = Object.assign(new Error('Request failed'), { response: { data: { message: 'API Error: Invalid request', }, }, - message: 'Request failed', - } as AxiosError; + }); - const result = createServiceError(axiosError, 'Operation failed'); + const result = createServiceError(fetchError, 'Operation failed'); expect(result.message).toBe('Operation failed. Error: API Error: Invalid request'); }); - it('should use Axios error message when response data has no message', () => { - const axiosError = { - isAxiosError: true, + it('should use error message when response data has no message', () => { + const fetchError = Object.assign(new Error('Network error'), { response: { data: {}, }, - message: 'Network error', - } as AxiosError; + }); - const result = createServiceError(axiosError, 'Operation failed'); + const result = createServiceError(fetchError, 'Operation failed'); expect(result.message).toBe('Operation failed. Error: Network error'); }); - it('should handle Axios error without response data', () => { - const axiosError = { - isAxiosError: true, - message: 'Connection timeout', - } as AxiosError; + it('should handle error without response data', () => { + const fetchError = Object.assign(new Error('Connection timeout'), { + response: undefined, + }); - const result = createServiceError(axiosError, 'Operation failed'); + const result = createServiceError(fetchError, 'Operation failed'); expect(result.message).toBe('Operation failed. Error: Connection timeout'); }); diff --git a/src/platform/plugins/shared/workflows_management/server/connectors/workflows/utils.ts b/src/platform/plugins/shared/workflows_management/server/connectors/workflows/utils.ts index d9c9e017fd8d9..b65948b4ba26a 100644 --- a/src/platform/plugins/shared/workflows_management/server/connectors/workflows/utils.ts +++ b/src/platform/plugins/shared/workflows_management/server/connectors/workflows/utils.ts @@ -7,10 +7,20 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { isAxiosError } from 'axios'; +interface ErrorWithResponse extends Error { + response?: { + data?: { + message?: string; + }; + }; +} + +function isErrorWithResponse(error: Error): error is ErrorWithResponse { + return 'response' in error; +} export const createServiceError = (error: Error, message: string) => { - if (isAxiosError(error)) { + if (isErrorWithResponse(error)) { const responseData = error.response?.data; const errorMessage = responseData?.message || error.message; return new Error(`${message}. Error: ${errorMessage}`); 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..36d77a2b04e5e 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,7 +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; + const is404Error = err instanceof Error && (err as any).response?.status === 404; if (!is404Error) { throw err; } diff --git a/x-pack/examples/alerting_example/server/rule_types/astros.ts b/x-pack/examples/alerting_example/server/rule_types/astros.ts index 6549041fedf1b..fcbe5fb9d09c9 100644 --- a/x-pack/examples/alerting_example/server/rule_types/astros.ts +++ b/x-pack/examples/alerting_example/server/rule_types/astros.ts @@ -5,7 +5,6 @@ * 2.0. */ -import axios from 'axios'; import type { RuleType, RuleTypeParams, RuleTypeState } from '@kbn/alerting-plugin/server'; import { DEFAULT_AAD_CONFIG, AlertsClientError } from '@kbn/alerting-plugin/server'; import { schema } from '@kbn/config-schema'; @@ -81,10 +80,12 @@ export const ruleType: RuleType< } const { outerSpaceCapacity, craft: craftToTriggerBy, op } = params; - const response = await axios.get('http://api.open-notify.org/astros.json'); - const { - data: { number: peopleInSpace, people = [] }, - } = response; + const response = await fetch('http://api.open-notify.org/astros.json'); + if (!response.ok) { + throw new Error(`Failed to fetch astros data: ${response.status} ${response.statusText}`); + } + const data: PeopleInSpace = await response.json(); + const { number: peopleInSpace, people = [] } = data; const peopleInCraft = people.filter(getCraftFilter(craftToTriggerBy)); diff --git a/x-pack/packages/kbn-synthetics-private-location/src/lib/kibana_api_client.ts b/x-pack/packages/kbn-synthetics-private-location/src/lib/kibana_api_client.ts index 227a4ffa1a73f..370c53572bc11 100644 --- a/x-pack/packages/kbn-synthetics-private-location/src/lib/kibana_api_client.ts +++ b/x-pack/packages/kbn-synthetics-private-location/src/lib/kibana_api_client.ts @@ -9,13 +9,10 @@ import { isError } from 'lodash'; import type { ToolingLog } from '@kbn/tooling-log'; import { KBN_CERT_PATH, KBN_KEY_PATH } from '@kbn/dev-utils'; import fs from 'fs'; -import type { Agent } from 'https'; -import https from 'https'; -import axios from 'axios'; +import { Agent } from 'undici'; export class KibanaAPIClient { - private isHTTPS: boolean; - private httpsAgent: Agent | undefined; + private dispatcher: Agent | undefined; constructor( private kibanaUrl: string, @@ -23,13 +20,14 @@ export class KibanaAPIClient { private kibanaPassword: string, private logger: ToolingLog ) { - this.isHTTPS = new URL(kibanaUrl).protocol === 'https:'; - this.httpsAgent = this.isHTTPS - ? new https.Agent({ - ca: fs.readFileSync(KBN_CERT_PATH), - key: fs.readFileSync(KBN_KEY_PATH), - // hard-coded set to false like in packages/kbn-cli-dev-mode/src/base_path_proxy_server.ts - rejectUnauthorized: false, + const isHTTPS = new URL(kibanaUrl).protocol === 'https:'; + this.dispatcher = isHTTPS + ? new Agent({ + connect: { + ca: fs.readFileSync(KBN_CERT_PATH).toString(), + key: fs.readFileSync(KBN_KEY_PATH).toString(), + rejectUnauthorized: false, + }, }) : undefined; } @@ -45,23 +43,37 @@ export class KibanaAPIClient { data?: Record; headers?: Record; }) { + const basicAuth = Buffer.from(`${this.kibanaUsername}:${this.kibanaPassword}`).toString( + 'base64' + ); + try { - const response = await axios({ + const response = await fetch(`${this.kibanaUrl}/${url}`, { method, - url: `${this.kibanaUrl}/${url}`, - data, headers: { 'kbn-xsrf': 'true', 'elastic-api-version': '2023-10-31', - ...headers, - }, - auth: { - username: this.kibanaUsername, - password: this.kibanaPassword, + 'content-type': 'application/json', + ...((headers as Record) ?? {}), + Authorization: `Basic ${basicAuth}`, }, - httpsAgent: this.httpsAgent, + ...(data !== undefined ? { body: JSON.stringify(data) } : {}), + ...(this.dispatcher ? ({ dispatcher: this.dispatcher } as RequestInit) : {}), }); - return response; + + const responseData = await response.json().catch(() => undefined); + + if (!response.ok) { + throw new Error( + `Request failed with status ${response.status}: ${JSON.stringify(responseData)}` + ); + } + + return { + status: response.status, + data: responseData, + headers: response.headers, + }; } catch (e) { if (isError(e)) { this.logger.error(`Error sending request to Kibana: ${e.message} ${e.stack}`); diff --git a/x-pack/platform/packages/shared/kbn-data-forge/src/lib/install_kibana_assets.ts b/x-pack/platform/packages/shared/kbn-data-forge/src/lib/install_kibana_assets.ts index d2b8ed5bf64ff..838929bb1f6f0 100644 --- a/x-pack/platform/packages/shared/kbn-data-forge/src/lib/install_kibana_assets.ts +++ b/x-pack/platform/packages/shared/kbn-data-forge/src/lib/install_kibana_assets.ts @@ -8,17 +8,15 @@ import fs from 'fs'; // eslint-disable-next-line import/no-extraneous-dependencies import FormData from 'form-data'; -import type { AxiosBasicCredentials } from 'axios'; -import axios from 'axios'; import { isError } from 'lodash'; import { KBN_CERT_PATH, KBN_KEY_PATH } from '@kbn/dev-utils'; import type { ToolingLog } from '@kbn/tooling-log'; -import https from 'https'; +import { Agent } from 'undici'; export async function installKibanaAssets( filePath: string, kibanaUrl: string, - userPassObject: AxiosBasicCredentials, + userPassObject: { username: string; password: string }, logger: ToolingLog ) { try { @@ -30,12 +28,13 @@ export async function installKibanaAssets( formData.append('file', fileStream); const isHTTPS = new URL(kibanaUrl).protocol === 'https:'; - const httpsAgent = isHTTPS - ? new https.Agent({ - ca: fs.readFileSync(KBN_CERT_PATH), - key: fs.readFileSync(KBN_KEY_PATH), - // hard-coded set to false like in packages/kbn-cli-dev-mode/src/base_path_proxy_server.ts - rejectUnauthorized: false, + const dispatcher = isHTTPS + ? new Agent({ + connect: { + ca: fs.readFileSync(KBN_CERT_PATH).toString(), + key: fs.readFileSync(KBN_KEY_PATH).toString(), + rejectUnauthorized: false, + }, }) : undefined; @@ -43,20 +42,29 @@ export async function installKibanaAssets( const baseUrl = kibanaUrl.endsWith('/') ? kibanaUrl.slice(0, -1) : kibanaUrl; const importUrl = `${baseUrl}/api/saved_objects/_import?overwrite=true`; + const { username, password } = userPassObject; + const authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`; + // Send the saved objects to Kibana using the _import API - const response = await axios.post(importUrl, formData, { + const response = await fetch(importUrl, { + method: 'POST', + body: formData as unknown as BodyInit, headers: { ...formData.getHeaders(), 'kbn-xsrf': 'true', + Authorization: authHeader, }, - auth: userPassObject, - httpsAgent, - }); + ...(dispatcher ? { dispatcher } : {}), + } as RequestInit); + + if (!response.ok) { + throw new Error(`Request failed with status ${response.status}`); + } + + const data = await response.json(); - logger.info( - `Imported ${response.data.successCount} saved objects from "${filePath}" into Kibana` - ); - return response.data; + logger.info(`Imported ${data.successCount} saved objects from "${filePath}" into Kibana`); + return data; } catch (error) { if (isError(error)) { logger.error( diff --git a/x-pack/platform/packages/shared/kbn-data-forge/src/lib/queue.ts b/x-pack/platform/packages/shared/kbn-data-forge/src/lib/queue.ts index daa2d468a2cee..333a7e9d0f9f4 100644 --- a/x-pack/platform/packages/shared/kbn-data-forge/src/lib/queue.ts +++ b/x-pack/platform/packages/shared/kbn-data-forge/src/lib/queue.ts @@ -8,7 +8,6 @@ import { cargoQueue } from 'async'; import moment from 'moment'; import { omit } from 'lodash'; -import axios from 'axios'; import type { ToolingLog } from '@kbn/tooling-log'; import type { Client } from '@elastic/elasticsearch'; import type { Config, Doc } from '../types'; @@ -80,27 +79,28 @@ async function post(config: Config, docs: Doc[], logger: ToolingLog) { } try { const startTs = Date.now(); - const resp = await axios.post(config.destination.url, docs, { - headers: config.destination.headers, + const response = await fetch(config.destination.url, { + method: 'POST', + body: JSON.stringify(docs), + headers: { + 'content-type': 'application/json', + ...config.destination.headers, + }, }); + if (!response.ok) { + const data = await response.text(); + throw new Error(`Failed to send documents. Status: ${response.status}, Data: ${data}`); + } logger.info( { - statusCode: resp.status, + statusCode: response.status, latency: Date.now() - startTs, indexed: docs.length, }, `Sent ${docs.length} documents.` ); } catch (error) { - if (axios.isAxiosError(error) && error.response) { - logger.error( - `Failed to send documents. Status: ${error.response.status}, Data: ${JSON.stringify( - error.response.data - )}` - ); - } else { - logger.error(error); - } + logger.error(error); throw error; } } diff --git a/x-pack/platform/packages/shared/kbn-evals/src/utils/create_connector_fixture.test.ts b/x-pack/platform/packages/shared/kbn-evals/src/utils/create_connector_fixture.test.ts index c7892e748cf27..4a86a85686ead 100644 --- a/x-pack/platform/packages/shared/kbn-evals/src/utils/create_connector_fixture.test.ts +++ b/x-pack/platform/packages/shared/kbn-evals/src/utils/create_connector_fixture.test.ts @@ -6,7 +6,6 @@ */ import { v5 } from 'uuid'; -import { AxiosError } from 'axios'; import type { AvailableConnectorWithId } from '@kbn/gen-ai-functional-testing'; import type { ToolingLog } from '@kbn/tooling-log'; import { @@ -173,18 +172,12 @@ describe('createConnectorFixture', () => { }); it('swallows 404 errors on delete', async () => { - const axiosError = new AxiosError('Not Found', '404', undefined, undefined, { - status: 404, - data: {}, - headers: {}, - statusText: 'Not Found', - config: {} as any, - }); + const notFoundError = Object.assign(new Error('Not Found'), { status: 404 }); // First call (preconfigured check) succeeds, second call (setup delete) rejects with 404, rest succeed mockFetch .mockResolvedValueOnce({ is_preconfigured: false }) - .mockRejectedValueOnce(axiosError) + .mockRejectedValueOnce(notFoundError) .mockResolvedValue(undefined); await expect( @@ -201,13 +194,7 @@ describe('createConnectorFixture', () => { }); it('throws non-404 errors on delete', async () => { - const serverError = new AxiosError('Internal Server Error', '500', undefined, undefined, { - status: 500, - data: {}, - headers: {}, - statusText: 'Internal Server Error', - config: {} as any, - }); + const serverError = Object.assign(new Error('Internal Server Error'), { status: 500 }); // First call (preconfigured check) succeeds, second call (setup delete) fails hard mockFetch.mockResolvedValueOnce({ is_preconfigured: false }).mockRejectedValueOnce(serverError); diff --git a/x-pack/platform/packages/shared/kbn-evals/src/utils/create_connector_fixture.ts b/x-pack/platform/packages/shared/kbn-evals/src/utils/create_connector_fixture.ts index a6df7f42a65a6..4b87bfdf35adf 100644 --- a/x-pack/platform/packages/shared/kbn-evals/src/utils/create_connector_fixture.ts +++ b/x-pack/platform/packages/shared/kbn-evals/src/utils/create_connector_fixture.ts @@ -8,7 +8,6 @@ import type { AvailableConnectorWithId } from '@kbn/gen-ai-functional-testing'; import { v5 } from 'uuid'; import type { HttpHandler } from '@kbn/core/public'; -import { isAxiosError } from 'axios'; import type { ToolingLog } from '@kbn/tooling-log'; /** @@ -55,7 +54,7 @@ export async function createConnectorFixture({ return res?.is_preconfigured === true; } catch (error) { - const status = isAxiosError(error) ? error.status : (error as any)?.status; + const status = (error as any)?.status; if (status === 404) return false; throw error; } @@ -94,7 +93,7 @@ export async function createConnectorFixture({ path: `/api/actions/connector/${connectorIdAsUuid}`, method: 'DELETE', }).catch((error) => { - if (isAxiosError(error) && error.status === 404) { + if ((error as any)?.status === 404) { return; } throw error; 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..30e9efe57b1d9 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 @@ -120,7 +120,7 @@ export function httpHandlerFromKbnClient({ return asResponse ? { fetchOptions: options, - request: response.request!, + request: (response as any).request!, body: undefined, response: new Response(response.data as BodyInit, { status: response.status, @@ -131,9 +131,9 @@ export function httpHandlerFromKbnClient({ : (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 maybeKbn = err instanceof KbnClientRequesterError ? err.responseError ?? err : err; + if (err instanceof KbnClientRequesterError && err.responseError) { + err.responseError.message = err.message; } const status = (maybeKbn as any)?.status; diff --git a/x-pack/platform/plugins/private/canvas/common/lib/fetch.test.ts b/x-pack/platform/plugins/private/canvas/common/lib/fetch.test.ts index 79ad69a4a73cd..a76ba12f30a60 100644 --- a/x-pack/platform/plugins/private/canvas/common/lib/fetch.test.ts +++ b/x-pack/platform/plugins/private/canvas/common/lib/fetch.test.ts @@ -6,23 +6,18 @@ */ import { fetch, arrayBufferFetch } from './fetch'; -import type { AxiosInstance, HeadersDefaults } from 'axios'; describe('fetch', () => { - // WORKAROUND: wrong Axios types, should be fixed in https://github.com/axios/axios/pull/4475 - const getDefaultHeader = (axiosInstance: AxiosInstance, headerName: string) => - (axiosInstance.defaults.headers as HeadersDefaults & Record)[headerName]; - it('test fetch headers', () => { - expect(getDefaultHeader(fetch, 'Accept')).toBe('application/json'); - expect(getDefaultHeader(fetch, 'Content-Type')).toBe('application/json'); - expect(getDefaultHeader(fetch, 'kbn-xsrf')).toBe('professionally-crafted-string-of-text'); + expect(fetch.defaults.headers.Accept).toBe('application/json'); + expect(fetch.defaults.headers['Content-Type']).toBe('application/json'); + expect(fetch.defaults.headers['kbn-xsrf']).toBe('professionally-crafted-string-of-text'); }); it('test arrayBufferFetch headers', () => { - expect(getDefaultHeader(arrayBufferFetch, 'Accept')).toBe('application/json'); - expect(getDefaultHeader(arrayBufferFetch, 'Content-Type')).toBe('application/json'); - expect(getDefaultHeader(arrayBufferFetch, 'kbn-xsrf')).toBe( + expect(arrayBufferFetch.defaults.headers.Accept).toBe('application/json'); + expect(arrayBufferFetch.defaults.headers['Content-Type']).toBe('application/json'); + expect(arrayBufferFetch.defaults.headers['kbn-xsrf']).toBe( 'professionally-crafted-string-of-text' ); expect(arrayBufferFetch.defaults.responseType).toBe('arraybuffer'); diff --git a/x-pack/platform/plugins/private/canvas/common/lib/fetch.ts b/x-pack/platform/plugins/private/canvas/common/lib/fetch.ts index 9fa15fcc9b3b7..36d262ef9915b 100644 --- a/x-pack/platform/plugins/private/canvas/common/lib/fetch.ts +++ b/x-pack/platform/plugins/private/canvas/common/lib/fetch.ts @@ -5,24 +5,76 @@ * 2.0. */ -import axios from 'axios'; import { FETCH_TIMEOUT } from './constants'; -export const fetch = axios.create({ - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - 'kbn-xsrf': 'professionally-crafted-string-of-text', - }, - timeout: FETCH_TIMEOUT, -}); - -export const arrayBufferFetch = axios.create({ - responseType: 'arraybuffer', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - 'kbn-xsrf': 'professionally-crafted-string-of-text', - }, - timeout: FETCH_TIMEOUT, -}); +const defaultHeaders: Record = { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'kbn-xsrf': 'professionally-crafted-string-of-text', +}; + +interface FetchResponse { + data: T; + status: number; + headers: Headers; +} + +interface FetchInstance { + defaults: { + headers: Record; + responseType?: string; + }; + get: (url: string) => Promise>; + post: (url: string, body?: unknown) => Promise>; + put: (url: string, body?: unknown) => Promise>; + delete: (url: string) => Promise>; +} + +const createFetchInstance = (options?: { responseType?: string }): FetchInstance => { + const makeRequest = async (url: string, init?: RequestInit): Promise> => { + const response = await globalThis.fetch(url, { + ...init, + headers: { + ...defaultHeaders, + ...(init?.headers as Record), + }, + signal: init?.signal ?? AbortSignal.timeout(FETCH_TIMEOUT), + }); + + if (!response.ok) { + throw new Error(`Request failed with status ${response.status}`); + } + + let data: T; + if (options?.responseType === 'arraybuffer') { + data = (await response.arrayBuffer()) as unknown as T; + } else { + data = (await response.json()) as T; + } + + return { data, status: response.status, headers: response.headers }; + }; + + return { + defaults: { + headers: { ...defaultHeaders }, + ...(options?.responseType ? { responseType: options.responseType } : {}), + }, + get: (url: string) => makeRequest(url, { method: 'GET' }), + post: (url: string, body?: unknown) => + makeRequest(url, { + method: 'POST', + body: body != null ? JSON.stringify(body) : undefined, + }), + put: (url: string, body?: unknown) => + makeRequest(url, { + method: 'PUT', + body: body != null ? JSON.stringify(body) : undefined, + }), + delete: (url: string) => makeRequest(url, { method: 'DELETE' }), + }; +}; + +export const fetch = createFetchInstance(); + +export const arrayBufferFetch = createFetchInstance({ responseType: 'arraybuffer' }); diff --git a/x-pack/platform/plugins/private/data_usage/server/services/autoops_api.ts b/x-pack/platform/plugins/private/data_usage/server/services/autoops_api.ts index 2f8c7ccd0636a..7fff4de4432d9 100644 --- a/x-pack/platform/plugins/private/data_usage/server/services/autoops_api.ts +++ b/x-pack/platform/plugins/private/data_usage/server/services/autoops_api.ts @@ -5,14 +5,12 @@ * 2.0. */ -import https from 'https'; +import { Agent } from 'undici'; import { SslConfig, sslSchema } from '@kbn/server-http-tools'; import apm from 'elastic-apm-node'; import type { Logger } from '@kbn/logging'; -import type { AxiosError, AxiosRequestConfig } from 'axios'; -import axios from 'axios'; import type { LogMeta } from '@kbn/core/server'; import type { Type, TypeOf } from '@kbn/config-schema'; import { schema } from '@kbn/config-schema'; @@ -71,34 +69,43 @@ export class AutoOpsAPIService { const tlsConfig = this.createTlsConfig(autoopsConfig); const cloudSetup = appContextService.getCloud(); - const requestConfig: AxiosRequestConfig = { - url: getAutoOpsAPIRequestUrl(autoopsConfig.api?.url, cloudSetup?.serverless.projectId), - data: { - from: momentDateParser(requestBody.from)?.toISOString(), - to: momentDateParser(requestBody.to)?.toISOString(), - size: requestBody.dataStreams.length, - level: 'datastream', - metric_types: requestBody.metricTypes, - allowed_indices: requestBody.dataStreams, + const requestUrl = getAutoOpsAPIRequestUrl( + autoopsConfig.api?.url, + cloudSetup?.serverless.projectId + ); + const requestBodyData: Record = { + from: momentDateParser(requestBody.from)?.toISOString(), + to: momentDateParser(requestBody.to)?.toISOString(), + size: requestBody.dataStreams.length, + level: 'datastream', + metric_types: requestBody.metricTypes, + allowed_indices: requestBody.dataStreams, + }; + + if (!cloudSetup?.isServerlessEnabled) { + requestBodyData.stack_version = appContextService.getKibanaVersion(); + } + + const dispatcher = new Agent({ + connect: { + rejectUnauthorized: tlsConfig.rejectUnauthorized, + cert: tlsConfig.certificate, + key: tlsConfig.key, }, - signal: controller.signal, - method: 'POST', + }); + + const requestConfig = { + url: requestUrl, + data: requestBodyData, + method: 'POST' as const, headers: { 'Content-type': 'application/json', 'X-Request-ID': traceId, traceparent: traceId, }, - httpsAgent: new https.Agent({ - rejectUnauthorized: tlsConfig.rejectUnauthorized, - cert: tlsConfig.certificate, - key: tlsConfig.key, - }), + dispatcher, }; - if (!cloudSetup?.isServerlessEnabled) { - requestConfig.data.stack_version = appContextService.getKibanaVersion(); - } - const requestConfigDebugStatus = this.createRequestConfigDebug(requestConfig); this.logger.debug( @@ -114,63 +121,67 @@ export class AutoOpsAPIService { }, }; - const response = await axios(requestConfig).catch( - (error: Error | AxiosError) => { - if (!axios.isAxiosError(error)) { - this.logger.error( - `${AUTO_OPS_REQUEST_FAILED_PREFIX} with an error ${error}, request config: ${requestConfigDebugStatus}`, - errorMetadataWithRequestConfig - ); - throw new AutoOpsError(withRequestIdMessage(error.message)); - } + let response: Response; + try { + response = await globalThis.fetch(requestUrl, { + method: 'POST', + headers: requestConfig.headers as Record, + body: JSON.stringify(requestBodyData), + signal: controller.signal, + // @ts-expect-error -- dispatcher is a valid undici option for Node.js fetch + dispatcher, + }); + } catch (error: unknown) { + if (error instanceof Error) { + const causeMessage = + error.cause instanceof AggregateError + ? error.cause.errors.map((e: Error) => e.message) + : error.cause; + const errorLogCodeCause = `${ + (error as Error & { code?: string }).code ?? '' + } ${causeMessage}`; + + this.logger.error( + `${AUTO_OPS_REQUEST_FAILED_PREFIX} while sending the request to the AutoOps API: ${errorLogCodeCause}, request config: ${requestConfigDebugStatus}`, + errorMetadataWithRequestConfig + ); + throw new AutoOpsError(withRequestIdMessage(`no response received from the AutoOps API`)); + } - const errorLogCodeCause = `${error.code} ${this.convertCauseErrorsToString(error)}`; - - if (error.response) { - // The request was made and the server responded with a status code and error data - this.logger.error( - `${AUTO_OPS_REQUEST_FAILED_PREFIX} because the AutoOps API responded with a status code that falls out of the range of 2xx: ${JSON.stringify( - error.response.status - )}} ${JSON.stringify( - error.response.data - )}}, request config: ${requestConfigDebugStatus}`, - { - ...errorMetadataWithRequestConfig, - http: { - ...errorMetadataWithRequestConfig.http, - response: { - status_code: error.response.status, - body: error.response.data, - }, - }, - } - ); - throw new AutoOpsError( - withRequestIdMessage( - `${AUTO_OPS_REQUEST_FAILED_PREFIX} with status code: ${error.response.status}` - ) - ); - } else if (error.request) { - // The request was made but no response was received - this.logger.error( - `${AUTO_OPS_REQUEST_FAILED_PREFIX} while sending the request to the AutoOps API: ${errorLogCodeCause}, request config: ${requestConfigDebugStatus}`, - errorMetadataWithRequestConfig - ); - throw new AutoOpsError(withRequestIdMessage(`no response received from the AutoOps API`)); - } else { - // Something happened in setting up the request that triggered an Error - this.logger.error( - `${AUTO_OPS_REQUEST_FAILED_PREFIX} with ${errorLogCodeCause}, request config: ${requestConfigDebugStatus}, error: ${error.toJSON()}`, - errorMetadataWithRequestConfig - ); - throw new AutoOpsError( - withRequestIdMessage(`${AUTO_OPS_REQUEST_FAILED_PREFIX}, ${error.message}`) - ); + this.logger.error( + `${AUTO_OPS_REQUEST_FAILED_PREFIX} with an error ${error}, request config: ${requestConfigDebugStatus}`, + errorMetadataWithRequestConfig + ); + throw new AutoOpsError(withRequestIdMessage(String(error))); + } + + if (!response.ok) { + const responseBody = await response.text().catch(() => ''); + this.logger.error( + `${AUTO_OPS_REQUEST_FAILED_PREFIX} because the AutoOps API responded with a status code that falls out of the range of 2xx: ${JSON.stringify( + response.status + )}} ${JSON.stringify(responseBody)}}, request config: ${requestConfigDebugStatus}`, + { + ...errorMetadataWithRequestConfig, + http: { + ...errorMetadataWithRequestConfig.http, + response: { + status_code: response.status, + body: { content: responseBody }, + }, + }, } - } - ); + ); + throw new AutoOpsError( + withRequestIdMessage( + `${AUTO_OPS_REQUEST_FAILED_PREFIX} with status code: ${response.status}` + ) + ); + } + + const responseData = (await response.json()) as UsageMetricsAutoOpsResponseSchemaBody; - const validatedResponse = UsageMetricsAutoOpsResponseSchema.body().validate(response.data); + const validatedResponse = UsageMetricsAutoOpsResponseSchema.body().validate(responseData); this.logger.debug(`[AutoOps API] Successfully created an autoops agent ${response}`); return validatedResponse; @@ -186,30 +197,20 @@ export class AutoOpsAPIService { ); } - private createRequestConfigDebug(requestConfig: AxiosRequestConfig) { + private createRequestConfigDebug(requestConfig: { + data: Record; + dispatcher: Agent; + [key: string]: unknown; + }) { return JSON.stringify({ ...requestConfig, data: { ...requestConfig.data, fleet_token: '[REDACTED]', }, - httpsAgent: { - ...requestConfig.httpsAgent, - options: { - ...requestConfig.httpsAgent.options, - cert: requestConfig.httpsAgent.options.cert ? 'REDACTED' : undefined, - key: requestConfig.httpsAgent.options.key ? 'REDACTED' : undefined, - }, - }, + dispatcher: '[Agent]', }); } - - private convertCauseErrorsToString = (error: AxiosError) => { - if (error.cause instanceof AggregateError) { - return error.cause.errors.map((e: Error) => e.message); - } - return error.cause; - }; } export const metricTypesSchema = schema.oneOf( diff --git a/x-pack/platform/plugins/private/indices_metadata/server/lib/services/artifact.test.ts b/x-pack/platform/plugins/private/indices_metadata/server/lib/services/artifact.test.ts index 3c5fc23f83f3f..0273ea4965e0e 100644 --- a/x-pack/platform/plugins/private/indices_metadata/server/lib/services/artifact.test.ts +++ b/x-pack/platform/plugins/private/indices_metadata/server/lib/services/artifact.test.ts @@ -6,19 +6,16 @@ */ import { loggingSystemMock } from '@kbn/core/server/mocks'; -import axios from 'axios'; import { ArtifactNotFoundError, ManifestNotFoundError } from './artifact.errors'; import { generateKeyPairSync, createSign } from 'crypto'; import type { InfoResponse } from '@elastic/elasticsearch/lib/api/types'; import AdmZip from 'adm-zip'; import { ArtifactService } from './artifact'; -jest.mock('axios'); - describe('ArtifactService', () => { const url = 'http://localhost:3000'; const requestTimeout = 10_000; - const mockedAxios = axios as jest.Mocked; + let mockedFetch: jest.SpyInstance; const logger = loggingSystemMock.createLogger(); const defaultClusterInfo: InfoResponse = { name: 'elasticsearch', @@ -51,7 +48,11 @@ describe('ArtifactService', () => { }); beforeEach(() => { - mockedAxios.get.mockReset(); + mockedFetch = jest.spyOn(global, 'fetch'); + }); + + afterEach(() => { + jest.restoreAllMocks(); }); it('should fail when manifest is not found', async () => { @@ -61,7 +62,7 @@ describe('ArtifactService', () => { requestTimeout, }); - mockedAxios.get.mockImplementationOnce(() => Promise.resolve({ status: 404 })); + mockedFetch.mockResolvedValueOnce(new Response(null, { status: 404 })); await expect(artifactService.getArtifact(artifactName)).rejects.toThrow(ManifestNotFoundError); }); @@ -88,7 +89,7 @@ describe('ArtifactService', () => { const result = await artifactService.getArtifact(artifactName); expect(result).toBeDefined(); - expect(mockedAxios.get.mock.calls[0][0]).toBe( + expect(mockedFetch.mock.calls[0][0]).toBe( `${url}/downloads/kibana/manifest/artifacts-${version}.zip` ); }); @@ -115,7 +116,7 @@ describe('ArtifactService', () => { const result = await artifactService.getArtifact(artifactName); expect(result).toBeDefined(); - expect(mockedAxios.get.mock.calls[0][0]).toBe( + expect(mockedFetch.mock.calls[0][0]).toBe( `${url}/downloads/kibana/manifest/artifacts-${version}.zip` ); }); @@ -188,30 +189,35 @@ describe('ArtifactService', () => { ); const fakeEtag = '123'; - const axiosResponse = { - status: 200, - data: zip.toBuffer(), - headers: { etag: fakeEtag }, - }; // first request: download the .zip, second request: get the artifact, third request: check if the artifact is modified // and since the status is 304, it shouldn't download the artifact again. - mockedAxios.get - .mockImplementationOnce(() => Promise.resolve(axiosResponse)) - .mockImplementationOnce(() => Promise.resolve({ status: 200, data: {} })) - .mockImplementationOnce(() => Promise.resolve({ status: 304 })); + mockedFetch + .mockResolvedValueOnce( + new Response(zip.toBuffer() as unknown as BodyInit, { + status: 200, + headers: { etag: fakeEtag }, + }) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({}), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + .mockResolvedValueOnce(new Response(null, { status: 304 })); let manifest = await artifactService.getArtifact(artifactName); expect(manifest).not.toBeFalsy(); expect(manifest.modified).toEqual(true); - expect(mockedAxios.get.mock.calls.length).toBe(2); + expect(mockedFetch.mock.calls.length).toBe(2); manifest = await artifactService.getArtifact(artifactName); expect(manifest).not.toBeFalsy(); expect(manifest.modified).toEqual(false); - expect(mockedAxios.get.mock.calls.length).toBe(3); + expect(mockedFetch.mock.calls.length).toBe(3); - const [_url, config] = mockedAxios.get.mock.calls[2]; + const [_url, config] = mockedFetch.mock.calls[2]; const headers = config?.headers ?? {}; expect(headers).not.toBeFalsy(); expect(headers['If-None-Match']).toEqual(fakeEtag); @@ -259,22 +265,19 @@ describe('ArtifactService', () => { } function setupMockResponses(manifestZipContent: Buffer, artifactContent: string = '') { - mockedAxios.get - .mockImplementationOnce(() => { - return Promise.resolve({ + mockedFetch + .mockResolvedValueOnce( + new Response(manifestZipContent as unknown as BodyInit, { status: 200, - data: manifestZipContent, headers: {}, - config: { responseType: 'arraybuffer' }, - }); - }) - .mockImplementationOnce(() => { - return Promise.resolve({ + }) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify(artifactContent), { status: 200, - data: artifactContent, - headers: {}, - }); - }); + headers: { 'Content-Type': 'application/json' }, + }) + ); } function signManifestContent(manifestJson: string): Buffer { diff --git a/x-pack/platform/plugins/private/indices_metadata/server/lib/services/artifact.ts b/x-pack/platform/plugins/private/indices_metadata/server/lib/services/artifact.ts index 7cc2b75f0b354..76463de95cac0 100644 --- a/x-pack/platform/plugins/private/indices_metadata/server/lib/services/artifact.ts +++ b/x-pack/platform/plugins/private/indices_metadata/server/lib/services/artifact.ts @@ -7,7 +7,6 @@ import type { Logger, LogMeta } from '@kbn/core/server'; import type { InfoResponse } from '@elastic/elasticsearch/lib/api/types'; -import axios from 'axios'; import { createVerify } from 'crypto'; import AdmZip from 'adm-zip'; import { cloneDeep } from 'lodash'; @@ -52,39 +51,46 @@ export class ArtifactService { public async getArtifact(name: string): Promise { this.logger.debug('Getting artifact', { name } as LogMeta); - return axios - .get(this.getManifestUrl(), { - headers: this.headers(name), - timeout: this.cdnConfig?.requestTimeout, - validateStatus: (status) => status < 500, - responseType: 'arraybuffer', - }) - .then(async (response) => { - switch (response.status) { - case 200: - const manifest = { - data: await this.getManifest(name, response.data), - modified: true, - }; - // only update etag if we got a valid manifest - if (response.headers && response.headers.etag) { - const cacheEntry = { - manifest: { ...manifest, modified: false }, - etag: response.headers?.etag ?? '', - }; - this.cache.set(name, cacheEntry); - } - return cloneDeep(manifest); - case 304: - return cloneDeep(this.getCachedManifest(name)); - case 404: - // just in case, remove the entry - this.cache.delete(name); - throw new ManifestNotFoundError(this.manifestUrl!); - default: - throw Error(`Failed to download manifest, unexpected status code: ${response.status}`); + const fetchOptions: RequestInit = { + method: 'GET', + headers: this.headers(name), + signal: this.cdnConfig?.requestTimeout + ? AbortSignal.timeout(this.cdnConfig.requestTimeout) + : undefined, + }; + + const response = await globalThis.fetch(this.getManifestUrl(), fetchOptions); + + switch (response.status) { + case 200: { + const arrayBuffer = await response.arrayBuffer(); + const manifest = { + data: await this.getManifest(name, Buffer.from(arrayBuffer)), + modified: true, + }; + // only update etag if we got a valid manifest + const etag = response.headers.get('etag'); + if (etag) { + const cacheEntry = { + manifest: { ...manifest, modified: false }, + etag, + }; + this.cache.set(name, cacheEntry); } - }); + return cloneDeep(manifest); + } + case 304: + return cloneDeep(this.getCachedManifest(name)); + case 404: + // just in case, remove the entry + this.cache.delete(name); + throw new ManifestNotFoundError(this.manifestUrl!); + default: + if (response.status >= 500) { + throw Error(`Failed to download manifest, unexpected status code: ${response.status}`); + } + throw Error(`Failed to download manifest, unexpected status code: ${response.status}`); + } } private getManifestUrl() { @@ -124,8 +130,18 @@ export class ArtifactService { const artifact = manifest.artifacts[name]; if (artifact) { const url = `${this.cdnConfig?.url}${artifact.relative_url}`; - const artifactResponse = await axios.get(url, { timeout: this.cdnConfig?.requestTimeout }); - return artifactResponse.data; + const artifactResponse = await globalThis.fetch(url, { + method: 'GET', + signal: this.cdnConfig?.requestTimeout + ? AbortSignal.timeout(this.cdnConfig.requestTimeout) + : undefined, + }); + + if (!artifactResponse.ok) { + throw new Error(`Failed to fetch artifact: ${artifactResponse.status}`); + } + + return await artifactResponse.json(); } else { throw new ArtifactNotFoundError(name); } diff --git a/x-pack/platform/plugins/shared/cloud_connect/server/routes/authenticate.test.ts b/x-pack/platform/plugins/shared/cloud_connect/server/routes/authenticate.test.ts index 34ee62df589db..12a6f2516d0b1 100644 --- a/x-pack/platform/plugins/shared/cloud_connect/server/routes/authenticate.test.ts +++ b/x-pack/platform/plugins/shared/cloud_connect/server/routes/authenticate.test.ts @@ -8,7 +8,7 @@ import type { IRouter } from '@kbn/core/server'; import { elasticsearchServiceMock, loggingSystemMock } from '@kbn/core/server/mocks'; import { registerAuthenticateRoute } from './authenticate'; -import { CloudConnectClient } from '../services/cloud_connect_client'; +import { CloudConnectClient, FetchResponseError } from '../services/cloud_connect_client'; import type { CloudConnectApiKey } from '../types'; jest.mock('../services/cloud_connect_client'); @@ -358,15 +358,9 @@ describe('Authentication Routes', () => { }); it('should return 401 for invalid/expired API key', async () => { - const axiosError = { - isAxiosError: true, - response: { - status: 401, - data: { message: 'Unauthorized' }, - }, - }; + const fetchError = new FetchResponseError('Unauthorized', 401, { message: 'Unauthorized' }); - mockCloudConnectInstance.validateApiKeyScope.mockRejectedValue(axiosError); + mockCloudConnectInstance.validateApiKeyScope.mockRejectedValue(fetchError); mockRequest = { body: { @@ -382,15 +376,9 @@ describe('Authentication Routes', () => { }); it('should return 403 when terms and conditions not accepted', async () => { - const axiosError = { - isAxiosError: true, - response: { - status: 403, - data: { message: 'Forbidden' }, - }, - }; + const fetchError = new FetchResponseError('Forbidden', 403, { message: 'Forbidden' }); - mockCloudConnectInstance.validateApiKeyScope.mockRejectedValue(axiosError); + mockCloudConnectInstance.validateApiKeyScope.mockRejectedValue(fetchError); mockRequest = { body: { @@ -409,15 +397,9 @@ describe('Authentication Routes', () => { }); it('should return 400 for bad request errors', async () => { - const axiosError = { - isAxiosError: true, - response: { - status: 400, - data: { message: 'Bad request' }, - }, - }; + const fetchError = new FetchResponseError('Bad request', 400, { message: 'Bad request' }); - mockCloudConnectInstance.validateApiKeyScope.mockRejectedValue(axiosError); + mockCloudConnectInstance.validateApiKeyScope.mockRejectedValue(fetchError); mockRequest = { body: { diff --git a/x-pack/platform/plugins/shared/cloud_connect/server/routes/authenticate.ts b/x-pack/platform/plugins/shared/cloud_connect/server/routes/authenticate.ts index 407472cc897a8..6de7536db1c44 100644 --- a/x-pack/platform/plugins/shared/cloud_connect/server/routes/authenticate.ts +++ b/x-pack/platform/plugins/shared/cloud_connect/server/routes/authenticate.ts @@ -7,11 +7,10 @@ import { schema } from '@kbn/config-schema'; import type { IRouter, Logger } from '@kbn/core/server'; -import axios from 'axios'; import type { EncryptedSavedObjectsPluginStart } from '@kbn/encrypted-saved-objects-plugin/server'; import type { StartServicesAccessor } from '@kbn/core/server'; import { API_BASE_PATH } from '../../common/constants'; -import { CloudConnectClient } from '../services/cloud_connect_client'; +import { CloudConnectClient, isFetchResponseError } from '../services/cloud_connect_client'; import type { OnboardClusterResponse } from '../types'; import { getCurrentClusterData } from '../lib/cluster_info'; import { createStorageService } from '../lib/create_storage_service'; @@ -181,9 +180,9 @@ export const registerAuthenticateRoute = ({ } catch (error) { logger.error('Failed to authenticate with Cloud Connect', { error }); - if (axios.isAxiosError(error)) { - const status = error.response?.status; - const errorData = error.response?.data; + if (isFetchResponseError(error)) { + const status = error.status; + const errorData = error.data as Record | undefined; if (status === 401) { return response.unauthorized({ @@ -205,7 +204,7 @@ export const registerAuthenticateRoute = ({ if (status === 400) { return response.badRequest({ body: { - message: errorData?.message || 'Invalid request', + message: (errorData?.message as string) || 'Invalid request', }, }); } diff --git a/x-pack/platform/plugins/shared/cloud_connect/server/routes/clusters.test.ts b/x-pack/platform/plugins/shared/cloud_connect/server/routes/clusters.test.ts index ed256e31a5d09..bd9e7d028e043 100644 --- a/x-pack/platform/plugins/shared/cloud_connect/server/routes/clusters.test.ts +++ b/x-pack/platform/plugins/shared/cloud_connect/server/routes/clusters.test.ts @@ -8,7 +8,7 @@ import type { IRouter } from '@kbn/core/server'; import { elasticsearchServiceMock, loggingSystemMock } from '@kbn/core/server/mocks'; import { registerClustersRoute } from './clusters'; -import { CloudConnectClient } from '../services/cloud_connect_client'; +import { CloudConnectClient, FetchResponseError } from '../services/cloud_connect_client'; import type { CloudConnectApiKey } from '../types'; jest.mock('../services/cloud_connect_client'); @@ -195,15 +195,9 @@ describe('Clusters Routes', () => { updatedAt: '2024-01-01T00:00:00.000Z', }); - const axiosError = { - isAxiosError: true, - response: { - status: 401, - data: { message: 'Unauthorized' }, - }, - }; + const fetchError = new FetchResponseError('Unauthorized', 401, { message: 'Unauthorized' }); - mockCloudConnectInstance.getClusterDetails.mockRejectedValue(axiosError); + mockCloudConnectInstance.getClusterDetails.mockRejectedValue(fetchError); mockRequest = {}; @@ -223,15 +217,9 @@ describe('Clusters Routes', () => { updatedAt: '2024-01-01T00:00:00.000Z', }); - const axiosError = { - isAxiosError: true, - response: { - status: 403, - data: { message: 'Forbidden' }, - }, - }; + const fetchError = new FetchResponseError('Forbidden', 403, { message: 'Forbidden' }); - mockCloudConnectInstance.getClusterDetails.mockRejectedValue(axiosError); + mockCloudConnectInstance.getClusterDetails.mockRejectedValue(fetchError); mockRequest = {}; @@ -251,15 +239,9 @@ describe('Clusters Routes', () => { updatedAt: '2024-01-01T00:00:00.000Z', }); - const axiosError = { - isAxiosError: true, - response: { - status: 404, - data: { message: 'Not found' }, - }, - }; + const fetchError = new FetchResponseError('Not found', 404, { message: 'Not found' }); - mockCloudConnectInstance.getClusterDetails.mockRejectedValue(axiosError); + mockCloudConnectInstance.getClusterDetails.mockRejectedValue(fetchError); mockRequest = {}; @@ -279,15 +261,9 @@ describe('Clusters Routes', () => { updatedAt: '2024-01-01T00:00:00.000Z', }); - const axiosError = { - isAxiosError: true, - response: { - status: 400, - data: { message: 'Bad request' }, - }, - }; + const fetchError = new FetchResponseError('Bad request', 400, { message: 'Bad request' }); - mockCloudConnectInstance.getClusterDetails.mockRejectedValue(axiosError); + mockCloudConnectInstance.getClusterDetails.mockRejectedValue(fetchError); mockRequest = {}; @@ -307,22 +283,16 @@ describe('Clusters Routes', () => { updatedAt: '2024-01-01T00:00:00.000Z', }); - const axiosError = { - isAxiosError: true, - response: { - status: 429, - data: { - errors: [ - { - code: 'clusters.get_cluster.rate_limit_exceeded', - message: 'User-rate limit exceeded', - }, - ], + const fetchError = new FetchResponseError('Rate limited', 429, { + errors: [ + { + code: 'clusters.get_cluster.rate_limit_exceeded', + message: 'User-rate limit exceeded', }, - }, - }; + ], + }); - mockCloudConnectInstance.getClusterDetails.mockRejectedValue(axiosError); + mockCloudConnectInstance.getClusterDetails.mockRejectedValue(fetchError); mockRequest = {}; @@ -342,22 +312,16 @@ describe('Clusters Routes', () => { updatedAt: '2024-01-01T00:00:00.000Z', }); - const axiosError = { - isAxiosError: true, - response: { - status: 403, - data: { - errors: [ - { - code: 'clusters.get_cluster.forbidden', - message: 'request is not authorized', - }, - ], + const fetchError = new FetchResponseError('Forbidden', 403, { + errors: [ + { + code: 'clusters.get_cluster.forbidden', + message: 'request is not authorized', }, - }, - }; + ], + }); - mockCloudConnectInstance.getClusterDetails.mockRejectedValue(axiosError); + mockCloudConnectInstance.getClusterDetails.mockRejectedValue(fetchError); mockRequest = {}; @@ -518,15 +482,11 @@ describe('Clusters Routes', () => { updatedAt: '2024-01-01T00:00:00.000Z', }); - const axiosError = { - isAxiosError: true, - response: { - status: 500, - data: { message: 'Internal server error' }, - }, - }; + const fetchError = new FetchResponseError('Internal server error', 500, { + message: 'Internal server error', + }); - mockCloudConnectInstance.deleteCluster.mockRejectedValue(axiosError); + mockCloudConnectInstance.deleteCluster.mockRejectedValue(fetchError); mockRequest = {}; @@ -924,7 +884,7 @@ describe('Clusters Routes', () => { }); }); - it('should return 500 for axios errors', async () => { + it('should return 500 for fetch response errors', async () => { mockStorageService.getApiKey.mockResolvedValue({ apiKey: 'test-api-key-123', clusterId: 'cluster-uuid-456', @@ -932,15 +892,11 @@ describe('Clusters Routes', () => { updatedAt: '2024-01-01T00:00:00.000Z', }); - const axiosError = { - isAxiosError: true, - response: { - status: 400, - data: { message: 'Invalid service configuration' }, - }, - }; + const fetchError = new FetchResponseError('Invalid service configuration', 400, { + message: 'Invalid service configuration', + }); - mockCloudConnectInstance.updateCluster.mockRejectedValue(axiosError); + mockCloudConnectInstance.updateCluster.mockRejectedValue(fetchError); mockRequest = { body: { diff --git a/x-pack/platform/plugins/shared/cloud_connect/server/routes/clusters.ts b/x-pack/platform/plugins/shared/cloud_connect/server/routes/clusters.ts index bdf1215b311c2..8eaaae9df9da4 100644 --- a/x-pack/platform/plugins/shared/cloud_connect/server/routes/clusters.ts +++ b/x-pack/platform/plugins/shared/cloud_connect/server/routes/clusters.ts @@ -9,9 +9,8 @@ import { schema } from '@kbn/config-schema'; import type { IRouter, Logger, StartServicesAccessor } from '@kbn/core/server'; import type { EncryptedSavedObjectsPluginStart } from '@kbn/encrypted-saved-objects-plugin/server'; import { i18n } from '@kbn/i18n'; -import axios from 'axios'; import { API_BASE_PATH } from '../../common/constants'; -import { CloudConnectClient } from '../services/cloud_connect_client'; +import { CloudConnectClient, isFetchResponseError } from '../services/cloud_connect_client'; import { createStorageService } from '../lib/create_storage_service'; import { enableInferenceCCM, disableInferenceCCM } from '../services/inference_ccm'; @@ -106,13 +105,13 @@ export const registerClustersRoute = ({ } catch (error) { logger.error('Failed to retrieve cluster details', { error }); - if (axios.isAxiosError(error)) { - const errorData = error.response?.data; - const apiStatusCode = error.response?.status; + if (isFetchResponseError(error)) { + const errorData = error.data as Record | undefined; + const apiStatusCode = error.status; // Extract error message from backend response const errorMessage = - errorData?.errors?.[0]?.message || + (errorData?.errors as Array<{ message?: string }> | undefined)?.[0]?.message || errorData?.message || 'Failed to retrieve cluster details'; @@ -123,7 +122,7 @@ export const registerClustersRoute = ({ return response.customError({ statusCode, body: { - message: errorMessage, + message: errorMessage as string, }, }); } @@ -191,12 +190,12 @@ export const registerClustersRoute = ({ } catch (error) { logger.error('Failed to disconnect cluster', { error }); - if (axios.isAxiosError(error)) { - const errorData = error.response?.data; + if (isFetchResponseError(error)) { + const errorData = error.data as Record | undefined; return response.customError({ statusCode: 500, - body: errorData || { + body: (errorData as { message: string }) || { message: 'An error occurred while disconnecting the cluster', }, }); @@ -348,12 +347,15 @@ export const registerClustersRoute = ({ } catch (error) { logger.error('Failed to update cluster services', { error }); - if (axios.isAxiosError(error)) { - const errorData = error.response?.data; + if (isFetchResponseError(error)) { + const errorData = error.data as Record | undefined; // Extract error code from Cloud Connect API error format // API returns: { "errors": [{ "code": "...", "message": "..." }] } - const errorCode = errorData?.errors?.[0]?.code; + const errors = errorData?.errors as + | Array<{ code?: string; message?: string }> + | undefined; + const errorCode = errors?.[0]?.code; // Check for specific error codes and return user-friendly messages let errorMessage; @@ -363,7 +365,7 @@ export const registerClustersRoute = ({ }); } else { errorMessage = - errorData?.errors?.[0]?.message || + errors?.[0]?.message || errorData?.message || 'An error occurred while updating cluster services'; } @@ -371,7 +373,7 @@ export const registerClustersRoute = ({ return response.customError({ statusCode: 500, body: { - message: errorMessage, + message: errorMessage as string, ...(errorCode && { code: errorCode }), }, }); diff --git a/x-pack/platform/plugins/shared/cloud_connect/server/routes/rotate_api_key.ts b/x-pack/platform/plugins/shared/cloud_connect/server/routes/rotate_api_key.ts index 5f18fe6575045..29087a23f0cc1 100644 --- a/x-pack/platform/plugins/shared/cloud_connect/server/routes/rotate_api_key.ts +++ b/x-pack/platform/plugins/shared/cloud_connect/server/routes/rotate_api_key.ts @@ -8,8 +8,7 @@ import { schema } from '@kbn/config-schema'; import type { IRouter, Logger, StartServicesAccessor } from '@kbn/core/server'; import type { EncryptedSavedObjectsPluginStart } from '@kbn/encrypted-saved-objects-plugin/server'; -import axios from 'axios'; -import { CloudConnectClient } from '../services/cloud_connect_client'; +import { CloudConnectClient, isFetchResponseError } from '../services/cloud_connect_client'; import { getApiKeyData, ApiKeyNotFoundError } from '../lib/create_storage_service'; import { enableInferenceCCM } from '../services/inference_ccm'; @@ -81,12 +80,13 @@ export const registerRotateApiKeyRoute = ({ }); } - if (axios.isAxiosError(error)) { - const errorData = error.response?.data; - const apiStatusCode = error.response?.status; + if (isFetchResponseError(error)) { + const errorData = error.data as Record | undefined; + const apiStatusCode = error.status; + const errors = errorData?.errors as Array<{ message?: string }> | undefined; const errorMessage = - errorData?.errors?.[0]?.message || errorData?.message || 'Failed to rotate API key'; + errors?.[0]?.message || (errorData?.message as string) || 'Failed to rotate API key'; // Use 500 for 401 errors to prevent Kibana logout const statusCode = apiStatusCode === 401 ? 500 : apiStatusCode || 500; @@ -168,13 +168,14 @@ export const registerRotateApiKeyRoute = ({ }); } - if (axios.isAxiosError(error)) { - const errorData = error.response?.data; - const apiStatusCode = error.response?.status; + if (isFetchResponseError(error)) { + const errorData = error.data as Record | undefined; + const apiStatusCode = error.status; + const errors = errorData?.errors as Array<{ message?: string }> | undefined; const errorMessage = - errorData?.errors?.[0]?.message || - errorData?.message || + errors?.[0]?.message || + (errorData?.message as string) || 'Failed to rotate service API key'; // Use 500 for 401 errors to prevent Kibana logout diff --git a/x-pack/platform/plugins/shared/cloud_connect/server/services/cloud_connect_client.ts b/x-pack/platform/plugins/shared/cloud_connect/server/services/cloud_connect_client.ts index 7e1914064240b..f04dc976944f9 100644 --- a/x-pack/platform/plugins/shared/cloud_connect/server/services/cloud_connect_client.ts +++ b/x-pack/platform/plugins/shared/cloud_connect/server/services/cloud_connect_client.ts @@ -5,8 +5,6 @@ * 2.0. */ -import type { AxiosInstance } from 'axios'; -import axios from 'axios'; import type { Logger } from '@kbn/core/server'; import type { CloudConnectUserResponse, @@ -16,22 +14,59 @@ import type { SubscriptionResponse, UpdateClusterRequest, } from '../types'; +import { FetchResponseError, isFetchResponseError } from './fetch_response_error'; + +export { FetchResponseError, isFetchResponseError }; + +const DEFAULT_TIMEOUT = 30000; export class CloudConnectClient { - private axiosInstance: AxiosInstance; private logger: Logger; private cloudApiUrl: string; + private defaultHeaders: Record; constructor(logger: Logger, cloudApiUrl: string) { this.logger = logger; this.cloudApiUrl = cloudApiUrl; - this.axiosInstance = axios.create({ - baseURL: cloudApiUrl, - timeout: 30000, + this.defaultHeaders = { + 'Content-Type': 'application/json', + }; + } + + private async request( + path: string, + options: RequestInit & { fullUrl?: boolean } = {} + ): Promise { + const { fullUrl, ...fetchOptions } = options; + const url = fullUrl ? path : `${this.cloudApiUrl}${path}`; + const response = await globalThis.fetch(url, { + ...fetchOptions, headers: { - 'Content-Type': 'application/json', + ...this.defaultHeaders, + ...(fetchOptions.headers as Record), }, + signal: fetchOptions.signal ?? AbortSignal.timeout(DEFAULT_TIMEOUT), }); + + if (!response.ok) { + let errorData: unknown; + try { + errorData = await response.json(); + } catch { + errorData = await response.text().catch(() => ''); + } + throw new FetchResponseError( + `Request to ${url} failed with status ${response.status}`, + response.status, + errorData + ); + } + + const text = await response.text(); + if (!text) { + return undefined as unknown as T; + } + return JSON.parse(text) as T; } /** @@ -42,17 +77,18 @@ export class CloudConnectClient { try { this.logger.debug('Validating API key scope'); - const response = await axios.get( + const data = await this.request( `${this.cloudApiUrl}/saas/user?show_role_assignments=true`, { + fullUrl: true, headers: { Authorization: `apiKey ${apiKey}`, }, - timeout: 30000, + signal: AbortSignal.timeout(DEFAULT_TIMEOUT), } ); - const roleAssignments = response.data.user.role_assignments.cloud_connected_resource; + const roleAssignments = data.user.role_assignments.cloud_connected_resource; if (!roleAssignments || roleAssignments.length === 0) { return { @@ -90,14 +126,12 @@ export class CloudConnectClient { } catch (error) { this.logger.error('Failed to validate API key scope', { error }); - if (axios.isAxiosError(error)) { - if (error.response?.status === 401) { - return { - isClusterScoped: false, - hasValidScope: false, - errorMessage: 'Invalid or expired API key', - }; - } + if (isFetchResponseError(error) && error.status === 401) { + return { + isClusterScoped: false, + hasValidScope: false, + errorMessage: 'Invalid or expired API key', + }; } return { @@ -116,7 +150,7 @@ export class CloudConnectClient { try { this.logger.debug(`Fetching cluster details for cluster ID: ${clusterId}`); - const response = await this.axiosInstance.get( + const data = await this.request( `/cloud-connected/clusters/${clusterId}`, { headers: { @@ -125,9 +159,9 @@ export class CloudConnectClient { } ); - this.logger.debug(`Successfully fetched cluster details for: ${response.data.id}`); + this.logger.debug(`Successfully fetched cluster details for: ${data.id}`); - return response.data; + return data; } catch (error) { this.logger.error(`Failed to fetch cluster details for cluster ID: ${clusterId}`, { error }); throw error; @@ -146,19 +180,17 @@ export class CloudConnectClient { try { this.logger.debug('Onboarding cluster with happy path key'); - const response = await this.axiosInstance.post( - '/cloud-connected/clusters', - clusterData, - { - headers: { - Authorization: `apiKey ${apiKey}`, - }, - } - ); + const data = await this.request('/cloud-connected/clusters', { + method: 'POST', + body: JSON.stringify(clusterData), + headers: { + Authorization: `apiKey ${apiKey}`, + }, + }); - this.logger.debug(`Cluster onboarded successfully: ${response.data.id}`); + this.logger.debug(`Cluster onboarded successfully: ${data.id}`); - return response.data; + return data; } catch (error) { this.logger.error('Failed to onboard cluster', { error }); throw error; @@ -176,21 +208,20 @@ export class CloudConnectClient { try { this.logger.debug('Onboarding cluster with admin key and generating new API key'); - const response = await this.axiosInstance.post( + const data = await this.request( '/cloud-connected/clusters?create_api_key=true', - clusterData, { + method: 'POST', + body: JSON.stringify(clusterData), headers: { Authorization: `apiKey ${apiKey}`, }, } ); - this.logger.debug( - `Cluster onboarded successfully with new API key generated: ${response.data.id}` - ); + this.logger.debug(`Cluster onboarded successfully with new API key generated: ${data.id}`); - return response.data; + return data; } catch (error) { this.logger.error('Failed to onboard cluster with key generation', { error }); throw error; @@ -208,19 +239,20 @@ export class CloudConnectClient { try { this.logger.debug(`Updating services for cluster ID: ${clusterId}`); - const response = await this.axiosInstance.patch( + const data = await this.request( `/cloud-connected/clusters/${clusterId}`, - clusterData, { + method: 'PATCH', + body: JSON.stringify(clusterData), headers: { Authorization: `apiKey ${apiKey}`, }, } ); - this.logger.debug(`Successfully updated services for cluster: ${response.data.id}`); + this.logger.debug(`Successfully updated services for cluster: ${data.id}`); - return response.data; + return data; } catch (error) { this.logger.error(`Failed to update services for cluster ID: ${clusterId}`, { error }); throw error; @@ -235,7 +267,8 @@ export class CloudConnectClient { try { this.logger.debug(`Deleting cluster from Cloud API: ${clusterId}`); - await this.axiosInstance.delete(`/cloud-connected/clusters/${clusterId}`, { + await this.request(`/cloud-connected/clusters/${clusterId}`, { + method: 'DELETE', headers: { Authorization: `apiKey ${apiKey}`, }, @@ -259,7 +292,7 @@ export class CloudConnectClient { try { this.logger.debug(`Fetching subscription for organization ID: ${organizationId}`); - const response = await this.axiosInstance.get( + const data = await this.request( `/cloud-connected/organizations/${organizationId}/subscription`, { headers: { @@ -269,10 +302,10 @@ export class CloudConnectClient { ); this.logger.debug( - `Successfully fetched subscription for organization: ${organizationId}, state: ${response.data.state}` + `Successfully fetched subscription for organization: ${organizationId}, state: ${data.state}` ); - return response.data; + return data; } catch (error) { this.logger.error(`Failed to fetch subscription for organization ID: ${organizationId}`, { error, @@ -289,10 +322,11 @@ export class CloudConnectClient { try { this.logger.debug(`Rotating API key for cluster ID: ${clusterId}`); - const response = await this.axiosInstance.post<{ key: string }>( + const data = await this.request<{ key: string }>( `/cloud-connected/clusters/${clusterId}/apikey/_rotate`, - {}, { + method: 'POST', + body: JSON.stringify({}), headers: { Authorization: `apiKey ${apiKey}`, }, @@ -301,7 +335,7 @@ export class CloudConnectClient { this.logger.debug(`Successfully rotated API key for cluster: ${clusterId}`); - return response.data; + return data; } catch (error) { this.logger.error(`Failed to rotate API key for cluster ID: ${clusterId}`, { error }); throw error; @@ -320,10 +354,11 @@ export class CloudConnectClient { try { this.logger.debug(`Rotating API key for service ${serviceKey} on cluster ID: ${clusterId}`); - const response = await this.axiosInstance.post<{ key: string }>( + const data = await this.request<{ key: string }>( `/cloud-connected/clusters/${clusterId}/apikey/${serviceKey}/_rotate`, - {}, { + method: 'POST', + body: JSON.stringify({}), headers: { Authorization: `apiKey ${apiKey}`, }, @@ -334,7 +369,7 @@ export class CloudConnectClient { `Successfully rotated API key for service ${serviceKey} on cluster: ${clusterId}` ); - return response.data; + return data; } catch (error) { this.logger.error( `Failed to rotate API key for service ${serviceKey} on cluster ID: ${clusterId}`, diff --git a/x-pack/platform/plugins/shared/cloud_connect/server/services/fetch_response_error.ts b/x-pack/platform/plugins/shared/cloud_connect/server/services/fetch_response_error.ts new file mode 100644 index 0000000000000..4599aca599e0a --- /dev/null +++ b/x-pack/platform/plugins/shared/cloud_connect/server/services/fetch_response_error.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export class FetchResponseError extends Error { + public readonly status: number; + public readonly data: unknown; + + constructor(message: string, status: number, data: unknown) { + super(message); + this.name = 'FetchResponseError'; + this.status = status; + this.data = data; + } +} + +export const isFetchResponseError = (error: unknown): error is FetchResponseError => + error instanceof FetchResponseError; diff --git a/x-pack/platform/plugins/shared/dataset_quality/server/test_helpers/create_dataset_quality_users/helpers/abort_error.ts b/x-pack/platform/plugins/shared/dataset_quality/server/test_helpers/create_dataset_quality_users/helpers/abort_error.ts new file mode 100644 index 0000000000000..9ac15b538834f --- /dev/null +++ b/x-pack/platform/plugins/shared/dataset_quality/server/test_helpers/create_dataset_quality_users/helpers/abort_error.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export class AbortError extends Error { + constructor(message: string) { + super(message); + } +} diff --git a/x-pack/platform/plugins/shared/dataset_quality/server/test_helpers/create_dataset_quality_users/helpers/call_kibana.ts b/x-pack/platform/plugins/shared/dataset_quality/server/test_helpers/create_dataset_quality_users/helpers/call_kibana.ts index 37d2700ab84c9..d9306c987140a 100644 --- a/x-pack/platform/plugins/shared/dataset_quality/server/test_helpers/create_dataset_quality_users/helpers/call_kibana.ts +++ b/x-pack/platform/plugins/shared/dataset_quality/server/test_helpers/create_dataset_quality_users/helpers/call_kibana.ts @@ -4,10 +4,9 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import type { AxiosRequestConfig, AxiosError } from 'axios'; -import axios from 'axios'; import { once } from 'lodash'; import type { Elasticsearch, Kibana } from '..'; +export { AbortError } from './abort_error'; export async function callKibana({ elasticsearch, @@ -16,47 +15,69 @@ export async function callKibana({ }: { elasticsearch: Omit; kibana: Kibana; - options: AxiosRequestConfig; + options: { + method?: string; + url?: string; + data?: unknown; + headers?: Record; + }; }): Promise { const baseUrl = await getBaseUrl(kibana.hostname); const { username, password } = elasticsearch; - const { data } = await axios.request({ - ...options, - baseURL: baseUrl, - allowAbsoluteUrls: false, - auth: { username, password }, - headers: { 'kbn-xsrf': 'true', 'x-elastic-internal-origin': 'kibana', ...options.headers }, - }); + const method = options.method ?? 'GET'; + const url = `${baseUrl}${options.url ?? ''}`; + const headers: Record = { + 'kbn-xsrf': 'true', + 'x-elastic-internal-origin': 'kibana', + Authorization: `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`, + ...options.headers, + }; + + const fetchOptions: RequestInit = { + method, + headers, + }; + + if (options.data !== undefined) { + fetchOptions.body = JSON.stringify(options.data); + headers['content-type'] = 'application/json'; + } + + const response = await fetch(url, fetchOptions); + if (!response.ok) { + throw new FetchError( + `Request to ${url} failed with status ${response.status} ${response.statusText}`, + response.status + ); + } + const data = await response.json(); return data; } const getBaseUrl = once(async (kibanaHostname: string) => { try { - await axios.request({ - url: kibanaHostname, - maxRedirects: 0, + const response = await fetch(kibanaHostname, { + redirect: 'manual', headers: { 'x-elastic-internal-origin': 'kibana' }, }); + const location = response.headers.get('location') ?? ''; + const hasBasePath = RegExp(/^\/\w{3}$/).test(location); + const basePath = hasBasePath ? location : ''; + return `${kibanaHostname}${basePath}`; } catch (e) { - if (isAxiosError(e)) { - const location = e.response?.headers?.location ?? ''; - const hasBasePath = RegExp(/^\/\w{3}$/).test(location); - const basePath = hasBasePath ? location : ''; - return `${kibanaHostname}${basePath}`; - } - - throw e; + return kibanaHostname; } - return kibanaHostname; }); -export function isAxiosError(e: AxiosError | Error): e is AxiosError { - return 'isAxiosError' in e; -} - -export class AbortError extends Error { - constructor(message: string) { +export class FetchError extends Error { + public readonly status: number; + constructor(message: string, status: number) { super(message); + this.status = status; } } + +export function isFetchError(e: unknown): e is FetchError { + return e instanceof FetchError; +} diff --git a/x-pack/platform/plugins/shared/dataset_quality/server/test_helpers/create_dataset_quality_users/helpers/create_or_update_user.ts b/x-pack/platform/plugins/shared/dataset_quality/server/test_helpers/create_dataset_quality_users/helpers/create_or_update_user.ts index f3e81c4b6c5e4..107bcfd4a0e48 100644 --- a/x-pack/platform/plugins/shared/dataset_quality/server/test_helpers/create_dataset_quality_users/helpers/create_or_update_user.ts +++ b/x-pack/platform/plugins/shared/dataset_quality/server/test_helpers/create_dataset_quality_users/helpers/create_or_update_user.ts @@ -9,7 +9,7 @@ import { difference, union } from 'lodash'; import type { Elasticsearch, Kibana } from '..'; -import { callKibana, isAxiosError } from './call_kibana'; +import { callKibana, isFetchError } from './call_kibana'; interface User { username: string; @@ -124,7 +124,7 @@ async function getUser({ }); } catch (e) { // return empty if user doesn't exist - if (isAxiosError(e) && e.response?.status === 404) { + if (isFetchError(e) && e.status === 404) { return null; } diff --git a/x-pack/platform/plugins/shared/dataset_quality/server/test_helpers/create_dataset_quality_users/index.ts b/x-pack/platform/plugins/shared/dataset_quality/server/test_helpers/create_dataset_quality_users/index.ts index 9cfb2f1dc66d0..81b4d6d66dadf 100644 --- a/x-pack/platform/plugins/shared/dataset_quality/server/test_helpers/create_dataset_quality_users/index.ts +++ b/x-pack/platform/plugins/shared/dataset_quality/server/test_helpers/create_dataset_quality_users/index.ts @@ -101,7 +101,6 @@ async function getIsCredentialsValid({ elasticsearch, kibana, options: { - validateStatus: (status) => status >= 200 && status < 400, url: `/`, }, }); diff --git a/x-pack/platform/plugins/shared/fleet/server/services/agents/agentless_agent.test.ts b/x-pack/platform/plugins/shared/fleet/server/services/agents/agentless_agent.test.ts index d12951c9058c0..8de43d01d87e6 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/agents/agentless_agent.test.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/agents/agentless_agent.test.ts @@ -9,9 +9,6 @@ import { securityMock } from '@kbn/security-plugin/server/mocks'; import { loggerMock } from '@kbn/logging-mocks'; import type { Logger } from '@kbn/core/server'; -import type { AxiosResponse } from 'axios'; -import axios from 'axios'; - import { AgentlessAgentCreateFleetUnreachableError } from '../../../common/errors'; import { AgentlessAgentConfigError, @@ -31,8 +28,6 @@ import { fleetServerHostService } from '../fleet_server_host'; import { agentlessAgentService } from './agentless_agent'; -jest.mock('axios'); - jest.mock('../fleet_server_host'); jest.mock('../api_keys'); jest.mock('../output'); @@ -70,14 +65,26 @@ function getAgentPolicyCreateMock() { } let mockedLogger: jest.Mocked; -const mockAgentlessDeploymentResponse: Partial> = { - data: { - code: AgentlessApiDeploymentResponseCode.Success, - error: null, - }, +const mockAgentlessDeploymentResponseData: AgentlessApiDeploymentResponse = { + code: AgentlessApiDeploymentResponseCode.Success, + error: null, +}; + +const mockAgentlessDeploymentResponse = { + status: 200, + data: mockAgentlessDeploymentResponseData, }; -const AxiosError = jest.requireActual('axios').AxiosError; +const createMockFetchResponse = (data: unknown, status = 200): Response => { + return { + ok: status >= 200 && status < 300, + status, + statusText: status === 200 ? 'OK' : 'Error', + json: jest.fn().mockResolvedValue(data), + text: jest.fn().mockResolvedValue(JSON.stringify(data)), + headers: new Headers({ 'content-type': 'application/json' }), + } as unknown as Response; +}; jest.mock('@kbn/server-http-tools', () => ({ ...jest.requireActual('@kbn/server-http-tools'), @@ -89,24 +96,26 @@ jest.mock('@kbn/server-http-tools', () => ({ })); describe('Agentless Agent service', () => { + let mockedFetch: jest.SpyInstance; + beforeEach(() => { - axios.isAxiosError = jest.requireActual('axios').isAxiosError; + jest.clearAllMocks(); mockedLogger = loggerMock.create(); mockedAppContextService.getLogger.mockReturnValue(mockedLogger); mockedAppContextService.getExperimentalFeatures.mockReturnValue({ agentless: false } as any); - jest.mocked(axios).mockReset(); + mockedFetch = jest.spyOn(global, 'fetch'); jest.spyOn(agentPolicyService, 'getFullAgentPolicy').mockResolvedValue({ outputs: { default: {} as any }, } as any); - jest.clearAllMocks(); }); afterEach(() => { jest.resetAllMocks(); + jest.restoreAllMocks(); }); it('should create agentless agent for ESS', async () => { - jest.mocked(axios).mockResolvedValueOnce(mockAgentlessDeploymentResponse); + mockedFetch.mockResolvedValueOnce(createMockFetchResponse(mockAgentlessDeploymentResponseData)); const soClient = getAgentPolicyCreateMock(); // ignore unrelated unique name constraint const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; @@ -175,40 +184,43 @@ describe('Agentless Agent service', () => { } as AgentPolicy ); - expect(axios).toHaveBeenCalledTimes(1); + expect(mockedFetch).toHaveBeenCalledTimes(1); expect(createAgentlessAgentReturnValue).toEqual(mockAgentlessDeploymentResponse); - expect(axios).toHaveBeenCalledWith( + expect(mockedFetch).toHaveBeenCalledWith( + 'http://api.agentless.com/api/v1/ess/deployments', expect.objectContaining({ - data: expect.objectContaining({ - fleet_token: 'mocked-fleet-enrollment-api-key', - fleet_url: 'http://fleetserver:8220', - policy_id: 'mocked-agentless-agent-policy-id', - stack_version: 'mocked-kibana-version-infinite', - labels: { - owner: { - org: 'elastic', - division: 'cloud', - team: 'fleet', - }, - }, - secrets: { - fleet_app_token: 'fleet-app-token', - elasticsearch_app_token: 'es-app-token', - }, - policy_details: { - output_name: 'default', - }, - }), - headers: expect.anything(), - httpsAgent: expect.anything(), method: 'POST', - url: 'http://api.agentless.com/api/v1/ess/deployments', + headers: expect.anything(), + body: expect.stringContaining('"policy_id":"mocked-agentless-agent-policy-id"'), + }) + ); + const callBody = JSON.parse(mockedFetch.mock.calls[0][1].body); + expect(callBody).toEqual( + expect.objectContaining({ + fleet_token: 'mocked-fleet-enrollment-api-key', + fleet_url: 'http://fleetserver:8220', + policy_id: 'mocked-agentless-agent-policy-id', + stack_version: 'mocked-kibana-version-infinite', + labels: { + owner: { + org: 'elastic', + division: 'cloud', + team: 'fleet', + }, + }, + secrets: { + fleet_app_token: 'fleet-app-token', + elasticsearch_app_token: 'es-app-token', + }, + policy_details: { + output_name: 'default', + }, }) ); }); it('should create agentless agent for serverless', async () => { - jest.mocked(axios).mockResolvedValueOnce(mockAgentlessDeploymentResponse); + mockedFetch.mockResolvedValueOnce(createMockFetchResponse(mockAgentlessDeploymentResponseData)); const soClient = getAgentPolicyCreateMock(); // ignore unrelated unique name constraint const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; @@ -279,45 +291,44 @@ describe('Agentless Agent service', () => { } as AgentPolicy ); - expect(axios).toHaveBeenCalledTimes(1); + expect(mockedFetch).toHaveBeenCalledTimes(1); expect(createAgentlessAgentReturnValue).toEqual(mockAgentlessDeploymentResponse); - expect(axios).toHaveBeenCalledWith( + expect(mockedFetch).toHaveBeenCalledWith( + 'http://api.agentless.com/api/v1/serverless/deployments', expect.objectContaining({ - data: expect.objectContaining({ - fleet_token: 'mocked-fleet-enrollment-api-key', - fleet_url: 'http://fleetserver:8220', - policy_id: 'mocked-agentless-agent-policy-id', - labels: { - owner: { - org: 'elastic', - division: 'cloud', - team: 'fleet', - }, - }, - secrets: { - fleet_app_token: 'fleet-app-token', - elasticsearch_app_token: 'es-app-token', - }, - policy_details: { - output_name: 'default', - }, - }), - headers: expect.anything(), - httpsAgent: expect.anything(), method: 'POST', - url: 'http://api.agentless.com/api/v1/serverless/deployments', + headers: expect.anything(), + }) + ); + const callBody = JSON.parse(mockedFetch.mock.calls[0][1].body); + expect(callBody).toEqual( + expect.objectContaining({ + fleet_token: 'mocked-fleet-enrollment-api-key', + fleet_url: 'http://fleetserver:8220', + policy_id: 'mocked-agentless-agent-policy-id', + labels: { + owner: { + org: 'elastic', + division: 'cloud', + team: 'fleet', + }, + }, + secrets: { + fleet_app_token: 'fleet-app-token', + elasticsearch_app_token: 'es-app-token', + }, + policy_details: { + output_name: 'default', + }, }) ); }); it('should retry creating agentless agent on 500 error', async () => { - const axiosError = new AxiosError('Test Error'); - axiosError.response = { - status: 500, - } as any; - - jest.mocked(axios).mockRejectedValueOnce(axiosError); - jest.mocked(axios).mockResolvedValueOnce(mockAgentlessDeploymentResponse); + mockedFetch.mockResolvedValueOnce( + createMockFetchResponse({ message: 'Internal Server Error' }, 500) + ); + mockedFetch.mockResolvedValueOnce(createMockFetchResponse(mockAgentlessDeploymentResponseData)); const soClient = getAgentPolicyCreateMock(); // ignore unrelated unique name constraint const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; @@ -388,39 +399,19 @@ describe('Agentless Agent service', () => { } as AgentPolicy ); - expect(axios).toHaveBeenCalledTimes(2); + expect(mockedFetch).toHaveBeenCalledTimes(2); expect(createAgentlessAgentReturnValue).toEqual(mockAgentlessDeploymentResponse); - expect(axios).toHaveBeenCalledWith( + expect(mockedFetch).toHaveBeenCalledWith( + 'http://api.agentless.com/api/v1/serverless/deployments', expect.objectContaining({ - data: expect.objectContaining({ - fleet_token: 'mocked-fleet-enrollment-api-key', - fleet_url: 'http://fleetserver:8220', - policy_id: 'mocked-agentless-agent-policy-id', - labels: { - owner: { - org: 'elastic', - division: 'cloud', - team: 'fleet', - }, - }, - secrets: { - fleet_app_token: 'fleet-app-token', - elasticsearch_app_token: 'es-app-token', - }, - policy_details: { - output_name: 'default', - }, - }), - headers: expect.anything(), - httpsAgent: expect.anything(), method: 'POST', - url: 'http://api.agentless.com/api/v1/serverless/deployments', + headers: expect.anything(), }) ); }); it('should create agentless agent with resources', async () => { - jest.mocked(axios).mockResolvedValueOnce(mockAgentlessDeploymentResponse); + mockedFetch.mockResolvedValueOnce(createMockFetchResponse(mockAgentlessDeploymentResponseData)); const soClient = getAgentPolicyCreateMock(); // ignore unrelated unique name constraint const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; @@ -499,45 +490,47 @@ describe('Agentless Agent service', () => { } as AgentPolicy ); - expect(axios).toHaveBeenCalledTimes(1); + expect(mockedFetch).toHaveBeenCalledTimes(1); expect(createAgentlessAgentReturnValue).toEqual(mockAgentlessDeploymentResponse); - expect(axios).toHaveBeenCalledWith( + expect(mockedFetch).toHaveBeenCalledWith( + 'http://api.agentless.com/api/v1/serverless/deployments', expect.objectContaining({ - data: expect.objectContaining({ - fleet_token: 'mocked-fleet-enrollment-api-key', - fleet_url: 'http://fleetserver:8220', - policy_id: 'mocked-agentless-agent-policy-id', - resources: { - requests: { - memory: '1Gi', - cpu: '500m', - }, - }, - labels: { - owner: { - org: 'elastic', - division: 'cloud', - team: 'fleet', - }, - }, - secrets: { - fleet_app_token: 'fleet-app-token', - elasticsearch_app_token: 'es-app-token', + method: 'POST', + headers: expect.anything(), + }) + ); + const callBody = JSON.parse(mockedFetch.mock.calls[0][1].body); + expect(callBody).toEqual( + expect.objectContaining({ + fleet_token: 'mocked-fleet-enrollment-api-key', + fleet_url: 'http://fleetserver:8220', + policy_id: 'mocked-agentless-agent-policy-id', + resources: { + requests: { + memory: '1Gi', + cpu: '500m', }, - policy_details: { - output_name: 'default', + }, + labels: { + owner: { + org: 'elastic', + division: 'cloud', + team: 'fleet', }, - }), - headers: expect.anything(), - httpsAgent: expect.anything(), - method: 'POST', - url: 'http://api.agentless.com/api/v1/serverless/deployments', + }, + secrets: { + fleet_app_token: 'fleet-app-token', + elasticsearch_app_token: 'es-app-token', + }, + policy_details: { + output_name: 'default', + }, }) ); }); it('should create agentless agent with cloud_connectors', async () => { - jest.mocked(axios).mockResolvedValueOnce(mockAgentlessDeploymentResponse); + mockedFetch.mockResolvedValueOnce(createMockFetchResponse(mockAgentlessDeploymentResponseData)); const soClient = getAgentPolicyCreateMock(); // ignore unrelated unique name constraint const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; @@ -620,49 +613,51 @@ describe('Agentless Agent service', () => { } as AgentPolicy ); - expect(axios).toHaveBeenCalledTimes(1); + expect(mockedFetch).toHaveBeenCalledTimes(1); expect(createAgentlessAgentReturnValue).toEqual(mockAgentlessDeploymentResponse); - expect(axios).toHaveBeenCalledWith( + expect(mockedFetch).toHaveBeenCalledWith( + 'http://api.agentless.com/api/v1/serverless/deployments', expect.objectContaining({ - data: expect.objectContaining({ - fleet_token: 'mocked-fleet-enrollment-api-key', - fleet_url: 'http://fleetserver:8220', - policy_id: 'mocked-agentless-agent-policy-id', - resources: { - requests: { - memory: '1Gi', - cpu: '500m', - }, - }, - cloud_connectors: { - target_csp: 'aws', - enabled: true, - }, - labels: { - owner: { - org: 'elastic', - division: 'cloud', - team: 'fleet', - }, - }, - secrets: { - fleet_app_token: 'fleet-app-token', - elasticsearch_app_token: 'es-app-token', + method: 'POST', + headers: expect.anything(), + }) + ); + const callBody = JSON.parse(mockedFetch.mock.calls[0][1].body); + expect(callBody).toEqual( + expect.objectContaining({ + fleet_token: 'mocked-fleet-enrollment-api-key', + fleet_url: 'http://fleetserver:8220', + policy_id: 'mocked-agentless-agent-policy-id', + resources: { + requests: { + memory: '1Gi', + cpu: '500m', }, - policy_details: { - output_name: 'default', + }, + cloud_connectors: { + target_csp: 'aws', + enabled: true, + }, + labels: { + owner: { + org: 'elastic', + division: 'cloud', + team: 'fleet', }, - }), - headers: expect.anything(), - httpsAgent: expect.anything(), - method: 'POST', - url: 'http://api.agentless.com/api/v1/serverless/deployments', + }, + secrets: { + fleet_app_token: 'fleet-app-token', + elasticsearch_app_token: 'es-app-token', + }, + policy_details: { + output_name: 'default', + }, }) ); }); it('should create agentless agent when no labels are given', async () => { - jest.mocked(axios).mockResolvedValueOnce(mockAgentlessDeploymentResponse); + mockedFetch.mockResolvedValueOnce(createMockFetchResponse(mockAgentlessDeploymentResponseData)); const soClient = getAgentPolicyCreateMock(); // ignore unrelated unique name constraint const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; @@ -717,30 +712,33 @@ describe('Agentless Agent service', () => { } as AgentPolicy ); - expect(axios).toHaveBeenCalledTimes(1); + expect(mockedFetch).toHaveBeenCalledTimes(1); expect(createAgentlessAgentReturnValue).toEqual(mockAgentlessDeploymentResponse); - expect(axios).toHaveBeenCalledWith( + expect(mockedFetch).toHaveBeenCalledWith( + 'http://api.agentless.com/api/v1/ess/deployments', expect.objectContaining({ - data: expect.objectContaining({ - fleet_token: 'mocked-fleet-enrollment-api-key', - fleet_url: 'http://fleetserver:8220', - policy_id: 'mocked-agentless-agent-policy-id', - stack_version: 'mocked-kibana-version-infinite', - }), - headers: expect.anything(), - httpsAgent: expect.anything(), method: 'POST', - url: 'http://api.agentless.com/api/v1/ess/deployments', + headers: expect.anything(), + }) + ); + const callBody = JSON.parse(mockedFetch.mock.calls[0][1].body); + expect(callBody).toEqual( + expect.objectContaining({ + fleet_token: 'mocked-fleet-enrollment-api-key', + fleet_url: 'http://fleetserver:8220', + policy_id: 'mocked-agentless-agent-policy-id', + stack_version: 'mocked-kibana-version-infinite', }) ); }); it('should delete agentless agent for ESS', async () => { const returnValue = { - id: 'mocked', + status: 200, + data: { id: 'mocked' }, }; - jest.mocked(axios).mockResolvedValueOnce(returnValue); + mockedFetch.mockResolvedValueOnce(createMockFetchResponse({ id: 'mocked' })); jest.spyOn(appContextService, 'getConfig').mockReturnValue({ agentless: { enabled: true, @@ -764,20 +762,19 @@ describe('Agentless Agent service', () => { 'mocked-agentless-agent-policy-id' ); - expect(axios).toHaveBeenCalledTimes(1); + expect(mockedFetch).toHaveBeenCalledTimes(1); expect(deleteAgentlessAgentReturnValue).toEqual(returnValue); - expect(axios).toHaveBeenCalledWith( + expect(mockedFetch).toHaveBeenCalledWith( + 'http://api.agentless.com/api/v1/ess/deployments/mocked-agentless-agent-policy-id', expect.objectContaining({ - headers: expect.anything(), - httpsAgent: expect.anything(), method: 'DELETE', - url: 'http://api.agentless.com/api/v1/ess/deployments/mocked-agentless-agent-policy-id', + headers: expect.anything(), }) ); }); it('should upgraded agentless agent for ESS', async () => { - jest.mocked(axios).mockResolvedValueOnce(mockAgentlessDeploymentResponse); + mockedFetch.mockResolvedValueOnce(createMockFetchResponse(mockAgentlessDeploymentResponseData)); jest.spyOn(appContextService, 'getConfig').mockReturnValue({ agentless: { enabled: true, @@ -800,27 +797,28 @@ describe('Agentless Agent service', () => { await agentlessAgentService.upgradeAgentlessDeployment('mocked-agentless-agent-policy-id'); - expect(axios).toHaveBeenCalledTimes(1); + expect(mockedFetch).toHaveBeenCalledTimes(1); - expect(axios).toHaveBeenCalledWith( + expect(mockedFetch).toHaveBeenCalledWith( + 'http://api.agentless.com/api/v1/ess/deployments/mocked-agentless-agent-policy-id', expect.objectContaining({ - headers: expect.anything(), - httpsAgent: expect.anything(), method: 'PUT', - data: { - stack_version: '8.18.0', - }, - url: 'http://api.agentless.com/api/v1/ess/deployments/mocked-agentless-agent-policy-id', + headers: expect.anything(), }) ); + const callBody = JSON.parse(mockedFetch.mock.calls[0][1].body); + expect(callBody).toEqual({ + stack_version: '8.18.0', + }); }); it('should delete agentless agent for serverless', async () => { const returnValue = { - id: 'mocked', + status: 200, + data: { id: 'mocked' }, }; - jest.mocked(axios).mockResolvedValueOnce(returnValue); + mockedFetch.mockResolvedValueOnce(createMockFetchResponse({ id: 'mocked' })); jest.spyOn(appContextService, 'getConfig').mockReturnValue({ agentless: { enabled: true, @@ -846,20 +844,19 @@ describe('Agentless Agent service', () => { 'mocked-agentless-agent-policy-id' ); - expect(axios).toHaveBeenCalledTimes(1); + expect(mockedFetch).toHaveBeenCalledTimes(1); expect(deleteAgentlessAgentReturnValue).toEqual(returnValue); - expect(axios).toHaveBeenCalledWith( + expect(mockedFetch).toHaveBeenCalledWith( + 'http://api.agentless.com/api/v1/serverless/deployments/mocked-agentless-agent-policy-id', expect.objectContaining({ - headers: expect.anything(), - httpsAgent: expect.anything(), method: 'DELETE', - url: 'http://api.agentless.com/api/v1/serverless/deployments/mocked-agentless-agent-policy-id', + headers: expect.anything(), }) ); }); it('should redact sensitive information from debug logs', async () => { - jest.mocked(axios).mockResolvedValueOnce(mockAgentlessDeploymentResponse); + mockedFetch.mockResolvedValueOnce(createMockFetchResponse(mockAgentlessDeploymentResponseData)); const soClient = getAgentPolicyCreateMock(); const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; @@ -922,7 +919,7 @@ describe('Agentless Agent service', () => { }); it('should log "undefined" on debug logs when tls configuration is missing', async () => { - jest.mocked(axios).mockResolvedValueOnce(mockAgentlessDeploymentResponse); + mockedFetch.mockResolvedValueOnce(createMockFetchResponse(mockAgentlessDeploymentResponseData)); const soClient = getAgentPolicyCreateMock(); const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; @@ -1014,9 +1011,8 @@ describe('Agentless Agent service', () => { }, ], } as any); - // Force axios to throw an AxiosError to simulate an error response - const axiosError = new AxiosError('Test Error'); - jest.mocked(axios).mockRejectedValueOnce(axiosError); + // Force fetch to throw an error to simulate an error response + mockedFetch.mockRejectedValueOnce(new Error('Test Error')); await expect( agentlessAgentService.createAgentlessAgent(esClient, soClient, { @@ -1037,7 +1033,7 @@ describe('Agentless Agent service', () => { }); it(`should have x-elastic-internal-origin in the headers when the request is internal`, async () => { - jest.mocked(axios).mockResolvedValueOnce(mockAgentlessDeploymentResponse); + mockedFetch.mockResolvedValueOnce(createMockFetchResponse(mockAgentlessDeploymentResponseData)); const soClient = getAgentPolicyCreateMock(); // ignore unrelated unique name constraint const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; @@ -1088,8 +1084,9 @@ describe('Agentless Agent service', () => { supports_agentless: true, } as AgentPolicy); - expect(axios).toHaveBeenCalledTimes(1); - expect(axios).toHaveBeenCalledWith( + expect(mockedFetch).toHaveBeenCalledTimes(1); + expect(mockedFetch).toHaveBeenCalledWith( + expect.any(String), expect.objectContaining({ headers: expect.objectContaining({ 'x-elastic-internal-origin': 'Kibana', @@ -1363,15 +1360,15 @@ describe('Agentless Agent service', () => { }, ], } as any); - // Force axios to throw an AxiosError to simulate an error response - const axiosError = new AxiosError('Test Error'); - axiosError.response = { - status: 999, - data: { - message: 'This is a fake error status that is never to be handled handled', - }, - } as AxiosResponse; - jest.mocked(axios).mockRejectedValueOnce(axiosError); + // Mock fetch to return a non-ok response to simulate an error + mockedFetch.mockResolvedValueOnce( + createMockFetchResponse( + { + message: 'This is a fake error status that is never to be handled handled', + }, + 999 + ) + ); await expect( agentlessAgentService.createAgentlessAgent(esClient, soClient, { @@ -1425,20 +1422,20 @@ describe('Agentless Agent service', () => { }, ], } as any); - // Force axios to throw an AxiosError to simulate an error response - const axiosError = new AxiosError('Test Error'); - axiosError.response = { - status: 500, - data: { - message: 'Internal Server Error', - }, - } as AxiosResponse; + // Mock fetch to return non-ok responses to simulate errors + const errorResponse = () => + createMockFetchResponse( + { + message: 'Internal Server Error', + }, + 500 + ); - jest.mocked(axios).mockRejectedValueOnce(axiosError); - jest.mocked(axios).mockRejectedValueOnce(axiosError); - jest.mocked(axios).mockRejectedValueOnce(axiosError); - jest.mocked(axios).mockRejectedValueOnce(axiosError); - jest.mocked(axios).mockRejectedValueOnce(axiosError); + mockedFetch.mockResolvedValueOnce(errorResponse()); + mockedFetch.mockResolvedValueOnce(errorResponse()); + mockedFetch.mockResolvedValueOnce(errorResponse()); + mockedFetch.mockResolvedValueOnce(errorResponse()); + mockedFetch.mockResolvedValueOnce(errorResponse()); await expect( agentlessAgentService.createAgentlessAgent(esClient, soClient, { @@ -1508,16 +1505,15 @@ describe('Agentless Agent service', () => { }, ], } as any); - // Force axios to throw an AxiosError to simulate an error response - const axiosError = new AxiosError('Test Error'); - axiosError.response = { - status: 429, - data: { - message: 'Limit exceeded', - }, - } as AxiosResponse; - - jest.mocked(axios).mockRejectedValueOnce(axiosError); + // Mock fetch to return a non-ok response to simulate an error + mockedFetch.mockResolvedValueOnce( + createMockFetchResponse( + { + message: 'Limit exceeded', + }, + 429 + ) + ); await expect( agentlessAgentService.createAgentlessAgent(esClient, soClient, { @@ -1571,16 +1567,15 @@ describe('Agentless Agent service', () => { }, ], } as any); - // Force axios to throw an AxiosError to simulate an error response - const axiosError = new AxiosError('Test Error'); - axiosError.response = { - status: 408, - data: { - message: 'Request timed out', - }, - } as AxiosResponse; - - jest.mocked(axios).mockRejectedValueOnce(axiosError); + // Mock fetch to return a non-ok response to simulate an error + mockedFetch.mockResolvedValueOnce( + createMockFetchResponse( + { + message: 'Request timed out', + }, + 408 + ) + ); await expect( agentlessAgentService.createAgentlessAgent(esClient, soClient, { @@ -1634,15 +1629,15 @@ describe('Agentless Agent service', () => { }, ], } as any); - // Force axios to throw an AxiosError to simulate an error response - const axiosError = new AxiosError('Test Error'); - axiosError.response = { - status: 404, - data: { - message: 'Not Found', - }, - } as AxiosResponse; - jest.mocked(axios).mockRejectedValueOnce(axiosError); + // Mock fetch to return a non-ok response to simulate an error + mockedFetch.mockResolvedValueOnce( + createMockFetchResponse( + { + message: 'Not Found', + }, + 404 + ) + ); await expect( agentlessAgentService.createAgentlessAgent(esClient, soClient, { @@ -1696,16 +1691,15 @@ describe('Agentless Agent service', () => { }, ], } as any); - // Force axios to throw an AxiosError to simulate an error response - const axiosError = new AxiosError('Test Error'); - axiosError.response = { - status: 403, - data: { - message: 'Forbidden', - }, - } as AxiosResponse; - - jest.mocked(axios).mockRejectedValueOnce(axiosError); + // Mock fetch to return a non-ok response to simulate an error + mockedFetch.mockResolvedValueOnce( + createMockFetchResponse( + { + message: 'Forbidden', + }, + 403 + ) + ); await expect( agentlessAgentService.createAgentlessAgent(esClient, soClient, { @@ -1759,16 +1753,15 @@ describe('Agentless Agent service', () => { }, ], } as any); - // Force axios to throw an AxiosError to simulate an error response - const axiosError = new AxiosError('Test Error'); - axiosError.response = { - status: 401, - data: { - message: 'Unauthorized', - }, - } as AxiosResponse; - - jest.mocked(axios).mockRejectedValueOnce(axiosError); + // Mock fetch to return a non-ok response to simulate an error + mockedFetch.mockResolvedValueOnce( + createMockFetchResponse( + { + message: 'Unauthorized', + }, + 401 + ) + ); await expect( agentlessAgentService.createAgentlessAgent(esClient, soClient, { @@ -1822,16 +1815,15 @@ describe('Agentless Agent service', () => { }, ], } as any); - // Force axios to throw an AxiosError to simulate an error response - const axiosError = new AxiosError('Bad Request'); - axiosError.response = { - status: 400, - data: { - message: 'Bad Request', - }, - } as AxiosResponse; - - jest.mocked(axios).mockRejectedValueOnce(axiosError); + // Mock fetch to return a non-ok response to simulate an error + mockedFetch.mockResolvedValueOnce( + createMockFetchResponse( + { + message: 'Bad Request', + }, + 400 + ) + ); await expect( agentlessAgentService.createAgentlessAgent(esClient, soClient, { @@ -1886,17 +1878,16 @@ describe('Agentless Agent service', () => { ], } as any); - const mockedError = new AxiosError('Bad Request'); - - mockedError.response = { - status: 400, - data: { - code: 'FLEET_UNREACHABLE', - message: 'Bad Request', - }, - } as AxiosResponse; - // Force axios to throw an AxiosError to simulate an error response - jest.mocked(axios).mockRejectedValueOnce(mockedError); + // Mock fetch to return a non-ok response to simulate an error + mockedFetch.mockResolvedValueOnce( + createMockFetchResponse( + { + code: 'FLEET_UNREACHABLE', + message: 'Bad Request', + }, + 400 + ) + ); await expect( agentlessAgentService.createAgentlessAgent(esClient, soClient, { @@ -1951,17 +1942,17 @@ describe('Agentless Agent service', () => { ], } as any); - const mockedError = new AxiosError('reached limit: 5'); - - mockedError.response = { - status: 429, - data: { - code: 'OVER_PROVISIONED', - message: 'reached limit: 5', - }, - } as AxiosResponse; - // Force axios to throw an AxiosError to simulate an error response - jest.mocked(axios).mockRejectedValueOnce(mockedError); + // Mock fetch to return a non-ok response to simulate an error + mockedFetch.mockResolvedValueOnce( + createMockFetchResponse( + { + code: 'OVER_PROVISIONED', + error: 'reached limit: 5', + message: 'reached limit: 5', + }, + 429 + ) + ); await expect( agentlessAgentService.createAgentlessAgent(esClient, soClient, { @@ -2014,14 +2005,8 @@ describe('Agentless Agent service', () => { ], } as any); - const mockedError = new AxiosError('reached limit: 5'); - - mockedError.response = { - status: 404, - data: {}, - } as AxiosResponse; - // Force axios to throw an AxiosError to simulate an error response - jest.mocked(axios).mockRejectedValueOnce(mockedError); + // Mock fetch to return a non-ok response to simulate an error + mockedFetch.mockResolvedValueOnce(createMockFetchResponse({}, 404)); await expect(agentlessAgentService.listAgentlessDeployments()).rejects.toThrowError( AgentlessAgentListNotFoundError diff --git a/x-pack/platform/plugins/shared/fleet/server/services/agents/agentless_agent.ts b/x-pack/platform/plugins/shared/fleet/server/services/agents/agentless_agent.ts index 321d9b7ca5005..f07cffd6378b8 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/agents/agentless_agent.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/agents/agentless_agent.ts @@ -5,16 +5,13 @@ * 2.0. */ -import https from 'https'; - import type { ElasticsearchClient, LogMeta, SavedObjectsClientContract } from '@kbn/core/server'; import type { Logger } from '@kbn/logging'; import { SslConfig, sslSchema } from '@kbn/server-http-tools'; import pRetry, { type FailedAttemptError } from 'p-retry'; import { pick } from 'lodash'; -import type { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios'; -import axios from 'axios'; +import { Agent } from 'undici'; import apm from 'elastic-apm-node'; @@ -61,6 +58,24 @@ import { } from '../../../common/constants/agentless'; import { agentPolicyService } from '../agent_policy'; +interface FetchRequestConfig { + url: string; + method: string; + data?: Record; + params?: Record; + headers?: Record; + dispatcher?: Agent; +} + +interface FetchResponseError extends Error { + response?: { + status: number; + data?: Record; + }; + request?: boolean; + code?: string; +} + interface AgentlessAgentErrorHandlingMessages { [key: string]: { [key: string]: { @@ -79,8 +94,10 @@ export interface AgentlessAgentService { esClient: ElasticsearchClient, soClient: SavedObjectsClientContract, agentlessAgentPolicy: AgentPolicy - ): Promise | void>; - deleteAgentlessAgent(agentlessPolicyId: string): Promise; + ): Promise<{ status: number; data: AgentlessApiDeploymentResponse } | void>; + deleteAgentlessAgent( + agentlessPolicyId: string + ): Promise<{ status: number; data: unknown } | void>; } class AgentlessAgentServiceImpl implements AgentlessAgentService { @@ -171,50 +188,55 @@ class AgentlessAgentServiceImpl implements AgentlessAgentService { ); const policyDetails = await this.getPolicyDetails(soClient, fullPolicy); - const requestConfig: AxiosRequestConfig = { - url: prependAgentlessApiBasePathToEndpoint(agentlessConfig, '/deployments'), - data: { - policy_id: agentlessAgentPolicy.id, - fleet_url: fleetUrl, - fleet_token: fleetToken, - resources: agentlessAgentPolicy.agentless?.resources, - cloud_connectors: agentlessAgentPolicy.agentless?.cloud_connectors, - labels, - secrets, - policy_details: policyDetails, - agent_policy: fullPolicy, - }, - method: 'POST', - ...this.getHeaders(tlsConfig, traceId), + const requestData: Record = { + policy_id: agentlessAgentPolicy.id, + fleet_url: fleetUrl, + fleet_token: fleetToken, + resources: agentlessAgentPolicy.agentless?.resources, + cloud_connectors: agentlessAgentPolicy.agentless?.cloud_connectors, + labels, + secrets, + policy_details: policyDetails, + agent_policy: fullPolicy, }; const cloudSetup = appContextService.getCloud(); if (!cloudSetup?.isServerlessEnabled) { - requestConfig.data.stack_version = appContextService.getKibanaVersion(); + requestData.stack_version = appContextService.getKibanaVersion(); } + const requestConfig: FetchRequestConfig = { + url: prependAgentlessApiBasePathToEndpoint(agentlessConfig, '/deployments'), + data: requestData, + method: 'POST', + ...this.getHeaders(tlsConfig, traceId), + }; + const requestConfigDebugStatus = this.createRequestConfigDebug(requestConfig); logger.debug( `[Agentless API] Creating agentless agent with request config ${requestConfigDebugStatus}` ); - const response = await pRetry(() => axios(requestConfig), { - retries: MAXIMUM_RETRIES, - minTimeout: 0, - maxTimeout: 100, - onFailedAttempt: (error: FailedAttemptError) => { - if (!this.isErrorRetryable(error as unknown as AxiosError)) { - throw error; - } - if (error.retriesLeft > 0) { - logger.warn( - `[Agentless API] Retrying creating agentless agent ${agentlessAgentPolicy.id}`, - { error } - ); - } - }, - }).catch((error: Error | AxiosError) => { + const response = await pRetry( + () => this.fetchRequest(requestConfig), + { + retries: MAXIMUM_RETRIES, + minTimeout: 0, + maxTimeout: 100, + onFailedAttempt: (error: FailedAttemptError) => { + if (!this.isErrorRetryable(error as unknown as FetchResponseError)) { + throw error; + } + if (error.retriesLeft > 0) { + logger.warn( + `[Agentless API] Retrying creating agentless agent ${agentlessAgentPolicy.id}`, + { error } + ); + } + }, + } + ).catch((error: Error | FetchResponseError) => { this.catchAgentlessApiError( 'create', error, @@ -236,7 +258,7 @@ class AgentlessAgentServiceImpl implements AgentlessAgentService { const traceId = apm.currentTransaction?.traceparent; const agentlessConfig = appContextService.getConfig()?.agentless; const tlsConfig = this.createTlsConfig(agentlessConfig); - const requestConfig = { + const requestConfig: FetchRequestConfig = { url: prependAgentlessApiBasePathToEndpoint( agentlessConfig, `/deployments/${agentlessPolicyId}` @@ -273,7 +295,7 @@ class AgentlessAgentServiceImpl implements AgentlessAgentService { `[Agentless API] Deleting agentless deployment with request config ${requestConfigDebugStatus}` ); - const response = await axios(requestConfig).catch((error: AxiosError) => { + const response = await this.fetchRequest(requestConfig).catch((error: FetchResponseError) => { this.catchAgentlessApiError( 'delete', error, @@ -302,7 +324,7 @@ class AgentlessAgentServiceImpl implements AgentlessAgentService { logger.info( `[Agentless API] Call Agentless API endpoint ${urlEndpoint} to upgrade agentless deployment` ); - const requestConfig = { + const requestConfig: FetchRequestConfig = { url: prependAgentlessApiBasePathToEndpoint(agentlessConfig, `/deployments/${policyId}`), method: 'PUT', data: { @@ -339,19 +361,21 @@ class AgentlessAgentServiceImpl implements AgentlessAgentService { `[Agentless API] Upgrade agentless deployment with request config ${requestConfigDebugStatus}` ); - const response = await axios(requestConfig).catch(async (error: AxiosError) => { - await this.handleErrorsWithRetries( - error, - requestConfig, - 'upgrade', - logger, - MAXIMUM_RETRIES, - policyId, - requestConfigDebugStatus, - errorMetadata, - traceId - ); - }); + const response = await this.fetchRequest(requestConfig).catch( + async (error: FetchResponseError) => { + await this.handleErrorsWithRetries( + error, + requestConfig, + 'upgrade', + logger, + MAXIMUM_RETRIES, + policyId, + requestConfigDebugStatus, + errorMetadata, + traceId + ); + } + ); return response; } @@ -362,7 +386,7 @@ class AgentlessAgentServiceImpl implements AgentlessAgentService { const traceId = apm.currentTransaction?.traceparent; const agentlessConfig = appContextService.getConfig()?.agentless; const tlsConfig = this.createTlsConfig(agentlessConfig); - const requestConfig: AxiosRequestConfig = { + const requestConfig: FetchRequestConfig = { url: prependAgentlessApiBasePathToEndpoint(agentlessConfig, '/deployments'), method: 'GET', params: { @@ -398,7 +422,9 @@ class AgentlessAgentServiceImpl implements AgentlessAgentService { `[Agentless API] Listing agentless deployments with request config ${requestConfigDebugStatus}` ); - const response = await axios(requestConfig).catch((error: AxiosError) => { + const response = await this.fetchRequest( + requestConfig + ).catch((error: FetchResponseError) => { this.catchAgentlessApiError( 'list', error, @@ -414,6 +440,73 @@ class AgentlessAgentServiceImpl implements AgentlessAgentService { return response.data; } + private async fetchRequest( + requestConfig: FetchRequestConfig + ): Promise<{ status: number; data: T }> { + let url = requestConfig.url; + if (requestConfig.params) { + const searchParams = new URLSearchParams(); + for (const [key, value] of Object.entries(requestConfig.params)) { + if (value !== undefined && value !== null) { + searchParams.append(key, String(value)); + } + } + const queryString = searchParams.toString(); + if (queryString) { + url = `${url}?${queryString}`; + } + } + + const fetchOptions: RequestInit & { dispatcher?: Agent } = { + method: requestConfig.method, + headers: requestConfig.headers as Record, + }; + + if (requestConfig.data && requestConfig.method !== 'GET') { + fetchOptions.body = JSON.stringify(requestConfig.data); + } + + if (requestConfig.dispatcher) { + fetchOptions.dispatcher = requestConfig.dispatcher; + } + + let response: Response; + try { + response = await fetch(url, fetchOptions); + } catch (err) { + const fetchError: FetchResponseError = new Error( + err instanceof Error ? err.message : String(err) + ); + fetchError.request = true; + fetchError.code = (err as NodeJS.ErrnoException)?.code; + if (err instanceof Error) { + fetchError.cause = err.cause; + } + throw fetchError; + } + + let data: T; + try { + data = (await response.json()) as T; + } catch { + data = {} as T; + } + + if (!response.ok) { + const fetchError: FetchResponseError = new Error( + `Request failed with status ${response.status}` + ); + fetchError.response = { + status: response.status, + data: data as unknown as Record, + }; + fetchError.request = true; + throw fetchError; + } + + return { status: response.status, data }; + } + private getAgentlessSecrets() { const deploymentSecrets = appContextService.getConfig()?.agentless?.deploymentSecrets; @@ -436,11 +529,13 @@ class AgentlessAgentServiceImpl implements AgentlessAgentService { 'X-Request-ID': traceId, 'x-elastic-internal-origin': 'Kibana', }, - httpsAgent: new https.Agent({ - rejectUnauthorized: tlsConfig.rejectUnauthorized, - cert: tlsConfig.certificate, - key: tlsConfig.key, - ca: tlsConfig.certificateAuthorities, + dispatcher: new Agent({ + connect: { + rejectUnauthorized: tlsConfig.rejectUnauthorized, + cert: tlsConfig.certificate, + key: tlsConfig.key, + ca: tlsConfig.certificateAuthorities as string | string[] | undefined, + }, }), }; } @@ -517,12 +612,12 @@ class AgentlessAgentServiceImpl implements AgentlessAgentService { return { fleetUrl, fleetToken }; } - private createRequestConfigDebug(requestConfig: AxiosRequestConfig) { + private createRequestConfigDebug(requestConfig: FetchRequestConfig) { return JSON.stringify({ ...requestConfig, data: { ...pick( - requestConfig.data, + requestConfig.data ?? {}, 'policy_id', 'fleet_url', 'labels', @@ -532,24 +627,24 @@ class AgentlessAgentServiceImpl implements AgentlessAgentService { agent_policy: '[REDACTED]', fleet_token: '[REDACTED]', }, - httpsAgent: { - ...requestConfig.httpsAgent, - options: { - ...requestConfig.httpsAgent.options, - cert: requestConfig.httpsAgent.options.cert ? 'REDACTED' : undefined, - key: requestConfig.httpsAgent.options.key ? 'REDACTED' : undefined, - ca: requestConfig.httpsAgent.options.ca ? 'REDACTED' : undefined, - }, - }, + dispatcher: requestConfig.dispatcher + ? { + options: { + cert: 'REDACTED', + key: 'REDACTED', + ca: 'REDACTED', + }, + } + : undefined, }); } private catchAgentlessApiError( action: 'create' | 'delete' | 'upgrade' | 'list', - error: Error | AxiosError, + error: Error | FetchResponseError, logger: Logger, agentlessPolicyId: string | undefined, - requestConfig: AxiosRequestConfig, + requestConfig: FetchRequestConfig, requestConfigDebugStatus: string, errorMetadata: LogMeta, traceId?: string @@ -563,23 +658,25 @@ class AgentlessAgentServiceImpl implements AgentlessAgentService { }, }; - const errorLogCodeCause = (axiosError: AxiosError) => - `${axiosError.code} ${this.convertCauseErrorsToString(axiosError)}`; + const errorLogCodeCause = (fetchError: FetchResponseError) => + `${fetchError.code} ${this.convertCauseErrorsToString(fetchError)}`; + + const fetchError = error as FetchResponseError; - if (!axios.isAxiosError(error)) { + if (!fetchError.response && !fetchError.request) { let errorLogMessage; if (action === 'create') { - errorLogMessage = `[Agentless API] Creating agentless failed with an error that is not an AxiosError for agentless policy`; + errorLogMessage = `[Agentless API] Creating agentless failed with an error that is not an HTTP response error for agentless policy`; } if (action === 'delete') { - errorLogMessage = `[Agentless API] Deleting agentless deployment failed with an error that is not an Axios error for agentless policy`; + errorLogMessage = `[Agentless API] Deleting agentless deployment failed with an error that is not an HTTP response error for agentless policy`; } if (action === 'upgrade') { - errorLogMessage = `[Agentless API] Upgrading agentless deployment failed with an error that is not an Axios error for agentless policy`; + errorLogMessage = `[Agentless API] Upgrading agentless deployment failed with an error that is not an HTTP response error for agentless policy`; } if (action === 'list') { - errorLogMessage = `[Agentless API] Listing agentless deployments failed with an error that is not an Axios error for agentless policy`; + errorLogMessage = `[Agentless API] Listing agentless deployments failed with an error that is not an HTTP response error for agentless policy`; } logger.error( @@ -593,16 +690,16 @@ class AgentlessAgentServiceImpl implements AgentlessAgentService { const ERROR_HANDLING_MESSAGES: AgentlessAgentErrorHandlingMessages = this.getErrorHandlingMessages(agentlessPolicyId); - if (error.response) { + if (fetchError.response) { // The request was made and the server responded with a status code and error data const responseErrorMessage = - error.response.status in ERROR_HANDLING_MESSAGES - ? ERROR_HANDLING_MESSAGES[error.response.status][action] + fetchError.response.status in ERROR_HANDLING_MESSAGES + ? ERROR_HANDLING_MESSAGES[fetchError.response.status][action] : ERROR_HANDLING_MESSAGES.unhandled_response[action]; this.handleResponseError( action, - error.response, + fetchError.response, logger, errorMetadataWithRequestConfig, requestConfigDebugStatus, @@ -610,11 +707,11 @@ class AgentlessAgentServiceImpl implements AgentlessAgentService { responseErrorMessage.message, traceId ); - } else if (error.request) { + } else if (fetchError.request) { // The request was made but no response was received const requestErrorMessage = ERROR_HANDLING_MESSAGES.request_error[action]; logger.error( - `${requestErrorMessage.log} ${errorLogCodeCause(error)} ${requestConfigDebugStatus}`, + `${requestErrorMessage.log} ${errorLogCodeCause(fetchError)} ${requestConfigDebugStatus}`, errorMetadataWithRequestConfig ); @@ -623,7 +720,7 @@ class AgentlessAgentServiceImpl implements AgentlessAgentService { // Something happened in setting up the request that triggered an Error logger.error( `[Agentless API] ${action + 'ing'} the agentless agent failed ${errorLogCodeCause( - error + fetchError )} ${requestConfigDebugStatus}`, errorMetadataWithRequestConfig ); @@ -638,7 +735,7 @@ class AgentlessAgentServiceImpl implements AgentlessAgentService { private handleResponseError( action: 'create' | 'delete' | 'upgrade' | 'list', - response: AxiosResponse, + response: { status: number; data?: Record }, logger: Logger, errorMetadataWithRequestConfig: LogMeta, requestConfigDebugStatus: string, @@ -662,15 +759,15 @@ class AgentlessAgentServiceImpl implements AgentlessAgentService { ); const responseData = { - code: response?.data?.code, - error: response?.data?.error, + code: response?.data?.code as string | undefined, + error: response?.data?.error as string | undefined, statusCode: response?.status, }; throw this.getAgentlessAgentError(action, userMessage, traceId, responseData); } - private convertCauseErrorsToString = (error: AxiosError) => { + private convertCauseErrorsToString = (error: FetchResponseError) => { if (error.cause instanceof AggregateError) { return error.cause.errors.map((e: Error) => e.message); } @@ -886,7 +983,7 @@ class AgentlessAgentServiceImpl implements AgentlessAgentService { }; } - private isErrorRetryable = (error: AxiosError): boolean => { + private isErrorRetryable = (error: FetchResponseError): boolean => { const hasRetryableStatusError = this.hasRetryableStatusError(error, RETRYABLE_HTTP_STATUSES); const hasRetryableCodeError = this.hasRetryableCodeError(error, RETRYABLE_SERVER_CODES); @@ -894,8 +991,8 @@ class AgentlessAgentServiceImpl implements AgentlessAgentService { }; private handleErrorsWithRetries = async ( - error: AxiosError, - requestConfig: AxiosRequestConfig, + error: FetchResponseError, + requestConfig: FetchRequestConfig, action: 'create' | 'delete' | 'upgrade', logger: Logger, retries: number, @@ -906,7 +1003,7 @@ class AgentlessAgentServiceImpl implements AgentlessAgentService { ) => { if (this.isErrorRetryable(error)) { await this.retry( - async () => await axios(requestConfig), + async () => await this.fetchRequest(requestConfig), action, requestConfigDebugStatus, logger, @@ -963,14 +1060,17 @@ class AgentlessAgentServiceImpl implements AgentlessAgentService { }; private hasRetryableStatusError = ( - error: AxiosError, + error: FetchResponseError, retryableStatusErrors: number[] ): boolean => { const status = error?.response?.status; return !!status && retryableStatusErrors.some((errorStatus) => errorStatus === status); }; - private hasRetryableCodeError = (error: AxiosError, retryableCodeErrors: string[]): boolean => { + private hasRetryableCodeError = ( + error: FetchResponseError, + retryableCodeErrors: string[] + ): boolean => { const code = error?.code; return !!code && retryableCodeErrors.includes(code); }; diff --git a/x-pack/platform/plugins/shared/fleet/server/telemetry/sender.test.ts b/x-pack/platform/plugins/shared/fleet/server/telemetry/sender.test.ts index 978bcda2dbbc6..26e0f87085adb 100644 --- a/x-pack/platform/plugins/shared/fleet/server/telemetry/sender.test.ts +++ b/x-pack/platform/plugins/shared/fleet/server/telemetry/sender.test.ts @@ -9,8 +9,6 @@ import { URL } from 'url'; -import axios from 'axios'; - import type { InfoResponse } from '@elastic/elasticsearch/lib/api/types'; import { loggingSystemMock } from '@kbn/core/server/mocks'; @@ -21,17 +19,17 @@ import { UpdateEventType } from '../services/upgrade_sender'; import { TelemetryEventsSender } from './sender'; -jest.mock('axios', () => { - return { - post: jest.fn(), - }; -}); - describe('TelemetryEventsSender', () => { let logger: ReturnType; let sender: TelemetryEventsSender; + let mockedFetch: jest.SpyInstance; beforeEach(async () => { + mockedFetch = jest + .spyOn(global, 'fetch') + .mockResolvedValue( + new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }) + ); logger = loggingSystemMock.createLogger(); sender = new TelemetryEventsSender(logger); sender['fetchClusterInfo'] = jest.fn(async () => { @@ -48,6 +46,10 @@ describe('TelemetryEventsSender', () => { } as any); }); + afterEach(() => { + mockedFetch.mockRestore(); + }); + describe('queueTelemetryEvents', () => { it('queues two events', () => { sender.queueTelemetryEvents('fleet-upgrades', [ @@ -145,23 +147,29 @@ describe('TelemetryEventsSender', () => { expect(sender['queuesPerChannel']['my-channel']['getEvents']).toBeCalledTimes(1); expect(sender['queuesPerChannel']['my-channel2']['getEvents']).toBeCalledTimes(1); - const requestConfig = { - headers: { - 'Content-Type': 'application/x-ndjson', - 'X-Elastic-Cluster-ID': '1', - 'X-Elastic-Stack-Version': '8.0.0', - }, - timeout: 5000, - }; - expect(axios.post).toHaveBeenCalledWith( + expect(mockedFetch).toHaveBeenCalledWith( 'https://telemetry.elastic.co/v3/send/my-channel', - '{"event.kind":"1"}\n{"event.kind":"2"}\n', - requestConfig + expect.objectContaining({ + method: 'POST', + body: '{"event.kind":"1"}\n{"event.kind":"2"}\n', + headers: expect.objectContaining({ + 'Content-Type': 'application/x-ndjson', + 'X-Elastic-Cluster-ID': '1', + 'X-Elastic-Stack-Version': '8.0.0', + }), + }) ); - expect(axios.post).toHaveBeenCalledWith( + expect(mockedFetch).toHaveBeenCalledWith( 'https://telemetry.elastic.co/v3/send/my-channel2', - '{"event.kind":"3"}\n', - requestConfig + expect.objectContaining({ + method: 'POST', + body: '{"event.kind":"3"}\n', + headers: expect.objectContaining({ + 'Content-Type': 'application/x-ndjson', + 'X-Elastic-Cluster-ID': '1', + 'X-Elastic-Stack-Version': '8.0.0', + }), + }) ); }); }); diff --git a/x-pack/platform/plugins/shared/fleet/server/telemetry/sender.ts b/x-pack/platform/plugins/shared/fleet/server/telemetry/sender.ts index 0823fbe5c36f1..0b1eac4fdcfd2 100644 --- a/x-pack/platform/plugins/shared/fleet/server/telemetry/sender.ts +++ b/x-pack/platform/plugins/shared/fleet/server/telemetry/sender.ts @@ -10,8 +10,6 @@ import type { TelemetryPluginStart, TelemetryPluginSetup } from '@kbn/telemetry- import { cloneDeep } from 'lodash'; -import axios from 'axios'; - import type { InfoResponse, LicenseGetResponse } from '@elastic/elasticsearch/lib/api/types'; import { exhaustMap, Subject, takeUntil, timer } from 'rxjs'; @@ -191,21 +189,23 @@ export class TelemetryEventsSender { const ndjson = this.transformDataToNdjson(events); try { - const resp = await axios.post(telemetryUrl, ndjson, { + const resp = await fetch(telemetryUrl, { + method: 'POST', + body: ndjson, headers: { 'Content-Type': 'application/x-ndjson', ...(clusterUuid ? { 'X-Elastic-Cluster-ID': clusterUuid } : undefined), 'X-Elastic-Stack-Version': clusterVersionNumber ? clusterVersionNumber : '7.16.0', }, - timeout: 5000, + signal: AbortSignal.timeout(5000), }); - this.logger.debug( - () => `Events sent!. Response: ${resp.status} ${JSON.stringify(resp.data)}` - ); + const respData = await resp.text(); + this.logger.debug(() => `Events sent!. Response: ${resp.status} ${respData}`); + if (!resp.ok) { + this.logger.debug(() => `Error sending events: ${resp.status} ${respData}`); + } } catch (err) { - this.logger.debug( - () => `Error sending events: ${err?.response?.status} ${JSON.stringify(err.response.data)}` - ); + this.logger.debug(() => `Error sending events: ${err}`); } } diff --git a/x-pack/platform/plugins/shared/inference/scripts/util/kibana_client.ts b/x-pack/platform/plugins/shared/inference/scripts/util/kibana_client.ts index 30b411137752d..179ac4649aa7c 100644 --- a/x-pack/platform/plugins/shared/inference/scripts/util/kibana_client.ts +++ b/x-pack/platform/plugins/shared/inference/scripts/util/kibana_client.ts @@ -6,15 +6,13 @@ */ import type { ToolingLog } from '@kbn/tooling-log'; -import type { AxiosInstance, AxiosResponse } from 'axios'; -import axios, { isAxiosError } from 'axios'; import type { IncomingMessage } from 'http'; -import { omit, pick } from 'lodash'; import { from, map, switchMap, throwError } from 'rxjs'; import type { UrlObject } from 'url'; import { format, parse } from 'url'; import { inspect } from 'util'; -import { isReadable } from 'stream'; +import { Readable } from 'stream'; +import type { ReadableStream as WebReadableStream } from 'stream/web'; import type { ChatCompleteAPI, OutputAPI, @@ -43,18 +41,28 @@ export interface ScriptInferenceClient { output: OutputAPI; } +interface FetchResponseError extends Error { + status?: number; + responseData?: unknown; + responseHeaders?: Record; + responseStatusText?: string; +} + +function isFetchError(error: unknown): error is FetchResponseError { + return error instanceof Error && 'status' in error; +} + export class KibanaClient { - axios: AxiosInstance; + private readonly defaultHeaders: Record; + constructor( private readonly log: ToolingLog, private readonly url: string, private readonly spaceId?: string ) { - this.axios = axios.create({ - headers: { - 'kbn-xsrf': 'foo', - }, - }); + this.defaultHeaders = { + 'kbn-xsrf': 'foo', + }; } private getUrl(props: { query?: UrlObject['query']; pathname: string; ignoreSpaceId?: boolean }) { @@ -75,36 +83,63 @@ export class KibanaClient { return url; } - callKibana( + async callKibana( method: string, props: { query?: UrlObject['query']; pathname: string; ignoreSpaceId?: boolean }, data?: any - ) { + ): Promise<{ status: number; data: T; headers: Record }> { const url = this.getUrl(props); - return this.axios({ + const resp = await fetch(url, { method, - url, - data: data || {}, headers: { + ...this.defaultHeaders, 'kbn-xsrf': 'true', 'x-elastic-internal-origin': 'foo', + 'Content-Type': 'application/json', }, + body: data !== undefined ? JSON.stringify(data) : JSON.stringify({}), }).catch((error) => { - if (isAxiosError(error)) { - const interestingPartsOfError = { - ...omit(error, 'request', 'response', 'config'), - ...pick( - error, - 'response.data', - 'response.headers', - 'response.status', - 'response.statusText' - ), - }; - this.log.error(inspect(interestingPartsOfError, { depth: 10 })); + throw error; + }); + + if (!resp.ok) { + const respBody = await resp.text().catch(() => ''); + let parsedBody: unknown; + try { + parsedBody = JSON.parse(respBody); + } catch { + parsedBody = respBody; } + + const error: FetchResponseError = new Error(`Request failed with status ${resp.status}`); + error.status = resp.status; + error.responseData = parsedBody; + error.responseStatusText = resp.statusText; + const responseHeaders: Record = {}; + resp.headers.forEach((value, key) => { + responseHeaders[key] = value; + }); + error.responseHeaders = responseHeaders; + + const interestingPartsOfError = { + message: error.message, + status: error.status, + responseData: error.responseData, + responseHeaders: error.responseHeaders, + responseStatusText: error.responseStatusText, + }; + this.log.error(inspect(interestingPartsOfError, { depth: 10 })); + throw error; + } + + const responseHeaders: Record = {}; + resp.headers.forEach((value, key) => { + responseHeaders[key] = value; }); + + const responseData = (await resp.json()) as T; + return { status: resp.status, data: responseData, headers: responseHeaders }; } async createSpaceIfNeeded() { @@ -114,22 +149,24 @@ export class KibanaClient { this.log.debug(`Checking if space ${this.spaceId} exists`); - const spaceExistsResponse = await this.callKibana<{ - id?: string; - }>('GET', { - pathname: `/api/spaces/space/${this.spaceId}`, - ignoreSpaceId: true, - }).catch((error) => { - if (isAxiosError(error) && error.response?.status === 404) { - return { + let spaceExistsResponse: { status: number; data: { id?: string } }; + try { + spaceExistsResponse = await this.callKibana<{ id?: string }>('GET', { + pathname: `/api/spaces/space/${this.spaceId}`, + ignoreSpaceId: true, + }); + } catch (error) { + if (isFetchError(error) && error.status === 404) { + spaceExistsResponse = { status: 404, data: { id: undefined, }, }; + } else { + throw error; } - throw error; - }); + } if (spaceExistsResponse.data.id) { this.log.debug(`Space id ${this.spaceId} found`); @@ -160,13 +197,16 @@ export class KibanaClient { } createInferenceClient({ connectorId }: { connectorId: string }): ScriptInferenceClient { - function streamResponse(responsePromise: Promise) { + function streamResponse(responsePromise: Promise) { return from(responsePromise).pipe( switchMap((response) => { - if (isReadable(response.data)) { - return eventSourceStreamIntoObservable(response.data as IncomingMessage); + if (response.body) { + const nodeStream = Readable.fromWeb( + response.body as unknown as WebReadableStream + ) as IncomingMessage; + return eventSourceStreamIntoObservable(nodeStream); } - return throwError(() => createInferenceInternalError('Unexpected error', response.data)); + return throwError(() => createInferenceInternalError('Unexpected error')); }), map((line) => { return JSON.parse(line) as ChatCompletionEvent | InferenceTaskErrorEvent; @@ -200,40 +240,44 @@ export class KibanaClient { if (stream) { return streamResponse( - this.axios.post( + fetch( this.getUrl({ pathname: `/internal/inference/chat_complete/stream`, }), - body, { - responseType: 'stream', - timeout: 0, + method: 'POST', headers: { + ...this.defaultHeaders, 'kbn-xsrf': 'true', 'x-elastic-internal-origin': 'foo', + 'Content-Type': 'application/json', }, + body: JSON.stringify(body), } ) ) as ChatCompleteAPIResponse; } - return this.axios - .post( - this.getUrl({ - pathname: `/internal/inference/chat_complete`, - }), - body, - { - timeout: 0, - headers: { - 'kbn-xsrf': 'true', - 'x-elastic-internal-origin': 'foo', - }, - } - ) - .then((response) => { - return response.data; - }) as ChatCompleteAPIResponse; + return fetch( + this.getUrl({ + pathname: `/internal/inference/chat_complete`, + }), + { + method: 'POST', + headers: { + ...this.defaultHeaders, + 'kbn-xsrf': 'true', + 'x-elastic-internal-origin': 'foo', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + } + ).then(async (response) => { + if (!response.ok) { + throw new Error(`Request failed with status ${response.status}`); + } + return response.json(); + }) as ChatCompleteAPIResponse; }; const outputApi: OutputAPI = createOutputApi(chatCompleteApi); diff --git a/x-pack/platform/plugins/shared/observability_ai_assistant/server/functions/kibana.test.ts b/x-pack/platform/plugins/shared/observability_ai_assistant/server/functions/kibana.test.ts index f6e53c2b78995..7b9a3e728988d 100644 --- a/x-pack/platform/plugins/shared/observability_ai_assistant/server/functions/kibana.test.ts +++ b/x-pack/platform/plugins/shared/observability_ai_assistant/server/functions/kibana.test.ts @@ -5,13 +5,10 @@ * 2.0. */ -import type { AxiosRequestConfig } from 'axios'; -import axios from 'axios'; import { registerKibanaFunction } from './kibana'; import type { FunctionRegistrationParameters } from '.'; -jest.mock('axios'); -const mockedAxios = jest.mocked(axios); +let mockedFetch: jest.SpyInstance; function registerFunction(overrides: { publicBaseUrl?: string; @@ -64,7 +61,16 @@ function registerFunction(overrides: { describe('kibana tool', () => { beforeEach(() => { jest.clearAllMocks(); - mockedAxios.mockResolvedValue({ data: { ok: true } }); + mockedFetch = jest.spyOn(global, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ); + }); + + afterEach(() => { + mockedFetch.mockRestore(); }); it('forwards requests to the configured publicBaseUrl host only', async () => { @@ -84,11 +90,11 @@ describe('kibana tool', () => { }, }); - const forwardedRequest = mockedAxios.mock.calls[0][0] as AxiosRequestConfig; - expect(forwardedRequest.url).toBe( + const forwardedUrl = mockedFetch.mock.calls[0][0] as string; + expect(forwardedUrl).toBe( 'https://kibana.example.com:5601/api/saved_objects/_find?type=dashboard' ); - expect(forwardedRequest.url).not.toContain('malicious-host'); + expect(forwardedUrl).not.toContain('malicious-host'); }); it('builds the forwarded url using the space from the incoming request path', async () => { @@ -106,10 +112,10 @@ describe('kibana tool', () => { }, }); - expect(mockedAxios).toHaveBeenCalledWith( + expect(mockedFetch).toHaveBeenCalledWith( + 'https://kibana.example.com:5601/s/my-space/api/apm/agent_keys', expect.objectContaining({ - url: 'https://kibana.example.com:5601/s/my-space/api/apm/agent_keys', - data: JSON.stringify({ foo: 'bar' }), + body: JSON.stringify({ foo: 'bar' }), }) ); }); @@ -129,9 +135,9 @@ describe('kibana tool', () => { }, }); - const forwardedRequest = mockedAxios.mock.calls[0][0] as AxiosRequestConfig; - expect(forwardedRequest.headers?.authorization).toBe('Basic dGVzdA=='); - expect(forwardedRequest.headers).not.toHaveProperty('x-forwarded-user'); + const forwardedHeaders = mockedFetch.mock.calls[0][1].headers as Record; + expect(forwardedHeaders.authorization).toBe('Basic dGVzdA=='); + expect(forwardedHeaders).not.toHaveProperty('x-forwarded-user'); }); it('throws when server.publicBaseUrl is not configured', async () => { diff --git a/x-pack/platform/plugins/shared/observability_ai_assistant/server/functions/kibana.ts b/x-pack/platform/plugins/shared/observability_ai_assistant/server/functions/kibana.ts index 7e9c542bfba29..d7c39c201ee81 100644 --- a/x-pack/platform/plugins/shared/observability_ai_assistant/server/functions/kibana.ts +++ b/x-pack/platform/plugins/shared/observability_ai_assistant/server/functions/kibana.ts @@ -4,8 +4,6 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - -import axios from 'axios'; import { format } from 'url'; import { pickBy } from 'lodash'; import type { KibanaRequest } from '@kbn/core/server'; @@ -110,14 +108,24 @@ export function registerKibanaFunction({ }); try { - const response = await axios({ + const fetchOptions: RequestInit = { method, - headers, - url: format(nextUrl), - data: body ? JSON.stringify(body) : undefined, + headers: headers as Record, signal, - }); - return { content: response.data }; + }; + if (body) { + fetchOptions.body = JSON.stringify(body); + } + const response = await fetch(format(nextUrl), fetchOptions); + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Request failed with status ${response.status}: ${errorText}`); + } + const contentType = response.headers.get('content-type') ?? ''; + const data = contentType.includes('application/json') + ? await response.json() + : await response.text(); + return { content: data }; } catch (e) { logger.error(`Error calling Kibana API: ${method} ${format(nextUrl)}. Failed with ${e}`); throw e; diff --git a/x-pack/platform/plugins/shared/observability_ai_assistant/server/service/util/create_server_side_function_response_error.ts b/x-pack/platform/plugins/shared/observability_ai_assistant/server/service/util/create_server_side_function_response_error.ts index 3c11e91769cd8..b3c1a79a91433 100644 --- a/x-pack/platform/plugins/shared/observability_ai_assistant/server/service/util/create_server_side_function_response_error.ts +++ b/x-pack/platform/plugins/shared/observability_ai_assistant/server/service/util/create_server_side_function_response_error.ts @@ -6,7 +6,6 @@ */ import { errors } from '@elastic/elasticsearch'; -import { isAxiosError } from 'axios'; import { createFunctionResponseMessage } from '../../../common/utils/create_function_response_message'; export function createServerSideFunctionResponseError({ @@ -25,8 +24,8 @@ export function createServerSideFunctionResponseError({ if (isElasticsearchError) { // remove meta key which is huge and noisy delete sanitizedError.meta; - } else if (isAxiosError(error)) { - sanitizedError.response = { message: error.response?.data?.message }; + } else if ('response' in error && (error as any).response?.data?.message) { + sanitizedError.response = { message: (error as any).response?.data?.message }; delete sanitizedError.config; } diff --git a/x-pack/platform/plugins/shared/osquery/cypress/lib/kibana_roles/role_loader.ts b/x-pack/platform/plugins/shared/osquery/cypress/lib/kibana_roles/role_loader.ts index 4ad93711a1ac9..74f3dd32457eb 100644 --- a/x-pack/platform/plugins/shared/osquery/cypress/lib/kibana_roles/role_loader.ts +++ b/x-pack/platform/plugins/shared/osquery/cypress/lib/kibana_roles/role_loader.ts @@ -11,12 +11,11 @@ import type { KbnClient } from '@kbn/test'; 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 type { ServerlessSecurityRoles, YamlRoleDefinitions } from './kibana_roles'; import { getServerlessSecurityKibanaRoleDefinitions } from './kibana_roles'; import { STANDARD_HTTP_HEADERS } from '../default_http_headers'; -const ignoreHttp409Error = (error: AxiosError) => { +const ignoreHttp409Error = (error: Error & { response?: { status?: number } }) => { if (error?.response?.status === 409) { return; } diff --git a/x-pack/platform/plugins/shared/screenshotting/server/browsers/chromium/integration_tests/downloads.test.ts b/x-pack/platform/plugins/shared/screenshotting/server/browsers/chromium/integration_tests/downloads.test.ts index aa37c5c6ea7a1..a5838ecfcba35 100644 --- a/x-pack/platform/plugins/shared/screenshotting/server/browsers/chromium/integration_tests/downloads.test.ts +++ b/x-pack/platform/plugins/shared/screenshotting/server/browsers/chromium/integration_tests/downloads.test.ts @@ -8,7 +8,6 @@ import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; import type { PackageInfo } from '@kbn/screenshotting-server'; import assert from 'assert'; -import axios from 'axios'; import path from 'path'; import { paths as chromiumArchivePaths } from '../../../utils'; import { download } from '../../download'; @@ -42,15 +41,6 @@ describe.each(packageInfos)('Chromium archive: %s/%s', (architecture, platform) // For testing, suffix the unzip folder by cpu + platform so the extracted folders do not overwrite each other in the cache const chromiumPath = path.resolve(__dirname, '../../../../chromium', architecture, platform); - const originalAxios = axios.defaults.adapter; - beforeAll(async () => { - axios.defaults.adapter = 'http'; - }); - - afterAll(() => { - axios.defaults.adapter = originalAxios; - }); - // Allow package definition to be altered to check error handling const originalPkg = chromiumArchivePaths.packages.find( (packageInfo) => packageInfo.platform === platform && packageInfo.architecture === architecture diff --git a/x-pack/platform/plugins/shared/screenshotting/server/browsers/download/fetch.test.ts b/x-pack/platform/plugins/shared/screenshotting/server/browsers/download/fetch.test.ts index 5e1b5fc771d6d..49f4713642eba 100644 --- a/x-pack/platform/plugins/shared/screenshotting/server/browsers/download/fetch.test.ts +++ b/x-pack/platform/plugins/shared/screenshotting/server/browsers/download/fetch.test.ts @@ -5,53 +5,72 @@ * 2.0. */ -import mockFs from 'mock-fs'; -import axios from 'axios'; import { createHash } from 'crypto'; -import { readFile } from 'fs/promises'; -import { resolve as resolvePath } from 'path'; -import { Readable } from 'stream'; +import { mkdtemp, readFile, rm } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join, resolve as resolvePath } from 'path'; import { fetch } from './fetch'; -const TEMP_DIR = resolvePath(__dirname, '__tmp__'); -const TEMP_FILE = resolvePath(TEMP_DIR, 'foo/bar/download'); +const createMockResponse = (body: string, status = 200): Response => { + const readable = new ReadableStream({ + start(controller) { + controller.enqueue(Buffer.from(body)); + controller.close(); + }, + }); + return { + ok: status >= 200 && status < 300, + status, + statusText: 'OK', + headers: new Headers({ 'Content-Type': 'application/octet-stream' }), + body: readable, + bodyUsed: false, + redirected: false, + type: 'basic', + url: '', + clone: jest.fn(), + json: jest.fn(), + text: jest.fn().mockResolvedValue(body), + arrayBuffer: jest.fn(), + blob: jest.fn(), + formData: jest.fn(), + bytes: jest.fn(), + } as unknown as Response; +}; describe('fetch', () => { - beforeEach(() => { - jest.spyOn(axios, 'request').mockResolvedValue({ - data: new Readable({ - read() { - this.push('foobar'); - this.push(null); - }, - }), - }); + let tempDir: string; + let tempFile: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'fetch-test-')); + tempFile = resolvePath(tempDir, 'foo/bar/download'); - mockFs(); + jest.spyOn(global, 'fetch').mockResolvedValue(createMockResponse('foobar')); }); - afterEach(() => { - mockFs.restore(); - jest.resetAllMocks(); + afterEach(async () => { + jest.restoreAllMocks(); + await rm(tempDir, { recursive: true, force: true }); }); test('downloads the url to the path', async () => { - await fetch('url', TEMP_FILE); + await fetch('url', tempFile); - await expect(readFile(TEMP_FILE, 'utf8')).resolves.toBe('foobar'); + await expect(readFile(tempFile, 'utf8')).resolves.toBe('foobar'); }); - test('returns the sha1 hex hash of the http body', async () => { + test('returns the sha256 hex hash of the http body', async () => { const hash = createHash('sha256').update('foobar').digest('hex'); - await expect(fetch('url', TEMP_FILE)).resolves.toEqual(hash); + await expect(fetch('url', tempFile)).resolves.toEqual(hash); }); test('throws if request emits an error', async () => { - (axios.request as jest.Mock).mockImplementationOnce(async () => { + (global.fetch as jest.Mock).mockImplementationOnce(async () => { throw new Error('foo'); }); - await expect(fetch('url', TEMP_FILE)).rejects.toThrow('foo'); + await expect(fetch('url', tempFile)).rejects.toThrow('foo'); }); }); diff --git a/x-pack/platform/plugins/shared/screenshotting/server/browsers/download/fetch.ts b/x-pack/platform/plugins/shared/screenshotting/server/browsers/download/fetch.ts index e49f8d59493c8..c271a19ddf556 100644 --- a/x-pack/platform/plugins/shared/screenshotting/server/browsers/download/fetch.ts +++ b/x-pack/platform/plugins/shared/screenshotting/server/browsers/download/fetch.ts @@ -5,13 +5,13 @@ * 2.0. */ -import Axios from 'axios'; import { createHash } from 'crypto'; import { mkdir, open } from 'fs/promises'; import { writeSync } from 'fs'; import { dirname } from 'path'; -import type { Readable } from 'stream'; +import { Readable } from 'stream'; import { finished } from 'stream/promises'; +import type { ReadableStream as WebReadableStream } from 'stream/web'; import type { Logger } from '@kbn/core/server'; /** @@ -25,18 +25,24 @@ export async function fetch(url: string, path: string, logger?: Logger): Promise await mkdir(dirname(path), { recursive: true }); const handle = await open(path, 'w'); try { - const response = await Axios.request({ - url, - method: 'GET', - responseType: 'stream', - }); + const response = await globalThis.fetch(url, { method: 'GET' }); + + if (!response.ok) { + throw new Error(`Request failed with status ${response.status}`); + } + + if (!response.body) { + throw new Error('Response body is empty'); + } + + const nodeStream = Readable.fromWeb(response.body as WebReadableStream); - response.data.on('data', (chunk: Buffer) => { + nodeStream.on('data', (chunk: Buffer) => { writeSync(handle.fd, chunk); hash.update(chunk); }); - await finished(response.data); + await finished(nodeStream); logger?.info(`Downloaded ${url}`); } catch (error) { logger?.error(error); diff --git a/x-pack/platform/test/alerting_api_integration/security_and_spaces/group4/tests/alerting/mustache_templates.ts b/x-pack/platform/test/alerting_api_integration/security_and_spaces/group4/tests/alerting/mustache_templates.ts index 23be8e33a90e4..0ef4250ab1638 100644 --- a/x-pack/platform/test/alerting_api_integration/security_and_spaces/group4/tests/alerting/mustache_templates.ts +++ b/x-pack/platform/test/alerting_api_integration/security_and_spaces/group4/tests/alerting/mustache_templates.ts @@ -14,7 +14,6 @@ import type http from 'http'; import getPort from 'get-port'; -import axios from 'axios'; import type httpProxy from 'http-proxy'; import expect from '@kbn/expect'; @@ -109,10 +108,12 @@ export default function executionStatusAlertTests({ getService }: FtrProviderCon }); async function waitForActionBody(url: string, id: string): Promise { - const response = await axios.get(url); + const response = await fetch(url); expect(response.status).to.eql(200); - for (const datum of response.data) { + const data: string[] = await response.json(); + + for (const datum of data) { const match = datum.match(/^(.*) - (.*)$/); if (match == null) continue; diff --git a/x-pack/platform/test/alerting_api_integration/spaces_only/tests/alerting/group4/mustache_templates.ts b/x-pack/platform/test/alerting_api_integration/spaces_only/tests/alerting/group4/mustache_templates.ts index 40088760debe4..64bb7e5bab957 100644 --- a/x-pack/platform/test/alerting_api_integration/spaces_only/tests/alerting/group4/mustache_templates.ts +++ b/x-pack/platform/test/alerting_api_integration/spaces_only/tests/alerting/group4/mustache_templates.ts @@ -14,8 +14,6 @@ import type http from 'http'; import getPort from 'get-port'; -import axios from 'axios'; - import expect from '@kbn/expect'; import { getWebhookServer, getSlackServer } from '@kbn/actions-simulators-plugin/server/plugin'; import { Spaces } from '../../../scenarios'; @@ -319,10 +317,12 @@ export default function executionStatusAlertTests({ getService }: FtrProviderCon }); async function waitForActionBody(url: string, id: string): Promise { - const response = await axios.get(url); + const response = await fetch(url); expect(response.status).to.eql(200); - for (const datum of response.data) { + const data: string[] = await response.json(); + + for (const datum of data) { const match = datum.match(/^(.*) - ([\S\s]*)$/); if (match == null) continue; diff --git a/x-pack/platform/test/api_integration/services/spaces.ts b/x-pack/platform/test/api_integration/services/spaces.ts index 5f4dc31ed9b9b..2c5c4433e9b12 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 } from 'undici'; import { format as formatUrl } from 'url'; import util from 'util'; import Chance from 'chance'; @@ -30,36 +29,59 @@ 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 baseUrl = formatUrl(config.get('servers.kibana')); // 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: Agent | undefined = certificateAuthorities + ? new Agent({ + connect: { + ca: certificateAuthorities, + 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 defaultHeaders: Record = { + 'kbn-xsrf': 'x-pack/ftr/services/spaces/space', + 'Content-Type': 'application/json', + }; + + async function request( + method: string, + path: string, + body?: unknown + ): Promise<{ data: T; status: number; statusText: string }> { + const resp = await fetch(`${baseUrl}${path}`, { + method, + headers: defaultHeaders, + body: body ? JSON.stringify(body) : undefined, + redirect: 'manual', + ...(dispatcher ? { dispatcher } : {}), + } as RequestInit); + + const text = await resp.text(); + let data: T; + try { + data = JSON.parse(text) as T; + } catch { + data = text as unknown as T; + } + + return { data, status: resp.status, statusText: resp.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 +106,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 +122,10 @@ 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 +137,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 +151,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/integrations/inputs_with_standalone_docker_agent.ts b/x-pack/platform/test/fleet_api_integration/apis/integrations/inputs_with_standalone_docker_agent.ts index 06aa35edda3dd..acab0ac708e67 100644 --- a/x-pack/platform/test/fleet_api_integration/apis/integrations/inputs_with_standalone_docker_agent.ts +++ b/x-pack/platform/test/fleet_api_integration/apis/integrations/inputs_with_standalone_docker_agent.ts @@ -5,7 +5,6 @@ * 2.0. */ -import axios from 'axios'; import { last } from 'lodash'; import { ToolingLog } from '@kbn/tooling-log'; import { tmpdir } from 'os'; @@ -28,13 +27,18 @@ export async function getLatestVersion(): Promise { timeout: 60_000, methodName: name, retryCount: 20, - block: () => axios('https://artifacts-api.elastic.co/v1/versions'), + block: async () => { + const response = await fetch('https://artifacts-api.elastic.co/v1/versions'); + if (!response.ok) { + throw new Error(`Failed to fetch versions: ${response.status} ${response.statusText}`); + } + return response.json(); + }, } ) .then( - (response) => - last((response.data.versions as string[]).filter((v) => v.includes('-SNAPSHOT'))) || - DEFAULT_VERSION + (data) => + last((data.versions as string[]).filter((v) => v.includes('-SNAPSHOT'))) || DEFAULT_VERSION ) .catch(() => DEFAULT_VERSION); } diff --git a/x-pack/platform/test/fleet_cypress/agent.ts b/x-pack/platform/test/fleet_cypress/agent.ts index c725cbe510df4..195f8020db106 100644 --- a/x-pack/platform/test/fleet_cypress/agent.ts +++ b/x-pack/platform/test/fleet_cypress/agent.ts @@ -6,8 +6,6 @@ */ import type { ToolingLog } from '@kbn/tooling-log'; -import type { AxiosRequestConfig } from 'axios'; -import axios from 'axios'; import type { ChildProcess } from 'child_process'; import { spawn } from 'child_process'; import { getLatestVersion } from './artifact_manager'; @@ -21,12 +19,16 @@ export interface AgentManagerParams { esPort: string; } +export interface RequestOptions { + headers: Record; +} + export class AgentManager extends Manager { private params: AgentManagerParams; private log: ToolingLog; private agentProcess?: ChildProcess; - private requestOptions: AxiosRequestConfig; - constructor(params: AgentManagerParams, log: ToolingLog, requestOptions: AxiosRequestConfig) { + private requestOptions: RequestOptions; + constructor(params: AgentManagerParams, log: ToolingLog, requestOptions: RequestOptions) { super(); this.log = log; this.params = params; @@ -35,19 +37,31 @@ export class AgentManager extends Manager { public async setup() { this.log.info('Running agent preconfig'); - return await axios.post( - `${this.params.kibanaUrl}/api/fleet/agents/setup`, - {}, - this.requestOptions - ); + const response = await fetch(`${this.params.kibanaUrl}/api/fleet/agents/setup`, { + method: 'POST', + body: JSON.stringify({}), + headers: { + 'content-type': 'application/json', + ...this.requestOptions.headers, + }, + }); + if (!response.ok) { + throw new Error(`Failed to setup fleet agents: ${response.status} ${response.statusText}`); + } + return response.json(); } public async startAgent() { this.log.info('Getting agent enrollment key'); - const { data: apiKeys } = await axios.get( - this.params.kibanaUrl + '/api/fleet/enrollment_api_keys', - this.requestOptions - ); + const apiKeysResponse = await fetch(this.params.kibanaUrl + '/api/fleet/enrollment_api_keys', { + headers: this.requestOptions.headers, + }); + if (!apiKeysResponse.ok) { + throw new Error( + `Failed to get enrollment API keys: ${apiKeysResponse.status} ${apiKeysResponse.statusText}` + ); + } + const apiKeys = await apiKeysResponse.json(); const policy = apiKeys.items[1]; this.log.info('Running the agent'); @@ -78,10 +92,15 @@ export class AgentManager extends Manager { let retries = 0; while (!done) { await new Promise((r) => setTimeout(r, 5000)); - const { data: agents } = await axios.get( - `${this.params.kibanaUrl}/api/fleet/agents`, - this.requestOptions - ); + const agentsResponse = await fetch(`${this.params.kibanaUrl}/api/fleet/agents`, { + headers: this.requestOptions.headers, + }); + if (!agentsResponse.ok) { + throw new Error( + `Failed to get fleet agents: ${agentsResponse.status} ${agentsResponse.statusText}` + ); + } + const agents = await agentsResponse.json(); done = agents.items[0]?.status === 'online'; if (++retries > 12) { this.log.error('Giving up on enrolling the agent after a minute'); diff --git a/x-pack/platform/test/fleet_cypress/artifact_manager.ts b/x-pack/platform/test/fleet_cypress/artifact_manager.ts index d1b35e3738bfa..542d39fcc9742 100644 --- a/x-pack/platform/test/fleet_cypress/artifact_manager.ts +++ b/x-pack/platform/test/fleet_cypress/artifact_manager.ts @@ -5,7 +5,6 @@ * 2.0. */ -import axios from 'axios'; import { last } from 'lodash'; import { ToolingLog } from '@kbn/tooling-log'; import { retryForSuccess } from '@kbn/ftr-common-functional-services'; @@ -20,13 +19,18 @@ export async function getLatestVersion(): Promise { timeout: 60_000, methodName: name, retryCount: 20, - block: () => axios('https://artifacts-api.elastic.co/v1/versions'), + block: async () => { + const response = await fetch('https://artifacts-api.elastic.co/v1/versions'); + if (!response.ok) { + throw new Error(`Failed to fetch versions: ${response.status} ${response.statusText}`); + } + return response.json(); + }, } ) .then( - (response) => - last((response.data.versions as string[]).filter((v) => v.includes('-SNAPSHOT'))) || - DEFAULT_VERSION + (data) => + last((data.versions as string[]).filter((v) => v.includes('-SNAPSHOT'))) || DEFAULT_VERSION ) .catch(() => DEFAULT_VERSION); } diff --git a/x-pack/platform/test/fleet_cypress/fleet_server.ts b/x-pack/platform/test/fleet_cypress/fleet_server.ts index c6f99a4b5bb5d..5645bef2f6381 100644 --- a/x-pack/platform/test/fleet_cypress/fleet_server.ts +++ b/x-pack/platform/test/fleet_cypress/fleet_server.ts @@ -8,18 +8,20 @@ import type { ChildProcess } from 'child_process'; import { spawn } from 'child_process'; import type { ToolingLog } from '@kbn/tooling-log'; -import type { AxiosRequestConfig } from 'axios'; -import axios from 'axios'; import { Manager } from './resource_manager'; import { getLatestVersion } from './artifact_manager'; import type { AgentManagerParams } from './agent'; +export interface RequestOptions { + headers: Record; +} + export class FleetManager extends Manager { private fleetProcess?: ChildProcess; private config: AgentManagerParams; private log: ToolingLog; - private requestOptions: AxiosRequestConfig; - constructor(config: AgentManagerParams, log: ToolingLog, requestOptions: AxiosRequestConfig) { + private requestOptions: RequestOptions; + constructor(config: AgentManagerParams, log: ToolingLog, requestOptions: RequestOptions) { super(); this.config = config; this.log = log; @@ -29,31 +31,49 @@ export class FleetManager extends Manager { this.log.info('Setting fleet up'); return new Promise(async (res, rej) => { try { - const response = await axios.post( + const serviceTokenResponse = await fetch( `${this.config.kibanaUrl}/api/fleet/service_tokens`, - {}, - this.requestOptions + { + method: 'POST', + body: JSON.stringify({}), + headers: { + 'content-type': 'application/json', + ...this.requestOptions.headers, + }, + } ); - const serviceToken = response.data.value; + if (!serviceTokenResponse.ok) { + throw new Error( + `Failed to create service token: ${serviceTokenResponse.status} ${serviceTokenResponse.statusText}` + ); + } + const serviceTokenData = await serviceTokenResponse.json(); + const serviceToken = serviceTokenData.value; const artifact = `docker.elastic.co/elastic-agent/elastic-agent:${await getLatestVersion()}`; this.log.info(artifact); // default fleet server policy no longer created by default - const { - data: { - item: { id: policyId }, - }, - } = await axios.post( - `${this.config.kibanaUrl}/api/fleet/agent_policies`, - { + const policyResponse = await fetch(`${this.config.kibanaUrl}/api/fleet/agent_policies`, { + method: 'POST', + body: JSON.stringify({ name: 'Fleet Server policy', description: '', namespace: 'default', monitoring_enabled: [], has_fleet_server: true, + }), + headers: { + 'content-type': 'application/json', + ...this.requestOptions.headers, }, - this.requestOptions - ); + }); + if (!policyResponse.ok) { + throw new Error( + `Failed to create agent policy: ${policyResponse.status} ${policyResponse.statusText}` + ); + } + const policyData = await policyResponse.json(); + const policyId = policyData.item.id; const host = 'host.docker.internal'; diff --git a/x-pack/platform/test/fleet_multi_cluster/apps/fleet/sync_integrations_flow.ts b/x-pack/platform/test/fleet_multi_cluster/apps/fleet/sync_integrations_flow.ts index ef6b5e3d03ba6..c402b17deab0c 100644 --- a/x-pack/platform/test/fleet_multi_cluster/apps/fleet/sync_integrations_flow.ts +++ b/x-pack/platform/test/fleet_multi_cluster/apps/fleet/sync_integrations_flow.ts @@ -4,7 +4,6 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import axios from 'axios'; import expect from '@kbn/expect'; import type { FtrProviderContext } from '../../ftr_provider_context'; @@ -90,19 +89,21 @@ export default ({ getService }: FtrProviderContext) => { } async function createLocalOutputOnRemote() { - const response = await axios.post( - 'http://localhost:5621/api/fleet/outputs', - { + const response = await fetch('http://localhost:5621/api/fleet/outputs', { + method: 'POST', + body: JSON.stringify({ id: 'es', type: 'elasticsearch', name: 'Local ES Output', hosts: ['http://localhost:9221'], + }), + headers: { + 'content-type': 'application/json', + Authorization: `Basic ${Buffer.from('elastic:changeme').toString('base64')}`, + 'kbn-xsrf': 'true', + 'x-elastic-internal-origin': 'fleet-e2e', }, - { - auth: { username: 'elastic', password: 'changeme' }, - headers: { 'kbn-xsrf': 'true', 'x-elastic-internal-origin': 'fleet-e2e' }, - } - ); + }); expect(response.status).to.be(200); } @@ -213,9 +214,13 @@ export default ({ getService }: FtrProviderContext) => { .expect(200); // Clean up the local output on remote - const response = await axios.delete('http://localhost:5621/api/fleet/outputs/es', { - auth: { username: 'elastic', password: 'changeme' }, - headers: { 'kbn-xsrf': 'true', 'x-elastic-internal-origin': 'fleet-e2e' }, + const response = await fetch('http://localhost:5621/api/fleet/outputs/es', { + method: 'DELETE', + headers: { + Authorization: `Basic ${Buffer.from('elastic:changeme').toString('base64')}`, + 'kbn-xsrf': 'true', + 'x-elastic-internal-origin': 'fleet-e2e', + }, }); expect(response.status).to.be(200); diff --git a/x-pack/platform/test/ui_capabilities/common/services/features.ts b/x-pack/platform/test/ui_capabilities/common/services/features.ts index 9482f5ac77353..1dad1cdf53df0 100644 --- a/x-pack/platform/test/ui_capabilities/common/services/features.ts +++ b/x-pack/platform/test/ui_capabilities/common/services/features.ts @@ -5,8 +5,6 @@ * 2.0. */ -import type { AxiosInstance } from 'axios'; -import axios from 'axios'; import { format as formatUrl } from 'url'; import util from 'util'; import type { ToolingLog } from '@kbn/tooling-log'; @@ -14,33 +12,34 @@ import type { FtrProviderContext } from '../ftr_provider_context'; import type { Features } from '../features'; export class FeaturesService { - private readonly axios: AxiosInstance; + private readonly baseURL: string; + private readonly defaultHeaders: Record; constructor(url: string, private readonly log: ToolingLog) { - this.axios = axios.create({ - headers: { 'kbn-xsrf': 'x-pack/ftr/services/features' }, - baseURL: url, - allowAbsoluteUrls: false, - maxRedirects: 0, - validateStatus: () => true, // we'll handle our own statusCodes and throw informative errors - }); + this.baseURL = url; + this.defaultHeaders = { 'kbn-xsrf': 'x-pack/ftr/services/features' }; } public async get({ ignoreValidLicenses } = { ignoreValidLicenses: false }): Promise { this.log.debug('requesting /api/features to get the features'); - const response = await this.axios.get( - `/api/features?ignoreValidLicenses=${ignoreValidLicenses}` + const response = await fetch( + `${this.baseURL}/api/features?ignoreValidLicenses=${ignoreValidLicenses}`, + { + headers: this.defaultHeaders, + } ); + const data = await response.json(); + if (response.status !== 200) { throw new Error( `Expected status code of 200, received ${response.status} ${ response.statusText - }: ${util.inspect(response.data)}` + }: ${util.inspect(data)}` ); } - const features = response.data.reduce( + const features = data.reduce( (acc: Features, feature: any) => ({ ...acc, [feature.id]: { diff --git a/x-pack/platform/test/ui_capabilities/common/services/ui_capabilities.ts b/x-pack/platform/test/ui_capabilities/common/services/ui_capabilities.ts index 6484a5f55ba7b..c13f12a10c726 100644 --- a/x-pack/platform/test/ui_capabilities/common/services/ui_capabilities.ts +++ b/x-pack/platform/test/ui_capabilities/common/services/ui_capabilities.ts @@ -5,8 +5,6 @@ * 2.0. */ -import type { AxiosInstance } from 'axios'; -import axios from 'axios'; import type { Capabilities as UICapabilities } from '@kbn/core/types'; import { format as formatUrl } from 'url'; import util from 'util'; @@ -33,18 +31,14 @@ interface GetUICapabilitiesResult { export class UICapabilitiesService { private readonly log: ToolingLog; - private readonly axios: AxiosInstance; + private readonly baseURL: string; + private readonly defaultHeaders: Record; private readonly featureService: FeaturesService; constructor(url: string, log: ToolingLog, featureService: FeaturesService) { this.log = log; - this.axios = axios.create({ - headers: { 'kbn-xsrf': 'x-pack/ftr/services/ui_capabilities' }, - baseURL: url, - allowAbsoluteUrls: false, - maxRedirects: 0, - validateStatus: () => true, // we'll handle our own statusCodes and throw informative errors - }); + this.baseURL = url; + this.defaultHeaders = { 'kbn-xsrf': 'x-pack/ftr/services/ui_capabilities' }; this.featureService = featureService; } @@ -71,15 +65,18 @@ export class UICapabilitiesService { ).toString('base64')}`, } : {}; - const response = await this.axios.post( - `${spaceUrlPrefix}/api/core/capabilities`, - { applications: [...applications, 'kibana:stack_management'] }, - { - headers: requestHeaders, - } - ); + const response = await fetch(`${this.baseURL}${spaceUrlPrefix}/api/core/capabilities`, { + method: 'POST', + body: JSON.stringify({ applications: [...applications, 'kibana:stack_management'] }), + headers: { + 'content-type': 'application/json', + ...this.defaultHeaders, + ...requestHeaders, + }, + redirect: 'manual', + }); - if (response.status === 302 && response.headers.location === '/spaces/space_selector') { + if (response.status === 302 && response.headers.get('location') === '/spaces/space_selector') { return { success: false, failureReason: GetUICapabilitiesFailureReason.RedirectedToSpaceSelector, @@ -93,17 +90,19 @@ export class UICapabilitiesService { }; } + const data = await response.json(); + if (response.status !== 200) { throw new Error( `Expected status code of 200, received ${response.status} ${ response.statusText - }: ${util.inspect(response.data)}` + }: ${util.inspect(data)}` ); } return { success: true, - value: response.data, + value: data, }; } } diff --git a/x-pack/solutions/observability/packages/alerting-test-data/src/create_data_view.ts b/x-pack/solutions/observability/packages/alerting-test-data/src/create_data_view.ts index 13e966c8c1759..bd6f77cde5fb6 100644 --- a/x-pack/solutions/observability/packages/alerting-test-data/src/create_data_view.ts +++ b/x-pack/solutions/observability/packages/alerting-test-data/src/create_data_view.ts @@ -5,7 +5,6 @@ * 2.0. */ -import axios from 'axios'; import { HEADERS, PASSWORD, USERNAME } from './constants'; import { getKibanaUrl } from './get_kibana_url'; @@ -17,6 +16,8 @@ export const createDataView = async ({ id: string; }) => { const DATA_VIEW_CREATION_API = `${await getKibanaUrl()}/api/content_management/rpc/create`; + const basicAuth = Buffer.from(`${USERNAME}:${PASSWORD}`).toString('base64'); + const dataViewParams = { contentTypeId: 'index-pattern', data: { @@ -34,11 +35,23 @@ export const createDataView = async ({ version: 1, }; - return axios.post(DATA_VIEW_CREATION_API, dataViewParams, { - headers: HEADERS, - auth: { - username: USERNAME, - password: PASSWORD, + const response = await fetch(DATA_VIEW_CREATION_API, { + method: 'POST', + body: JSON.stringify(dataViewParams), + headers: { + 'content-type': 'application/json', + ...HEADERS, + Authorization: `Basic ${basicAuth}`, }, }); + + if (!response.ok) { + const errorText = await response.text().catch(() => ''); + throw new Error(`Failed to create data view: ${response.status} ${errorText}`); + } + + return { + status: response.status, + data: await response.json(), + }; }; diff --git a/x-pack/solutions/observability/packages/alerting-test-data/src/create_index_connector.ts b/x-pack/solutions/observability/packages/alerting-test-data/src/create_index_connector.ts index 19fe799bae2f6..22e09dbee189c 100644 --- a/x-pack/solutions/observability/packages/alerting-test-data/src/create_index_connector.ts +++ b/x-pack/solutions/observability/packages/alerting-test-data/src/create_index_connector.ts @@ -5,12 +5,13 @@ * 2.0. */ -import axios from 'axios'; import { ALERT_ACTION_INDEX, HEADERS, PASSWORD, USERNAME } from './constants'; import { getKibanaUrl } from './get_kibana_url'; export const createIndexConnector = async () => { const INDEX_CONNECTOR_API = `${await getKibanaUrl()}/api/actions/connector`; + const basicAuth = Buffer.from(`${USERNAME}:${PASSWORD}`).toString('base64'); + const indexConnectorParams = { name: 'Test Index Connector', config: { @@ -20,11 +21,23 @@ export const createIndexConnector = async () => { connector_type_id: '.index', }; - return axios.post(INDEX_CONNECTOR_API, indexConnectorParams, { - headers: HEADERS, - auth: { - username: USERNAME, - password: PASSWORD, + const response = await fetch(INDEX_CONNECTOR_API, { + method: 'POST', + body: JSON.stringify(indexConnectorParams), + headers: { + 'content-type': 'application/json', + ...HEADERS, + Authorization: `Basic ${basicAuth}`, }, }); + + if (!response.ok) { + const errorText = await response.text().catch(() => ''); + throw new Error(`Failed to create index connector: ${response.status} ${errorText}`); + } + + return { + status: response.status, + data: await response.json(), + }; }; diff --git a/x-pack/solutions/observability/packages/alerting-test-data/src/create_rule.ts b/x-pack/solutions/observability/packages/alerting-test-data/src/create_rule.ts index 3b5fcb92562dc..4a70e72afde5c 100644 --- a/x-pack/solutions/observability/packages/alerting-test-data/src/create_rule.ts +++ b/x-pack/solutions/observability/packages/alerting-test-data/src/create_rule.ts @@ -5,17 +5,30 @@ * 2.0. */ -import axios from 'axios'; import { HEADERS, PASSWORD, USERNAME } from './constants'; import { getKibanaUrl } from './get_kibana_url'; export const createRule = async (ruleParams: any) => { const RULE_CREATION_API = `${await getKibanaUrl()}/api/alerting/rule`; - return axios.post(RULE_CREATION_API, ruleParams, { - headers: HEADERS, - auth: { - username: USERNAME, - password: PASSWORD, + const basicAuth = Buffer.from(`${USERNAME}:${PASSWORD}`).toString('base64'); + + const response = await fetch(RULE_CREATION_API, { + method: 'POST', + body: JSON.stringify(ruleParams), + headers: { + 'content-type': 'application/json', + ...HEADERS, + Authorization: `Basic ${basicAuth}`, }, }); + + if (!response.ok) { + const errorText = await response.text().catch(() => ''); + throw new Error(`Failed to create rule: ${response.status} ${errorText}`); + } + + return { + status: response.status, + data: await response.json(), + }; }; diff --git a/x-pack/solutions/observability/packages/kbn-evals-suite-obs-ai-assistant/src/clients/chat/obs_ai_assistant_client.ts b/x-pack/solutions/observability/packages/kbn-evals-suite-obs-ai-assistant/src/clients/chat/obs_ai_assistant_client.ts index e8749bedd60de..dd3477d2bce4d 100644 --- a/x-pack/solutions/observability/packages/kbn-evals-suite-obs-ai-assistant/src/clients/chat/obs_ai_assistant_client.ts +++ b/x-pack/solutions/observability/packages/kbn-evals-suite-obs-ai-assistant/src/clients/chat/obs_ai_assistant_client.ts @@ -34,7 +34,6 @@ import { } from 'rxjs'; import { throwSerializedChatCompletionErrors } from '@kbn/observability-ai-assistant-plugin/common/utils/throw_serialized_chat_completion_errors'; import type { ToolingLog } from '@kbn/tooling-log'; -import { isAxiosError } from 'axios'; import { inspect } from 'util'; import type { Message } from '@kbn/observability-ai-assistant-plugin/common/types'; import { MessageRole } from '@kbn/observability-ai-assistant-plugin/common/types'; @@ -70,8 +69,8 @@ function serializeAndHandleRetryableErrors let status: number = 0; - if (isAxiosError(error)) { - status = error.status ?? 0; + if (error instanceof Error && 'status' in error) { + status = (error as any).status ?? 0; } else if (isHttpFetchError(error)) { status = error.response?.status ?? 0; } @@ -82,12 +81,12 @@ function serializeAndHandleRetryableErrors log.info('Caught retryable error'); - if (isAxiosError(error)) { + if (error instanceof Error && 'status' in error) { log.error( inspect( { message: error.message, - status: error.status, + status: (error as any).status, }, { depth: 10 } ) diff --git a/x-pack/solutions/observability/packages/kbn-synthetics-forge/src/api_client.ts b/x-pack/solutions/observability/packages/kbn-synthetics-forge/src/api_client.ts index ee9a4983a7abf..27b41ca6e4661 100644 --- a/x-pack/solutions/observability/packages/kbn-synthetics-forge/src/api_client.ts +++ b/x-pack/solutions/observability/packages/kbn-synthetics-forge/src/api_client.ts @@ -5,8 +5,6 @@ * 2.0. */ -import type { AxiosInstance, AxiosResponse } from 'axios'; -import axios from 'axios'; import type { ToolingLog } from '@kbn/tooling-log'; import type { ApiClientConfig, PrivateLocation, AgentPolicy, Space, Monitor } from './types'; @@ -15,30 +13,44 @@ const DEFAULT_RETRY_DELAY_MS = 1000; const BATCH_SIZE = 50; export class SyntheticsApiClient { - private client: AxiosInstance; private kibanaUrl: string; private maxRetries: number; private retryDelayMs: number; private log: ToolingLog; + private defaultHeaders: Record; + private authHeader: string; constructor(config: ApiClientConfig, log: ToolingLog) { this.kibanaUrl = config.kibanaUrl.replace(/\/$/, ''); this.maxRetries = DEFAULT_RETRY_COUNT; this.retryDelayMs = DEFAULT_RETRY_DELAY_MS; this.log = log; - this.client = axios.create({ - baseURL: this.kibanaUrl, - auth: { - username: config.username, - password: config.password, - }, - headers: { - 'kbn-xsrf': 'true', - 'x-elastic-internal-origin': 'synthetics-forge', - 'elastic-api-version': '2023-10-31', - }, - validateStatus: () => true, + this.authHeader = `Basic ${Buffer.from(`${config.username}:${config.password}`).toString( + 'base64' + )}`; + this.defaultHeaders = { + 'kbn-xsrf': 'true', + 'x-elastic-internal-origin': 'synthetics-forge', + 'elastic-api-version': '2023-10-31', + 'content-type': 'application/json', + Authorization: this.authHeader, + }; + } + + private async request( + method: string, + path: string, + data?: unknown + ): Promise<{ status: number; data: any }> { + const url = `${this.kibanaUrl}${path}`; + const response = await fetch(url, { + method, + headers: this.defaultHeaders, + ...(data !== undefined ? { body: JSON.stringify(data) } : {}), }); + + const responseData = await response.json().catch(() => undefined); + return { status: response.status, data: responseData }; } private async withRetry( @@ -75,7 +87,7 @@ export class SyntheticsApiClient { return new Promise((resolve) => setTimeout(resolve, ms)); } - private isSuccessResponse(response: AxiosResponse): boolean { + private isSuccessResponse(response: { status: number }): boolean { return response.status >= 200 && response.status < 300; } @@ -85,7 +97,7 @@ export class SyntheticsApiClient { async setupFleet(): Promise { this.log.info('Setting up Fleet...'); - const response = await this.client.post('/api/fleet/setup'); + const response = await this.request('POST', '/api/fleet/setup'); if (!this.isSuccessResponse(response)) { throw new Error(`Fleet setup failed: ${JSON.stringify(response.data)}`); } @@ -94,7 +106,7 @@ export class SyntheticsApiClient { async enableSynthetics(): Promise { this.log.info('Enabling Synthetics...'); - const response = await this.client.put('/internal/synthetics/service/enablement'); + const response = await this.request('PUT', '/internal/synthetics/service/enablement'); if (response.status !== 200 && response.status !== 409) { throw new Error(`Enable Synthetics failed: ${JSON.stringify(response.data)}`); } @@ -104,13 +116,13 @@ export class SyntheticsApiClient { async createSpace(spaceId: string, name: string): Promise { this.log.info(`Creating space: ${spaceId}`); - const existingResponse = await this.client.get(`/api/spaces/space/${spaceId}`); + const existingResponse = await this.request('GET', `/api/spaces/space/${spaceId}`); if (existingResponse.status === 200) { this.log.info(`Space ${spaceId} already exists`); return existingResponse.data as Space; } - const response = await this.client.post('/api/spaces/space', { + const response = await this.request('POST', '/api/spaces/space', { id: spaceId, name, description: 'Space for Synthetics scalability testing', @@ -133,7 +145,7 @@ export class SyntheticsApiClient { return found; } - const response = await this.client.post('/api/fleet/agent_policies?sys_monitoring=true', { + const response = await this.request('POST', '/api/fleet/agent_policies?sys_monitoring=true', { name, description: 'Agent policy for Synthetics scalability testing', namespace: 'default', @@ -149,7 +161,7 @@ export class SyntheticsApiClient { async getPrivateLocations(spaceId?: string): Promise { const basePath = this.getBasePath(spaceId); - const response = await this.client.get(`${basePath}/api/synthetics/private_locations`); + const response = await this.request('GET', `${basePath}/api/synthetics/private_locations`); if (!this.isSuccessResponse(response)) { return []; } @@ -195,7 +207,7 @@ export class SyntheticsApiClient { } const basePath = this.getBasePath(spaceId); - const response = await this.client.post(`${basePath}/api/synthetics/private_locations`, { + const response = await this.request('POST', `${basePath}/api/synthetics/private_locations`, { label, agentPolicyId, geo: { lat: 0, lon: 0 }, @@ -219,7 +231,7 @@ export class SyntheticsApiClient { return this.withRetry(async () => { this.log.debug(`Creating HTTP monitor: ${name}`); const basePath = this.getBasePath(spaceId); - const response = await this.client.post(`${basePath}/api/synthetics/monitors`, { + const response = await this.request('POST', `${basePath}/api/synthetics/monitors`, { type: 'http', name, urls: url, @@ -247,7 +259,7 @@ export class SyntheticsApiClient { return this.withRetry(async () => { this.log.debug(`Creating TCP monitor: ${name}`); const basePath = this.getBasePath(spaceId); - const response = await this.client.post(`${basePath}/api/synthetics/monitors`, { + const response = await this.request('POST', `${basePath}/api/synthetics/monitors`, { type: 'tcp', name, hosts: host, @@ -275,7 +287,7 @@ export class SyntheticsApiClient { return this.withRetry(async () => { this.log.debug(`Creating ICMP monitor: ${name}`); const basePath = this.getBasePath(spaceId); - const response = await this.client.post(`${basePath}/api/synthetics/monitors`, { + const response = await this.request('POST', `${basePath}/api/synthetics/monitors`, { type: 'icmp', name, hosts: host, @@ -304,7 +316,7 @@ export class SyntheticsApiClient { return this.withRetry(async () => { this.log.debug(`Creating Browser monitor: ${name}`); const basePath = this.getBasePath(spaceId); - const response = await this.client.post(`${basePath}/api/synthetics/monitors`, { + const response = await this.request('POST', `${basePath}/api/synthetics/monitors`, { type: 'browser', name, schedule: { number: '3', unit: 'm' }, @@ -332,7 +344,8 @@ export class SyntheticsApiClient { const perPage = 100; while (true) { - const response = await this.client.get( + const response = await this.request( + 'GET', `${basePath}/api/synthetics/monitors?perPage=${perPage}&page=${page}` ); if (!this.isSuccessResponse(response)) { @@ -355,8 +368,8 @@ export class SyntheticsApiClient { if (monitorIds.length === 0) return; const basePath = this.getBasePath(spaceId); - const response = await this.client.delete(`${basePath}/api/synthetics/monitors`, { - data: { ids: monitorIds }, + const response = await this.request('DELETE', `${basePath}/api/synthetics/monitors`, { + ids: monitorIds, }); if (!this.isSuccessResponse(response)) { @@ -366,7 +379,8 @@ export class SyntheticsApiClient { async deletePrivateLocation(locationId: string, spaceId?: string): Promise { const basePath = this.getBasePath(spaceId); - const response = await this.client.delete( + const response = await this.request( + 'DELETE', `${basePath}/api/synthetics/private_locations/${locationId}` ); if (!this.isSuccessResponse(response) && response.status !== 404) { @@ -375,7 +389,7 @@ export class SyntheticsApiClient { } async deleteAgentPolicy(policyId: string, force: boolean = false): Promise { - const response = await this.client.post('/api/fleet/agent_policies/delete', { + const response = await this.request('POST', '/api/fleet/agent_policies/delete', { agentPolicyId: policyId, force, }); @@ -385,7 +399,8 @@ export class SyntheticsApiClient { } async getAgentsForPolicy(agentPolicyId: string): Promise> { - const response = await this.client.get( + const response = await this.request( + 'GET', `/api/fleet/agents?kuery=policy_id:${agentPolicyId}&perPage=1000` ); if (!this.isSuccessResponse(response)) { @@ -396,7 +411,7 @@ export class SyntheticsApiClient { async bulkUnenrollAgents(agentPolicyId: string): Promise { // Use bulk unenroll API - const response = await this.client.post('/api/fleet/agents/bulk_unenroll', { + const response = await this.request('POST', '/api/fleet/agents/bulk_unenroll', { agents: `policy_id:${agentPolicyId}`, force: true, revoke: true, @@ -410,7 +425,7 @@ export class SyntheticsApiClient { } async getAgentPolicies(): Promise { - const response = await this.client.get('/api/fleet/agent_policies?perPage=1000'); + const response = await this.request('GET', '/api/fleet/agent_policies?perPage=1000'); if (!this.isSuccessResponse(response)) { return []; } @@ -419,7 +434,8 @@ export class SyntheticsApiClient { async getEnrollmentToken(agentPolicyId: string): Promise { this.log.info(`Fetching enrollment token for policy: ${agentPolicyId}`); - const response = await this.client.get( + const response = await this.request( + 'GET', `/api/fleet/enrollment_api_keys?kuery=policy_id:${agentPolicyId}` ); @@ -433,7 +449,7 @@ export class SyntheticsApiClient { } async getKibanaVersion(): Promise { - const response = await this.client.get('/api/status'); + const response = await this.request('GET', '/api/status'); if (!this.isSuccessResponse(response)) { throw new Error(`Failed to get Kibana version: ${JSON.stringify(response.data)}`); } @@ -477,7 +493,7 @@ export class SyntheticsApiClient { const batch = policyIds.slice(i, i + BATCH_SIZE); try { - const response = await this.client.post('/api/fleet/package_policies/delete', { + const response = await this.request('POST', '/api/fleet/package_policies/delete', { packagePolicyIds: batch, force: true, }); diff --git a/x-pack/solutions/observability/plugins/apm/scripts/create_apm_users/create_apm_users_cli.ts b/x-pack/solutions/observability/plugins/apm/scripts/create_apm_users/create_apm_users_cli.ts index 281694632df55..706d56907019d 100644 --- a/x-pack/solutions/observability/plugins/apm/scripts/create_apm_users/create_apm_users_cli.ts +++ b/x-pack/solutions/observability/plugins/apm/scripts/create_apm_users/create_apm_users_cli.ts @@ -10,7 +10,7 @@ import { argv } from 'yargs'; import { AbortError, - isAxiosError, + isKibanaError, } from '../../server/test_helpers/create_apm_users/helpers/call_kibana'; import { createApmUsers } from '../../server/test_helpers/create_apm_users/create_apm_users'; import { getKibanaVersion } from '../../server/test_helpers/create_apm_users/helpers/get_version'; @@ -73,14 +73,8 @@ async function init() { init().catch((e) => { if (e instanceof AbortError) { console.error(e.message); - } else if (isAxiosError(e)) { - console.error( - `${e.config?.method?.toUpperCase() || 'GET'} ${e.config?.url} (Code: ${e.response?.status})` - ); - - if (e.response) { - console.error(JSON.stringify({ request: e.config, response: e.response.data }, null, 2)); - } + } else if (isKibanaError(e)) { + console.error(`Request failed (Code: ${e.status}): ${e.message}`); } else { console.error(e); } diff --git a/x-pack/solutions/observability/plugins/apm/scripts/diagnostics_bundle/cli.ts b/x-pack/solutions/observability/plugins/apm/scripts/diagnostics_bundle/cli.ts index f029ea8015e9b..9a0382306613f 100644 --- a/x-pack/solutions/observability/plugins/apm/scripts/diagnostics_bundle/cli.ts +++ b/x-pack/solutions/observability/plugins/apm/scripts/diagnostics_bundle/cli.ts @@ -10,7 +10,6 @@ import { URL } from 'url'; import datemath from '@elastic/datemath'; import { errors } from '@elastic/elasticsearch'; -import axios, { AxiosError } from 'axios'; import yargs from 'yargs'; import { initDiagnosticsBundle } from './diagnostics_bundle'; @@ -129,8 +128,8 @@ async function init() { }) .catch((err) => { process.exitCode = 1; - if (err instanceof AxiosError && err.response?.data) { - console.error(err.response.data); + if (isFetchResponseError(err) && err.responseData) { + console.error(err.responseData); return; } @@ -156,6 +155,16 @@ function convertDate(dateString: string): number { throw new Error(`Incorrect argument: ${dateString}`); } +interface FetchResponseError extends Error { + status?: number; + responseData?: unknown; + headers?: Record; +} + +function isFetchResponseError(e: unknown): e is FetchResponseError { + return e instanceof Error && 'responseData' in e; +} + async function getHostnameWithBasePath(kibanaHostname?: string) { if (!kibanaHostname) { return; @@ -164,18 +173,18 @@ async function getHostnameWithBasePath(kibanaHostname?: string) { const parsedHostName = parseHostName(kibanaHostname); try { - await axios.get(parsedHostName, { - maxRedirects: 0, + const response = await fetch(parsedHostName, { + redirect: 'manual', headers: { 'x-elastic-internal-origin': 'Kibana', }, }); - } catch (e) { - if (isAxiosError(e) && e.response?.status === 302) { - const location = e.response?.headers?.location ?? ''; + + if (response.status === 302) { + const location = response.headers.get('location') ?? ''; return `${parsedHostName}${location}`; } - + } catch (e) { throw e; } @@ -191,7 +200,3 @@ function parseHostName(hostname: string) { const parsedUrl = new URL(hostname); return parsedUrl.origin; } - -export function isAxiosError(e: AxiosError | Error): e is AxiosError { - return 'isAxiosError' in e; -} diff --git a/x-pack/solutions/observability/plugins/apm/scripts/diagnostics_bundle/diagnostics_bundle.ts b/x-pack/solutions/observability/plugins/apm/scripts/diagnostics_bundle/diagnostics_bundle.ts index d463c16fa5753..79a5251245e74 100644 --- a/x-pack/solutions/observability/plugins/apm/scripts/diagnostics_bundle/diagnostics_bundle.ts +++ b/x-pack/solutions/observability/plugins/apm/scripts/diagnostics_bundle/diagnostics_bundle.ts @@ -9,8 +9,6 @@ import { Client, HttpConnection } from '@elastic/elasticsearch'; import fs from 'fs/promises'; -import type { AxiosRequestConfig } from 'axios'; -import axios from 'axios'; import type { APMIndices, APIReturnType as SourcesAPIReturnType, @@ -20,6 +18,29 @@ import { getDiagnosticsBundle } from '../../server/routes/diagnostics/get_diagno type DiagnosticsBundle = APIReturnType<'GET /internal/apm/diagnostics'>; +interface KbnClientOpts { + baseURL?: string; + auth?: { username: string; password: string }; + headers: Record; +} + +async function kbnFetch(path: string, opts: KbnClientOpts): Promise { + const url = `${opts.baseURL ?? ''}${path}`; + const headers: Record = { ...opts.headers }; + if (opts.auth) { + headers.Authorization = `Basic ${Buffer.from( + `${opts.auth.username}:${opts.auth.password}` + ).toString('base64')}`; + } + + const response = await fetch(url, { headers }); + if (!response.ok) { + const errorText = await response.text().catch(() => ''); + throw new Error(`Request to ${path} failed with status ${response.status}: ${errorText}`); + } + return (await response.json()) as T; +} + export async function initDiagnosticsBundle({ esHost, kbHost, @@ -42,7 +63,7 @@ export async function initDiagnosticsBundle({ apiKey?: string; }) { const auth = username && password ? { username, password } : undefined; - const apiKeyHeader = apiKey ? { Authorization: `ApiKey ${apiKey}` } : {}; + const apiKeyHeader: Record = apiKey ? { Authorization: `ApiKey ${apiKey}` } : {}; const parsedCloudId = parseCloudId(cloudId); const esClient = new Client({ @@ -54,9 +75,8 @@ export async function initDiagnosticsBundle({ requestTimeout: 30_000, }); - const kibanaClientOpts = { + const kibanaClientOpts: KbnClientOpts = { baseURL: kbHost ?? parsedCloudId.kibanaHost, - allowAbsoluteUrls: false, auth, headers: { 'kbn-xsrf': 'true', @@ -90,28 +110,25 @@ async function saveReportToFile(combinedReport: DiagnosticsBundle) { console.log(`Diagnostics report written to "${filename}"`); } -async function getApmIndices(kbnClientOpts: AxiosRequestConfig): Promise { +async function getApmIndices(kbnClientOpts: KbnClientOpts): Promise { type Response = SourcesAPIReturnType<'GET /internal/apm-sources/settings/apm-indices'>; + return kbnFetch('/internal/apm-sources/settings/apm-indices', kbnClientOpts); +} - const res = await axios.get( - '/internal/apm-sources/settings/apm-indices', +async function getFleetPackageInfo(kbnClientOpts: KbnClientOpts) { + const data = await kbnFetch<{ item: { version: string; status: string } }>( + '/api/fleet/epm/packages/apm', kbnClientOpts ); - - return res.data; -} - -async function getFleetPackageInfo(kbnClientOpts: AxiosRequestConfig) { - const res = await axios.get('/api/fleet/epm/packages/apm', kbnClientOpts); return { - version: res.data.item.version, - isInstalled: res.data.item.status, + version: data.item.version, + isInstalled: data.item.status === 'installed', }; } -async function getKibanaVersion(kbnClientOpts: AxiosRequestConfig) { - const res = await axios.get('/api/status', kbnClientOpts); - return res.data.version.number; +async function getKibanaVersion(kbnClientOpts: KbnClientOpts) { + const data = await kbnFetch<{ version: { number: string } }>('/api/status', kbnClientOpts); + return data.version.number; } function parseCloudId(cloudId?: string) { diff --git a/x-pack/solutions/observability/plugins/apm/server/test_helpers/create_apm_users/helpers/abort_error.ts b/x-pack/solutions/observability/plugins/apm/server/test_helpers/create_apm_users/helpers/abort_error.ts new file mode 100644 index 0000000000000..9ac15b538834f --- /dev/null +++ b/x-pack/solutions/observability/plugins/apm/server/test_helpers/create_apm_users/helpers/abort_error.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export class AbortError extends Error { + constructor(message: string) { + super(message); + } +} diff --git a/x-pack/solutions/observability/plugins/apm/server/test_helpers/create_apm_users/helpers/call_kibana.ts b/x-pack/solutions/observability/plugins/apm/server/test_helpers/create_apm_users/helpers/call_kibana.ts index 17b58860c542f..cfeb1c2498872 100644 --- a/x-pack/solutions/observability/plugins/apm/server/test_helpers/create_apm_users/helpers/call_kibana.ts +++ b/x-pack/solutions/observability/plugins/apm/server/test_helpers/create_apm_users/helpers/call_kibana.ts @@ -4,11 +4,10 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import type { AxiosRequestConfig, AxiosError } from 'axios'; -import axios from 'axios'; import type { Elasticsearch, Kibana } from '../create_apm_users'; +export { AbortError } from './abort_error'; -const DEFAULT_HEADERS = { +const DEFAULT_HEADERS: Record = { 'kbn-xsrf': 'true', 'x-elastic-internal-origin': 'Kibana', }; @@ -24,6 +23,14 @@ const stripUrlCredentials = (urlString: string): string => { } }; +export interface CallKibanaOptions { + method?: string; + url?: string; + data?: unknown; + headers?: Record; + validateStatus?: (status: number) => boolean; +} + export async function callKibana({ elasticsearch, kibana, @@ -31,51 +38,66 @@ export async function callKibana({ }: { elasticsearch: Omit; kibana: Kibana; - options: AxiosRequestConfig; + options: CallKibanaOptions; }): Promise { const baseUrl = await getBaseUrl(kibana.hostname); const { username, password } = elasticsearch; const basicAuth = Buffer.from(`${username}:${password}`).toString('base64'); - const { data } = await axios.request({ - ...options, - baseURL: stripUrlCredentials(baseUrl), - allowAbsoluteUrls: false, + const fullUrl = `${stripUrlCredentials(baseUrl)}${options.url ?? ''}`; + + const response = await fetch(fullUrl, { + method: options.method ?? 'GET', headers: { ...DEFAULT_HEADERS, + 'content-type': 'application/json', ...options.headers, Authorization: `Basic ${basicAuth}`, }, + ...(options.data !== undefined + ? { body: typeof options.data === 'string' ? options.data : JSON.stringify(options.data) } + : {}), + redirect: 'manual', }); - return data; + + if (!response.ok) { + const errorText = await response.text().catch(() => ''); + throw new KibanaError( + response.status, + `Request failed with status ${response.status}: ${errorText}` + ); + } + + const data = await response.json(); + return data as T; } const getBaseUrl = async (kibanaHostname: string) => { try { - await axios.request({ - url: kibanaHostname, - maxRedirects: 0, + const response = await fetch(kibanaHostname, { + redirect: 'manual', headers: DEFAULT_HEADERS, }); - } catch (e) { - if (isAxiosError(e)) { - const location = e.response?.headers?.location ?? ''; + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get('location') ?? ''; const hasBasePath = RegExp(/^\/\w{3}$/).test(location); const basePath = hasBasePath ? location : ''; return `${kibanaHostname}${basePath}`; } - - throw e; + } catch (e) { + // If fetch itself throws (network error), just return the hostname } return kibanaHostname; }; -export function isAxiosError(e: AxiosError | Error): e is AxiosError { - return 'isAxiosError' in e; -} - -export class AbortError extends Error { - constructor(message: string) { +export class KibanaError extends Error { + public readonly status: number; + constructor(status: number, message: string) { super(message); + this.status = status; } } + +export function isKibanaError(e: unknown): e is KibanaError { + return e instanceof KibanaError; +} diff --git a/x-pack/solutions/observability/plugins/apm/server/test_helpers/create_apm_users/helpers/create_or_update_user.ts b/x-pack/solutions/observability/plugins/apm/server/test_helpers/create_apm_users/helpers/create_or_update_user.ts index efc6c071dbb47..d91e3c51f29f1 100644 --- a/x-pack/solutions/observability/plugins/apm/server/test_helpers/create_apm_users/helpers/create_or_update_user.ts +++ b/x-pack/solutions/observability/plugins/apm/server/test_helpers/create_apm_users/helpers/create_or_update_user.ts @@ -9,7 +9,7 @@ import { difference, union } from 'lodash'; import type { Elasticsearch, Kibana } from '../create_apm_users'; -import { callKibana, isAxiosError } from './call_kibana'; +import { callKibana, isKibanaError } from './call_kibana'; interface User { username: string; @@ -124,7 +124,7 @@ async function getUser({ }); } catch (e) { // return empty if user doesn't exist - if (isAxiosError(e) && e.response?.status === 404) { + if (isKibanaError(e) && e.status === 404) { return null; } diff --git a/x-pack/solutions/observability/plugins/apm/server/test_helpers/create_apm_users/helpers/get_version.ts b/x-pack/solutions/observability/plugins/apm/server/test_helpers/create_apm_users/helpers/get_version.ts index 331ad795eb88f..2ad5451822933 100644 --- a/x-pack/solutions/observability/plugins/apm/server/test_helpers/create_apm_users/helpers/get_version.ts +++ b/x-pack/solutions/observability/plugins/apm/server/test_helpers/create_apm_users/helpers/get_version.ts @@ -6,8 +6,7 @@ */ import type { Elasticsearch, Kibana } from '../create_apm_users'; -import { AbortError } from './call_kibana'; -import { callKibana, isAxiosError } from './call_kibana'; +import { AbortError, callKibana, isKibanaError } from './call_kibana'; export async function getKibanaVersion({ elasticsearch, @@ -27,19 +26,19 @@ export async function getKibanaVersion({ }); return res.version.number; } catch (e) { - if (isAxiosError(e)) { - switch (e.response?.status) { + if (isKibanaError(e)) { + switch (e.status) { case 401: throw new AbortError( - `Could not access Kibana with the provided credentials. Username: "${e.config?.auth?.username}". Password: "${e.config?.auth?.password}"` + `Could not access Kibana with the provided credentials. Username: "${elasticsearch.username}". Password: "${elasticsearch.password}"` ); case 404: - throw new AbortError(`Could not get version on ${e.config?.url} (Code: 404)`); + throw new AbortError(`Could not get version on ${kibana.hostname} (Code: 404)`); default: throw new AbortError( - `Cannot access Kibana on ${e.config?.baseURL}. Please specify Kibana with: "--kibana-url "` + `Cannot access Kibana on ${kibana.hostname}. Please specify Kibana with: "--kibana-url "` ); } } diff --git a/x-pack/solutions/observability/plugins/observability/test/scout/ui/fixtures/generators.ts b/x-pack/solutions/observability/plugins/observability/test/scout/ui/fixtures/generators.ts index 90862afbf8d2b..273d14ee244fb 100644 --- a/x-pack/solutions/observability/plugins/observability/test/scout/ui/fixtures/generators.ts +++ b/x-pack/solutions/observability/plugins/observability/test/scout/ui/fixtures/generators.ts @@ -8,7 +8,6 @@ import type { ApiServicesFixture, KbnClient } from '@kbn/scout-oblt'; import type { ApmFields, InfraDocument, LogDocument } from '@kbn/synthtrace-client'; import type { SynthtraceEsClient } from '@kbn/synthtrace/src/lib/shared/base_client'; import { apm, infra, log, timerange } from '@kbn/synthtrace-client'; -import { AxiosError } from 'axios'; export const TEST_START_DATE = '2024-01-01T00:00:00.000Z'; export const TEST_END_DATE = '2024-01-01T01:00:00.000Z'; @@ -205,7 +204,7 @@ export const createDataView = async ( }, }); } catch (error) { - if (error instanceof AxiosError && error.response?.status === 409) { + if (error instanceof Error && (error as any).response?.status === 409) { return; } throw error; diff --git a/x-pack/solutions/observability/plugins/observability_ai_assistant_app/scripts/evaluation/kibana_client.ts b/x-pack/solutions/observability/plugins/observability_ai_assistant_app/scripts/evaluation/kibana_client.ts index c78b43b251750..32e3781d44c06 100644 --- a/x-pack/solutions/observability/plugins/observability_ai_assistant_app/scripts/evaluation/kibana_client.ts +++ b/x-pack/solutions/observability/plugins/observability_ai_assistant_app/scripts/evaluation/kibana_client.ts @@ -28,10 +28,10 @@ import type { Message } from '@kbn/observability-ai-assistant-plugin/common'; import { MessageRole } from '@kbn/observability-ai-assistant-plugin/common'; import { streamIntoObservable } from '@kbn/observability-ai-assistant-plugin/server'; import type { ToolingLog } from '@kbn/tooling-log'; -import type { AxiosInstance, AxiosResponse, AxiosRequestConfig } from 'axios'; -import axios, { isAxiosError } from 'axios'; import { omit, pick, remove } from 'lodash'; import pRetry from 'p-retry'; +import { Readable } from 'stream'; +import type { ReadableStream as WebReadableStream } from 'stream/web'; import type { OperatorFunction, Observable } from 'rxjs'; import { concatMap, @@ -76,6 +76,15 @@ type CompleteFunction = (params: CompleteFunctionParams) => Promise<{ errors: ChatCompletionErrorEvent[]; }>; +interface FetchResponseError extends Error { + status?: number; + responseData?: unknown; +} + +function isFetchError(error: unknown): error is FetchResponseError { + return error instanceof Error && 'status' in error; +} + export interface ChatClient { chat: (message: StringOrMessageList, system: string) => Promise; complete: CompleteFunction; @@ -89,18 +98,18 @@ export interface ChatClient { } export class KibanaClient { - axios: AxiosInstance; + private readonly defaultHeaders: Record; + constructor( private readonly log: ToolingLog, private readonly url: string, private readonly spaceId?: string ) { - this.axios = axios.create({ - headers: { - 'kbn-xsrf': 'foo', - 'x-elastic-internal-origin': 'kibana', - }, - }); + this.defaultHeaders = { + 'kbn-xsrf': 'foo', + 'x-elastic-internal-origin': 'kibana', + 'Content-Type': 'application/json', + }; } private getUrl(props: { query?: UrlObject['query']; pathname: string; ignoreSpaceId?: boolean }) { @@ -121,34 +130,55 @@ export class KibanaClient { return url; } - callKibana( + async callKibana( method: string, props: { query?: UrlObject['query']; pathname: string; ignoreSpaceId?: boolean }, data?: any, - axiosParams: Partial = {} - ) { + fetchParams: { headers?: Record } = {} + ): Promise<{ status: number; data: T; headers: Record }> { const url = this.getUrl(props); - return this.axios({ + const body = + method.toLowerCase() === 'delete' && !data ? undefined : JSON.stringify(data || {}); + + const resp = await fetch(url, { method, - url, - ...(method.toLowerCase() === 'delete' && !data ? {} : { data: data || {} }), - ...axiosParams, - }).catch((error) => { - if (isAxiosError(error)) { - const interestingPartsOfError = { - ...omit(error, 'request', 'response', 'config'), - ...pick( - error, - 'response.data', - 'response.headers', - 'response.status', - 'response.statusText' - ), - }; - this.log.error(inspect(interestingPartsOfError, { depth: 10 })); + headers: { + ...this.defaultHeaders, + ...fetchParams.headers, + }, + body, + }); + + if (!resp.ok) { + const respBody = await resp.text().catch(() => ''); + let parsedBody: unknown; + try { + parsedBody = JSON.parse(respBody); + } catch { + parsedBody = respBody; } + + const error: FetchResponseError = new Error(`Request failed with status ${resp.status}`); + error.status = resp.status; + error.responseData = parsedBody; + + const interestingPartsOfError = { + message: error.message, + status: error.status, + responseData: error.responseData, + }; + this.log.error(inspect(interestingPartsOfError, { depth: 10 })); + throw error; + } + + const responseHeaders: Record = {}; + resp.headers.forEach((value, key) => { + responseHeaders[key] = value; }); + + const responseData = (await resp.json()) as T; + return { status: resp.status, data: responseData, headers: responseHeaders }; } async installKnowledgeBase() { @@ -194,22 +224,24 @@ export class KibanaClient { this.log.info(`Checking if space ${this.spaceId} exists`); - const spaceExistsResponse = await this.callKibana<{ - id?: string; - }>('GET', { - pathname: `/api/spaces/space/${this.spaceId}`, - ignoreSpaceId: true, - }).catch((error) => { - if (isAxiosError(error) && error.response?.status === 404) { - return { + let spaceExistsResponse: { status: number; data: { id?: string } }; + try { + spaceExistsResponse = await this.callKibana<{ id?: string }>('GET', { + pathname: `/api/spaces/space/${this.spaceId}`, + ignoreSpaceId: true, + }); + } catch (error) { + if (isFetchError(error) && error.status === 404) { + spaceExistsResponse = { status: 404, data: { id: undefined, }, }; + } else { + throw error; } - throw error; - }); + } if (spaceExistsResponse.data.id) { this.log.success(`Space id ${this.spaceId} found`); @@ -323,7 +355,7 @@ export class KibanaClient { that.log.info('Caught retryable error'); - if (isAxiosError(error)) { + if (isFetchError(error)) { that.log.error( inspect( { @@ -387,19 +419,29 @@ export class KibanaClient { scopes: currentScopes, }; - return that.axios.post( - that.getUrl({ - pathname: '/internal/observability_ai_assistant/chat', - }), - params, - { - responseType: 'stream', - timeout: NaN, - headers: { 'x-elastic-internal-origin': 'Kibana' }, - } + return from( + fetch( + that.getUrl({ + pathname: '/internal/observability_ai_assistant/chat', + }), + { + method: 'POST', + headers: { + ...that.defaultHeaders, + 'x-elastic-internal-origin': 'Kibana', + }, + body: JSON.stringify(params), + } + ) ); }).pipe( - switchMap((response) => streamIntoObservable(response.data)), + switchMap((response) => + streamIntoObservable( + Readable.fromWeb( + response.body! as unknown as WebReadableStream + ) as unknown as NodeJS.AsyncIterator + ) + ), serializeAndHandleRetryableErrors(), filter( (line): line is ChatCompletionChunkEvent => @@ -446,29 +488,35 @@ export class KibanaClient { const stream$ = defer(() => { that.log.info(`Calling /chat/complete API`); return from( - that.axios.post( + fetch( that.getUrl({ pathname: '/internal/observability_ai_assistant/chat/complete', }), { - screenContexts: options.screenContexts || [], - conversationId, - messages, - connectorId, - persist, - title: currentTitle, - scopes: currentScopes, - }, - { - responseType: 'stream', - timeout: NaN, - headers: { 'x-elastic-internal-origin': 'Kibana' }, + method: 'POST', + headers: { + ...that.defaultHeaders, + 'x-elastic-internal-origin': 'Kibana', + }, + body: JSON.stringify({ + screenContexts: options.screenContexts || [], + conversationId, + messages, + connectorId, + persist, + title: currentTitle, + scopes: currentScopes, + }), } ) ); }).pipe( switchMap((response) => { - return streamIntoObservable(response.data); + return streamIntoObservable( + Readable.fromWeb( + response.body! as unknown as WebReadableStream + ) as unknown as NodeJS.AsyncIterator + ); }), serializeAndHandleRetryableErrors(), catchError((error): Observable => { @@ -537,13 +585,13 @@ export class KibanaClient { For each criterion, calculate a score. Explain your score, by describing what the assistant did right, and describing and quoting what the assistant did wrong, where it could improve, and what the root cause was in case of a failure. - + ### Scoring Contract - * You MUST call the function "scores" exactly once. - * The "criteria" array in the arguments MUST contain **one object for EVERY criterion**. - * If a criterion cannot be satisfied, still include it with \`"score": 0\` and a short \`"reasoning"\`. - * Do NOT omit, merge, or reorder indices. + * You MUST call the function "scores" exactly once. + * The "criteria" array in the arguments MUST contain **one object for EVERY criterion**. + * If a criterion cannot be satisfied, still include it with \`"score": 0\` and a short \`"reasoning"\`. + * Do NOT omit, merge, or reorder indices. * Do NOT place the scores in normal text; only in the "scores" function call.`, messages: [ { @@ -666,23 +714,27 @@ export class KibanaClient { } async getConnectors() { - const connectors: AxiosResponse< - Array<{ - id: string; - connector_type_id: string; - name: string; - is_preconfigured: boolean; - is_deprecated: boolean; - referenced_by_count: number; - }> - > = await axios.get( - this.getUrl({ - pathname: '/api/actions/connectors', - }) - ); + const url = this.getUrl({ + pathname: '/api/actions/connectors', + }); - return connectors.data.filter((connector) => - isSupportedConnectorType(connector.connector_type_id) - ); + const resp = await fetch(url, { + headers: this.defaultHeaders, + }); + + if (!resp.ok) { + throw new Error(`Failed to get connectors: ${resp.status} ${resp.statusText}`); + } + + const connectors = (await resp.json()) as Array<{ + id: string; + connector_type_id: string; + name: string; + is_preconfigured: boolean; + is_deprecated: boolean; + referenced_by_count: number; + }>; + + return connectors.filter((connector) => isSupportedConnectorType(connector.connector_type_id)); } } diff --git a/x-pack/solutions/observability/plugins/observability_onboarding/server/test_helpers/create_observability_onboarding_users/helpers/abort_error.ts b/x-pack/solutions/observability/plugins/observability_onboarding/server/test_helpers/create_observability_onboarding_users/helpers/abort_error.ts new file mode 100644 index 0000000000000..9ac15b538834f --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability_onboarding/server/test_helpers/create_observability_onboarding_users/helpers/abort_error.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export class AbortError extends Error { + constructor(message: string) { + super(message); + } +} diff --git a/x-pack/solutions/observability/plugins/observability_onboarding/server/test_helpers/create_observability_onboarding_users/helpers/call_kibana.ts b/x-pack/solutions/observability/plugins/observability_onboarding/server/test_helpers/create_observability_onboarding_users/helpers/call_kibana.ts index 37d2700ab84c9..a02820d1156d4 100644 --- a/x-pack/solutions/observability/plugins/observability_onboarding/server/test_helpers/create_observability_onboarding_users/helpers/call_kibana.ts +++ b/x-pack/solutions/observability/plugins/observability_onboarding/server/test_helpers/create_observability_onboarding_users/helpers/call_kibana.ts @@ -4,10 +4,17 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import type { AxiosRequestConfig, AxiosError } from 'axios'; -import axios from 'axios'; import { once } from 'lodash'; import type { Elasticsearch, Kibana } from '..'; +export { AbortError } from './abort_error'; + +export interface CallKibanaOptions { + method?: string; + url?: string; + data?: unknown; + headers?: Record; + validateStatus?: (status: number) => boolean; +} export async function callKibana({ elasticsearch, @@ -16,47 +23,67 @@ export async function callKibana({ }: { elasticsearch: Omit; kibana: Kibana; - options: AxiosRequestConfig; + options: CallKibanaOptions; }): Promise { const baseUrl = await getBaseUrl(kibana.hostname); const { username, password } = elasticsearch; + const basicAuth = Buffer.from(`${username}:${password}`).toString('base64'); - const { data } = await axios.request({ - ...options, - baseURL: baseUrl, - allowAbsoluteUrls: false, - auth: { username, password }, - headers: { 'kbn-xsrf': 'true', 'x-elastic-internal-origin': 'kibana', ...options.headers }, + const fullUrl = `${baseUrl}${options.url ?? ''}`; + + const response = await fetch(fullUrl, { + method: options.method ?? 'GET', + headers: { + 'kbn-xsrf': 'true', + 'x-elastic-internal-origin': 'kibana', + 'content-type': 'application/json', + ...options.headers, + Authorization: `Basic ${basicAuth}`, + }, + ...(options.data !== undefined + ? { body: typeof options.data === 'string' ? options.data : JSON.stringify(options.data) } + : {}), + redirect: 'manual', }); - return data; + + if (!response.ok) { + const errorText = await response.text().catch(() => ''); + throw new KibanaError( + response.status, + `Request failed with status ${response.status}: ${errorText}` + ); + } + + const data = await response.json(); + return data as T; } const getBaseUrl = once(async (kibanaHostname: string) => { try { - await axios.request({ - url: kibanaHostname, - maxRedirects: 0, + const response = await fetch(kibanaHostname, { + redirect: 'manual', headers: { 'x-elastic-internal-origin': 'kibana' }, }); - } catch (e) { - if (isAxiosError(e)) { - const location = e.response?.headers?.location ?? ''; + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get('location') ?? ''; const hasBasePath = RegExp(/^\/\w{3}$/).test(location); const basePath = hasBasePath ? location : ''; return `${kibanaHostname}${basePath}`; } - - throw e; + } catch (e) { + // If fetch itself throws (network error), just return the hostname } return kibanaHostname; }); -export function isAxiosError(e: AxiosError | Error): e is AxiosError { - return 'isAxiosError' in e; -} - -export class AbortError extends Error { - constructor(message: string) { +export class KibanaError extends Error { + public readonly status: number; + constructor(status: number, message: string) { super(message); + this.status = status; } } + +export function isKibanaError(e: unknown): e is KibanaError { + return e instanceof KibanaError; +} diff --git a/x-pack/solutions/observability/plugins/observability_onboarding/server/test_helpers/create_observability_onboarding_users/helpers/create_or_update_user.ts b/x-pack/solutions/observability/plugins/observability_onboarding/server/test_helpers/create_observability_onboarding_users/helpers/create_or_update_user.ts index f3e81c4b6c5e4..bec033f05de43 100644 --- a/x-pack/solutions/observability/plugins/observability_onboarding/server/test_helpers/create_observability_onboarding_users/helpers/create_or_update_user.ts +++ b/x-pack/solutions/observability/plugins/observability_onboarding/server/test_helpers/create_observability_onboarding_users/helpers/create_or_update_user.ts @@ -9,7 +9,7 @@ import { difference, union } from 'lodash'; import type { Elasticsearch, Kibana } from '..'; -import { callKibana, isAxiosError } from './call_kibana'; +import { callKibana, isKibanaError } from './call_kibana'; interface User { username: string; @@ -124,7 +124,7 @@ async function getUser({ }); } catch (e) { // return empty if user doesn't exist - if (isAxiosError(e) && e.response?.status === 404) { + if (isKibanaError(e) && e.status === 404) { return null; } diff --git a/x-pack/solutions/observability/plugins/synthetics/scripts/tasks/generate_monitors.ts b/x-pack/solutions/observability/plugins/synthetics/scripts/tasks/generate_monitors.ts index c88fa53c1198e..224f5ff630bf5 100644 --- a/x-pack/solutions/observability/plugins/synthetics/scripts/tasks/generate_monitors.ts +++ b/x-pack/solutions/observability/plugins/synthetics/scripts/tasks/generate_monitors.ts @@ -5,7 +5,6 @@ * 2.0. */ -import axios from 'axios'; import moment from 'moment'; import { readKibanaConfig } from '@kbn/observability-synthetics-test-data'; @@ -22,9 +21,14 @@ function getAuthFromKibanaConfig() { return { username, password }; } +const getAuthHeader = () => { + const { username, password } = getAuthFromKibanaConfig(); + return 'Basic ' + Buffer.from(`${username}:${password}`).toString('base64'); +}; + export const generateMonitors = async () => { - const policy = (await createTestAgentPolicy()) as { data: { item: { id: string } } }; - const location = await createPrivateLocation(policy.data.item.id); + const policy = (await createTestAgentPolicy()) as { item: { id: string } }; + const location = await createPrivateLocation(policy.item.id); // eslint-disable-next-line no-console console.log(`Generating ${UP_MONITORS} up monitors`); @@ -40,18 +44,26 @@ export const generateMonitors = async () => { }; const createMonitor = async (monitor: any) => { - await axios - .request({ - data: monitor, - method: 'post', - url: 'http://127.0.0.1:5601/test/api/synthetics/monitors', - auth: getAuthFromKibanaConfig(), - headers: { 'kbn-xsrf': 'true', 'elastic-api-version': '2023-10-31' }, - }) - .catch((error) => { - // eslint-disable-next-line no-console - console.error(error); + try { + const response = await fetch('http://127.0.0.1:5601/test/api/synthetics/monitors', { + method: 'POST', + body: JSON.stringify(monitor), + headers: { + 'content-type': 'application/json', + 'kbn-xsrf': 'true', + 'elastic-api-version': '2023-10-31', + Authorization: getAuthHeader(), + }, }); + if (!response.ok) { + const errorText = await response.text(); + // eslint-disable-next-line no-console + console.error(`Failed to create monitor: ${response.status} ${errorText}`); + } + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + } }; const createTestAgentPolicy = async () => { @@ -63,18 +75,29 @@ const createTestAgentPolicy = async () => { inactivity_timeout: 1209600, is_protected: false, }; - return await axios - .request({ - data, - method: 'post', - url: 'http://127.0.0.1:5601/test/api/fleet/agent_policies', - auth: getAuthFromKibanaConfig(), - headers: { 'kbn-xsrf': 'true', 'elastic-api-version': '2023-10-31' }, - }) - .catch((error) => { - // eslint-disable-next-line no-console - console.error(error); + try { + const response = await fetch('http://127.0.0.1:5601/test/api/fleet/agent_policies', { + method: 'POST', + body: JSON.stringify(data), + headers: { + 'content-type': 'application/json', + 'kbn-xsrf': 'true', + 'elastic-api-version': '2023-10-31', + Authorization: getAuthHeader(), + }, }); + if (!response.ok) { + const errorText = await response.text(); + // eslint-disable-next-line no-console + console.error(`Failed to create agent policy: ${response.status} ${errorText}`); + return undefined; + } + return await response.json(); + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + return undefined; + } }; const createPrivateLocation = async (policyId: string) => { @@ -85,20 +108,29 @@ const createPrivateLocation = async (policyId: string) => { spaces: ['*'], }; - return ( - (await axios - .request({ - data, - method: 'post', - url: 'http://127.0.0.1:5601/test/api/synthetics/private_locations', - auth: getAuthFromKibanaConfig(), - headers: { 'kbn-xsrf': 'true', 'elastic-api-version': '2023-10-31' }, - }) - .catch((error) => { - // eslint-disable-next-line no-console - console.error(error); - })) as any - ).data; + try { + const response = await fetch('http://127.0.0.1:5601/test/api/synthetics/private_locations', { + method: 'POST', + body: JSON.stringify(data), + headers: { + 'content-type': 'application/json', + 'kbn-xsrf': 'true', + 'elastic-api-version': '2023-10-31', + Authorization: getAuthHeader(), + }, + }); + if (!response.ok) { + const errorText = await response.text(); + // eslint-disable-next-line no-console + console.error(`Failed to create private location: ${response.status} ${errorText}`); + return undefined; + } + return await response.json(); + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + return undefined; + } }; const getHttpMonitor = ({ diff --git a/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/get_service_locations.test.ts b/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/get_service_locations.test.ts index 245ae06dc66d5..23c24bce59a68 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/get_service_locations.test.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/get_service_locations.test.ts @@ -4,41 +4,37 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import axios from 'axios'; import { getServiceLocations } from './get_service_locations'; import { BandwidthLimitKey, LocationStatus } from '../../common/runtime_types'; -jest.mock('axios'); -const mockedAxios = axios as jest.Mocked; - -const mockSuccessResponse = { - data: { - throttling: { - [BandwidthLimitKey.DOWNLOAD]: 100, - [BandwidthLimitKey.UPLOAD]: 50, - }, - locations: { - us_central: { - url: 'https://local.dev', - geo: { - name: 'US Central', - location: { lat: 41.25, lon: -95.86 }, - }, - status: LocationStatus.GA, +const mockResponseData = { + throttling: { + [BandwidthLimitKey.DOWNLOAD]: 100, + [BandwidthLimitKey.UPLOAD]: 50, + }, + locations: { + us_central: { + url: 'https://local.dev', + geo: { + name: 'US Central', + location: { lat: 41.25, lon: -95.86 }, }, - us_east: { - url: 'https://local.dev', - geo: { - name: 'US East', - location: { lat: 41.25, lon: -95.86 }, - }, - status: LocationStatus.EXPERIMENTAL, + status: LocationStatus.GA, + }, + us_east: { + url: 'https://local.dev', + geo: { + name: 'US East', + location: { lat: 41.25, lon: -95.86 }, }, + status: LocationStatus.EXPERIMENTAL, }, }, }; +const mockFetch = jest.spyOn(global, 'fetch'); + // @ts-ignore - only mocking the properties needed for testing const createMockServer = (): any => ({ isDev: true, @@ -65,7 +61,10 @@ describe('getServiceLocations', function () { }); it('should return all locations on successful first attempt', async () => { - mockedAxios.get.mockResolvedValue(mockSuccessResponse); + mockFetch.mockResolvedValue({ + ok: true, + json: async () => mockResponseData, + } as Response); const mockServer = createMockServer(); const locationsPromise = getServiceLocations(mockServer); @@ -106,16 +105,19 @@ describe('getServiceLocations', function () { }, ], }); - expect(mockedAxios.get).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledTimes(1); expect(mockServer.logger.error).not.toHaveBeenCalled(); }); it('should retry on failure and succeed on subsequent attempt', async () => { const networkError = new Error('getaddrinfo EAI_AGAIN manifest.synthetics.elastic-cloud.com'); - mockedAxios.get + mockFetch .mockRejectedValueOnce(networkError) .mockRejectedValueOnce(networkError) - .mockResolvedValueOnce(mockSuccessResponse); + .mockResolvedValueOnce({ + ok: true, + json: async () => mockResponseData, + } as Response); const mockServer = createMockServer(); const locationsPromise = getServiceLocations(mockServer); @@ -124,7 +126,7 @@ describe('getServiceLocations', function () { await jest.runAllTimersAsync(); const locations = await locationsPromise; - expect(mockedAxios.get).toHaveBeenCalledTimes(3); + expect(mockFetch).toHaveBeenCalledTimes(3); expect(mockServer.logger.debug).toHaveBeenCalledTimes(2); expect(mockServer.logger.error).not.toHaveBeenCalled(); expect(locations.locations).toHaveLength(2); @@ -132,7 +134,7 @@ describe('getServiceLocations', function () { it('should log error only after all retries are exhausted', async () => { const networkError = new Error('getaddrinfo EAI_AGAIN manifest.synthetics.elastic-cloud.com'); - mockedAxios.get.mockRejectedValue(networkError); + mockFetch.mockRejectedValue(networkError); const mockServer = createMockServer(); const locationsPromise = getServiceLocations(mockServer); @@ -142,7 +144,7 @@ describe('getServiceLocations', function () { const locations = await locationsPromise; // Initial attempt + 3 retries = 4 total calls - expect(mockedAxios.get).toHaveBeenCalledTimes(4); + expect(mockFetch).toHaveBeenCalledTimes(4); // Debug logs for each failed attempt expect(mockServer.logger.debug).toHaveBeenCalledTimes(4); // Error logged only once after all retries exhausted @@ -156,7 +158,10 @@ describe('getServiceLocations', function () { it('should succeed on first retry after initial failure', async () => { const networkError = new Error('Network error'); - mockedAxios.get.mockRejectedValueOnce(networkError).mockResolvedValueOnce(mockSuccessResponse); + mockFetch.mockRejectedValueOnce(networkError).mockResolvedValueOnce({ + ok: true, + json: async () => mockResponseData, + } as Response); const mockServer = createMockServer(); const locationsPromise = getServiceLocations(mockServer); @@ -165,7 +170,7 @@ describe('getServiceLocations', function () { await jest.runAllTimersAsync(); const locations = await locationsPromise; - expect(mockedAxios.get).toHaveBeenCalledTimes(2); + expect(mockFetch).toHaveBeenCalledTimes(2); expect(mockServer.logger.debug).toHaveBeenCalledTimes(1); expect(mockServer.logger.error).not.toHaveBeenCalled(); expect(locations.locations).toHaveLength(2); @@ -188,7 +193,7 @@ describe('getServiceLocations', function () { const locations = await getServiceLocations(mockServer); - expect(mockedAxios.get).not.toHaveBeenCalled(); + expect(mockFetch).not.toHaveBeenCalled(); expect(locations.locations).toHaveLength(2); expect(locations.locations[0].id).toBe('dev'); expect(locations.locations[1].id).toBe('dev2'); @@ -211,7 +216,7 @@ describe('getServiceLocations', function () { const locations = await getServiceLocations(mockServer); - expect(mockedAxios.get).not.toHaveBeenCalled(); + expect(mockFetch).not.toHaveBeenCalled(); expect(locations).toEqual({ locations: [] }); }); }); diff --git a/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/get_service_locations.ts b/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/get_service_locations.ts index 43827427ec501..41e4ec951a696 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/get_service_locations.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/get_service_locations.ts @@ -5,7 +5,6 @@ * 2.0. */ -import axios from 'axios'; import { pick } from 'lodash'; import pRetry from 'p-retry'; import type { SyntheticsServerSetup } from '../types'; @@ -54,12 +53,16 @@ export async function getServiceLocations(server: SyntheticsServerSetup) { } try { - const { data } = await pRetry( + const data = await pRetry( async () => { - return axios.get<{ + const response = await fetch(server.config.service!.manifestUrl!); + if (!response.ok) { + throw new Error(`Failed to fetch locations: ${response.status} ${response.statusText}`); + } + return (await response.json()) as { throttling: ThrottlingOptions; locations: Record; - }>(server.config.service!.manifestUrl!); + }; }, { retries: RETRY_COUNT, diff --git a/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/service_api_client.test.ts b/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/service_api_client.test.ts index 857cb8cd3c585..a5c624eea72d3 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/service_api_client.test.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/service_api_client.test.ts @@ -11,7 +11,6 @@ import { coreMock } from '@kbn/core/server/mocks'; import type { Logger } from '@kbn/core/server'; import { ServiceAPIClient } from './service_api_client'; import type { ServiceConfig } from '../config'; -import axios from 'axios'; import type { PublicLocations } from '../../common/runtime_types'; import { LocationStatus } from '../../common/runtime_types'; import type { LicenseGetResponse } from '@elastic/elasticsearch/lib/api/types'; @@ -35,7 +34,8 @@ const licenseMock: LicenseGetResponse = { }, }; -jest.mock('axios', () => jest.fn()); +const mockFetch = jest.spyOn(global, 'fetch'); + jest.mock('./utils/sanitize_error', () => ({ getSanitizedError: jest.fn().mockImplementation(() => 'sanitized error'), })); @@ -111,7 +111,7 @@ describe('getHttpsAgent', () => { describe('checkAccountAccessStatus', () => { beforeEach(() => { - (axios as jest.MockedFunction).mockReset(); + mockFetch.mockReset(); }); it('includes a header with the kibana version', async () => { @@ -130,18 +130,18 @@ describe('checkAccountAccessStatus', () => { }, ]; - (axios as jest.MockedFunction).mockResolvedValue({ - status: 200, - statusText: 'ok', - headers: {}, - config: {}, - data: { allowed: true, signupUrl: 'http://localhost:666/example' }, - }); + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ allowed: true, signupUrl: 'http://localhost:666/example' }), + } as Response); const result = await apiClient.checkAccountAccessStatus(); - expect(axios).toHaveBeenCalledWith( - expect.objectContaining({ headers: { 'x-kibana-version': '8.4' } }) + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost/allowed', + expect.objectContaining({ + headers: expect.objectContaining({ 'x-kibana-version': '8.4' }), + }) ); expect(result).toEqual({ allowed: true, signupUrl: 'http://localhost:666/example' }); @@ -163,7 +163,7 @@ describe('checkAccountAccessStatus', () => { }, ]; const error = new Error('Request failed', { someConfig: 'someValue' } as any); - (axios as jest.MockedFunction).mockRejectedValue(error); + mockFetch.mockRejectedValue(error); await apiClient.checkAccountAccessStatus(); @@ -179,7 +179,7 @@ describe('checkAccountAccessStatus', () => { describe('syncMonitors', () => { beforeEach(() => { - (axios as jest.MockedFunction).mockReset(); + mockFetch.mockReset(); }); it('logs a sanitized error if callAPI fails', async () => { @@ -198,7 +198,7 @@ describe('syncMonitors', () => { }, ]; const error = new Error('Request failed', { someConfig: 'someValue' } as any); - (axios as jest.MockedFunction).mockRejectedValue(error); + mockFetch.mockRejectedValue(error); const output = { hosts: ['https://localhost:9200'], api_key: '12345' }; @@ -222,7 +222,7 @@ describe('syncMonitors', () => { describe('callAPI', () => { beforeEach(() => { - (axios as jest.MockedFunction).mockReset(); + mockFetch.mockReset(); jest.clearAllMocks(); }); @@ -235,13 +235,11 @@ describe('callAPI', () => { }; it('it calls service endpoint when adding monitors with basic auth', async () => { - const axiosSpy = (axios as jest.MockedFunction).mockResolvedValue({ + mockFetch.mockResolvedValue({ + ok: true, status: 200, - statusText: 'ok', - headers: {}, - config: {}, - data: { allowed: true, signupUrl: 'http://localhost:666/example' }, - }); + json: async () => ({ allowed: true, signupUrl: 'http://localhost:666/example' }), + } as Response); const apiClient = new ServiceAPIClient(logger, config, { isDev: true, @@ -293,78 +291,56 @@ describe('callAPI', () => { 'monitors' ); - expect(axiosSpy).toHaveBeenCalledTimes(3); - expect(axiosSpy).toHaveBeenNthCalledWith(1, { - data: { - monitors: request1, - is_edit: undefined, - output, - stack_version: '8.7.0', - license_level: 'trial', - license_issued_to: '2c515bd215ce444441f83ffd36a9d3d2546', - }, - headers: { - Authorization: 'Basic ZGV2OjEyMzQ1', - 'x-kibana-version': '8.7.0', - }, - httpsAgent: expect.objectContaining({ - options: expect.objectContaining({ - rejectUnauthorized: true, - path: null, - noDelay: true, + expect(mockFetch).toHaveBeenCalledTimes(3); + expect(mockFetch).toHaveBeenNthCalledWith( + 1, + 'https://service.dev/monitors', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Basic ZGV2OjEyMzQ1', + 'x-kibana-version': '8.7.0', + 'content-type': 'application/json', }), - }), - method: 'POST', - url: 'https://service.dev/monitors', - }); + }) + ); - expect(axiosSpy).toHaveBeenNthCalledWith(2, { - data: { - monitors: request2, - is_edit: undefined, - output, - stack_version: '8.7.0', - license_level: 'trial', - license_issued_to: '2c515bd215ce444441f83ffd36a9d3d2546', - }, - headers: { - Authorization: 'Basic ZGV2OjEyMzQ1', - 'x-kibana-version': '8.7.0', - }, - httpsAgent: expect.objectContaining({ - options: expect.objectContaining({ - rejectUnauthorized: true, - path: null, - noDelay: true, + expect(mockFetch).toHaveBeenNthCalledWith( + 2, + 'https://qa.service.elstc.co/monitors', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Basic ZGV2OjEyMzQ1', + 'x-kibana-version': '8.7.0', + 'content-type': 'application/json', }), - }), - method: 'POST', - url: 'https://qa.service.elstc.co/monitors', - }); + }) + ); - expect(axiosSpy).toHaveBeenNthCalledWith(3, { - data: { - monitors: request3, - is_edit: undefined, - output, - stack_version: '8.7.0', - license_level: 'trial', - license_issued_to: '2c515bd215ce444441f83ffd36a9d3d2546', - }, - headers: { - Authorization: 'Basic ZGV2OjEyMzQ1', - 'x-kibana-version': '8.7.0', - }, - httpsAgent: expect.objectContaining({ - options: expect.objectContaining({ - rejectUnauthorized: true, - path: null, - noDelay: true, + expect(mockFetch).toHaveBeenNthCalledWith( + 3, + 'https://qa.service.stg.co/monitors', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Basic ZGV2OjEyMzQ1', + 'x-kibana-version': '8.7.0', + 'content-type': 'application/json', }), - }), - method: 'POST', - url: 'https://qa.service.stg.co/monitors', - }); + }) + ); + + // Verify the request bodies contain the expected data + const call1Body = JSON.parse(mockFetch.mock.calls[0][1]!.body as string); + expect(call1Body.monitors).toEqual(request1); + expect(call1Body.stack_version).toBe('8.7.0'); + + const call2Body = JSON.parse(mockFetch.mock.calls[1][1]!.body as string); + expect(call2Body.monitors).toEqual(request2); + + const call3Body = JSON.parse(mockFetch.mock.calls[2][1]!.body as string); + expect(call3Body.monitors).toEqual(request3); expect(logger.error).toHaveBeenCalledTimes(0); expect(logger.debug).toHaveBeenCalledTimes(6); @@ -374,26 +350,24 @@ describe('callAPI', () => { }); expect(logger.debug).toHaveBeenNthCalledWith( 2, - 'Successfully called service location https://service.devundefined with method POST with 4 monitors' + 'Successfully called service location https://service.dev/monitors with method POST with 4 monitors' ); expect(logger.debug).toHaveBeenNthCalledWith( 4, - 'Successfully called service location https://qa.service.elstc.coundefined with method POST with 4 monitors' + 'Successfully called service location https://qa.service.elstc.co/monitors with method POST with 4 monitors' ); expect(logger.debug).toHaveBeenNthCalledWith( 6, - 'Successfully called service location https://qa.service.stg.coundefined with method POST with 1 monitors' + 'Successfully called service location https://qa.service.stg.co/monitors with method POST with 1 monitors' ); }); it('it calls service endpoint when adding monitors with tls auth', async () => { - const axiosSpy = (axios as jest.MockedFunction).mockResolvedValue({ + mockFetch.mockResolvedValue({ + ok: true, status: 200, - statusText: 'ok', - headers: {}, - config: {}, - data: { allowed: true, signupUrl: 'http://localhost:666/example' }, - }); + json: async () => ({ allowed: true, signupUrl: 'http://localhost:666/example' }), + } as Response); const apiClient = new ServiceAPIClient( logger, @@ -420,40 +394,34 @@ describe('callAPI', () => { license: licenseMock.license, }); - expect(axiosSpy).toHaveBeenNthCalledWith(1, { - data: { - monitors: request1, - is_edit: undefined, - output, - stack_version: '8.7.0', - license_level: 'trial', - license_issued_to: '2c515bd215ce444441f83ffd36a9d3d2546', - }, - headers: { - 'x-kibana-version': '8.7.0', - }, - httpsAgent: expect.objectContaining({ - options: expect.objectContaining({ - rejectUnauthorized: true, - path: null, - noDelay: true, - cert: 'test-certificate', - key: 'test-key', + expect(mockFetch).toHaveBeenNthCalledWith( + 1, + 'https://service.dev/monitors', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'x-kibana-version': '8.7.0', + 'content-type': 'application/json', }), - }), - method: 'POST', - url: 'https://service.dev/monitors', - }); + }) + ); + + // Verify no Authorization header when using TLS + const headers = mockFetch.mock.calls[0][1]!.headers as Record; + expect(headers.Authorization).toBeUndefined(); + + // Verify request body + const body = JSON.parse(mockFetch.mock.calls[0][1]!.body as string); + expect(body.monitors).toEqual(request1); + expect(body.stack_version).toBe('8.7.0'); }); it('Calls the `/run` endpoint when calling `runOnce`', async () => { - const axiosSpy = (axios as jest.MockedFunction).mockResolvedValue({ + mockFetch.mockResolvedValue({ + ok: true, status: 200, - statusText: 'ok', - headers: {}, - config: {}, - data: { allowed: true, signupUrl: 'http://localhost:666/example' }, - }); + json: async () => ({ allowed: true, signupUrl: 'http://localhost:666/example' }), + } as Response); const apiClient = new ServiceAPIClient( logger, @@ -474,40 +442,28 @@ describe('callAPI', () => { license: licenseMock.license, }); - expect(axiosSpy).toHaveBeenNthCalledWith(1, { - data: { - monitors: request1, - is_edit: undefined, - output, - stack_version: '8.7.0', - license_level: 'trial', - license_issued_to: '2c515bd215ce444441f83ffd36a9d3d2546', - }, - headers: { - 'x-kibana-version': '8.7.0', - }, - httpsAgent: expect.objectContaining({ - options: expect.objectContaining({ - rejectUnauthorized: true, - path: null, - noDelay: true, - cert: 'test-certificate', - key: 'test-key', + expect(mockFetch).toHaveBeenNthCalledWith( + 1, + 'https://service.dev/run', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'x-kibana-version': '8.7.0', + 'content-type': 'application/json', }), - }), - method: 'POST', - url: 'https://service.dev/run', - }); + }) + ); + + const body = JSON.parse(mockFetch.mock.calls[0][1]!.body as string); + expect(body.monitors).toEqual(request1); }); it('Calls the `/monitors/sync` endpoint when calling `syncMonitors`', async () => { - const axiosSpy = (axios as jest.MockedFunction).mockResolvedValue({ + mockFetch.mockResolvedValue({ + ok: true, status: 200, - statusText: 'ok', - headers: {}, - config: {}, - data: { allowed: true, signupUrl: 'http://localhost:666/example' }, - }); + json: async () => ({ allowed: true, signupUrl: 'http://localhost:666/example' }), + } as Response); const apiClient = new ServiceAPIClient( logger, @@ -532,44 +488,42 @@ describe('callAPI', () => { license: licenseMock.license, }); - expect(axiosSpy).toHaveBeenNthCalledWith(1, { - data: { - monitors: request1, - is_edit: undefined, - output, - stack_version: '8.7.0', - license_level: 'trial', - license_issued_to: '2c515bd215ce444441f83ffd36a9d3d2546', - cloud_id: 'test-id', - deployment_id: 'deployment-id', - }, - headers: { - 'x-kibana-version': '8.7.0', - }, - httpsAgent: expect.objectContaining({ - options: expect.objectContaining({ - rejectUnauthorized: true, - path: null, - noDelay: true, - cert: 'test-certificate', - key: 'test-key', + expect(mockFetch).toHaveBeenNthCalledWith( + 1, + 'https://service.dev/monitors/sync', + expect.objectContaining({ + method: 'PUT', + headers: expect.objectContaining({ + 'x-kibana-version': '8.7.0', + 'content-type': 'application/json', }), - }), - method: 'PUT', - url: 'https://service.dev/monitors/sync', - }); + }) + ); + + const body = JSON.parse(mockFetch.mock.calls[0][1]!.body as string); + expect(body.monitors).toEqual(request1); + expect(body.cloud_id).toBe('test-id'); + expect(body.deployment_id).toBe('deployment-id'); }); it('splits the payload into multiple requests if the payload is too large', async () => { const requests: number[] = []; - const axiosSpy = (axios as jest.MockedFunction).mockImplementation((req: any) => { - requests.push(req.data.monitors.length); - if (req.data.monitors.length > 100) { - // throw 413 error - return Promise.reject({ response: { status: 413 } }); + mockFetch.mockImplementation((_url: any, options: any) => { + const body = JSON.parse(options.body); + requests.push(body.monitors.length); + if (body.monitors.length > 100) { + // throw error with status 413 + const error: any = new Error('Payload too large'); + error.status = 413; + error.responseData = { reason: 'Payload too large', status: 413 }; + return Promise.reject(error); } - return Promise.resolve({} as any); + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({}), + } as Response); }); const apiClient = new ServiceAPIClient( @@ -611,7 +565,7 @@ describe('callAPI', () => { }, }); - expect(axiosSpy).toHaveBeenCalledTimes(7); + expect(mockFetch).toHaveBeenCalledTimes(7); expect(requests).toEqual([250, 125, 125, 63, 62, 63, 62]); }); }); diff --git a/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/service_api_client.ts b/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/service_api_client.ts index 72146995cbe1e..67a99c89e5ad4 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/service_api_client.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/service_api_client.ts @@ -5,8 +5,6 @@ * 2.0. */ -import type { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios'; -import axios from 'axios'; import type { Observable } from 'rxjs'; import { concat, forkJoin, from as rxjsFrom, of } from 'rxjs'; import { catchError, tap } from 'rxjs'; @@ -14,6 +12,7 @@ import * as https from 'https'; import { SslConfig } from '@kbn/server-http-tools'; import type { Logger } from '@kbn/core/server'; import type { LicenseGetLicenseInformation } from '@elastic/elasticsearch/lib/api/types'; +import { Agent } from 'undici'; import type { SyntheticsServerSetup } from '../types'; import type { DataStreamConfig } from './formatters/public_formatters/convert_to_data_stream'; import { convertToDataStreamFormat } from './formatters/public_formatters/convert_to_data_stream'; @@ -55,6 +54,13 @@ export interface ServicePayload { cloud_id?: string; } +interface FetchError extends Error { + status?: number; + responseData?: { reason: string; status: number }; + requestPath?: string; + code?: string; +} + export class ServiceAPIClient { private readonly username?: string; private readonly authorization: string; @@ -81,9 +87,8 @@ export class ServiceAPIClient { this.server = server; } - addVersionHeader(req: AxiosRequestConfig) { - req.headers = { ...req.headers, 'x-kibana-version': this.stackVersion }; - return req; + getHeaders(extraHeaders?: Record): Record { + return { 'x-kibana-version': this.stackVersion, ...extraHeaders }; } async checkAccountAccessStatus() { @@ -98,17 +103,19 @@ export class ServiceAPIClient { /* url is required for service locations, but omitted for private locations. /* this.locations is only service locations */ - const httpsAgent = this.getHttpsAgent(url); + const dispatcher = this.getUndiciDispatcher(url); - if (httpsAgent) { + if (dispatcher) { try { - const { data } = await axios( - this.addVersionHeader({ - method: 'GET', - url: url + '/allowed', - httpsAgent, - }) - ); + const response = await fetch(url + '/allowed', { + method: 'GET', + headers: this.getHeaders(), + ...(dispatcher ? ({ dispatcher } as RequestInit) : {}), + }); + if (!response.ok) { + throw new Error(`Request failed with status ${response.status}`); + } + const data = (await response.json()) as { allowed: boolean; signupUrl: string | null }; const { allowed, signupUrl } = data; return { allowed, signupUrl }; @@ -131,12 +138,14 @@ export class ServiceAPIClient { const parsedTargetUrl = new URL(targetUrl); const rejectUnauthorized = parsedTargetUrl.hostname !== 'localhost' || !this.server.isDev; - const baseHttpsAgent = new https.Agent({ rejectUnauthorized }); + const baseOptions = { rejectUnauthorized }; const config = this.config ?? {}; // If using basic-auth, ignore certificate configs - if (this.authorization) return baseHttpsAgent; + if (this.authorization) { + return new https.Agent(baseOptions); + } if (config.tls && config.tls.certificate && config.tls.key) { const tlsConfig = new SslConfig(config.tls); @@ -152,7 +161,38 @@ export class ServiceAPIClient { ); } - return baseHttpsAgent; + return new https.Agent(baseOptions); + } + + getUndiciDispatcher(targetUrl: string): Agent | undefined { + const parsedTargetUrl = new URL(targetUrl); + + const rejectUnauthorized = parsedTargetUrl.hostname !== 'localhost' || !this.server.isDev; + + const config = this.config ?? {}; + + // If using basic-auth, ignore certificate configs + if (this.authorization) { + return new Agent({ connect: { rejectUnauthorized } }); + } + + if (config.tls && config.tls.certificate && config.tls.key) { + const tlsConfig = new SslConfig(config.tls); + + return new Agent({ + connect: { + rejectUnauthorized, + cert: tlsConfig.certificate as string, + key: tlsConfig.key as string, + }, + }); + } else if (!this.server.isDev) { + this.logger.warn( + 'TLS certificate and key are not provided. Falling back to default HTTPS agent.' + ); + } + + return new Agent({ connect: { rejectUnauthorized } }); } async inspect(data: ServiceData) { @@ -227,8 +267,8 @@ export class ServiceAPIClient { tap((result) => { this.logSuccessMessage(url, method, payload.monitors.length, result); }), - catchError((err: AxiosError<{ reason: string; status: number }>) => { - if (err.response?.status === 413 && payload.monitors.length > 1) { + catchError((err: FetchError) => { + if (err.status === 413 && payload.monitors.length > 1) { // If payload is too large, split it and retry const mid = Math.ceil(payload.monitors.length / 2); const firstHalfMonitors = payload.monitors.slice(0, mid); @@ -250,7 +290,7 @@ export class ServiceAPIClient { ); } - pushErrors.push({ locationId: id, error: err.response?.data! }); + pushErrors.push({ locationId: id, error: err.responseData! }); this.logServiceError(err, url, method, payload.monitors.length); // Return an empty observable to prevent unhandled exceptions @@ -289,16 +329,33 @@ export class ServiceAPIClient { } const authHeader = this.authorization ? { Authorization: this.authorization } : undefined; + const dispatcher = this.getUndiciDispatcher(baseUrl); + + const response = await fetch(url, { + method, + body: JSON.stringify(data), + headers: { + 'content-type': 'application/json', + ...this.getHeaders(authHeader), + }, + ...(dispatcher ? ({ dispatcher } as RequestInit) : {}), + }); - return axios( - this.addVersionHeader({ - method, - url, - data, - headers: authHeader, - httpsAgent: this.getHttpsAgent(baseUrl), - }) - ); + if (!response.ok) { + const errorData = await response.json().catch(() => undefined); + const error: FetchError = new Error(`Request failed with status ${response.status}`); + error.status = response.status; + error.responseData = errorData as { reason: string; status: number }; + error.requestPath = new URL(url).pathname; + throw error; + } + + const responseData = await response.json().catch(() => undefined); + return { + status: response.status, + data: responseData, + request: { path: new URL(url).pathname }, + }; } getRequestData({ monitors, output, isEdit, license }: ServiceData) { @@ -328,7 +385,7 @@ export class ServiceAPIClient { url: string, method: string, numMonitors: number, - result: AxiosResponse | ServicePayload + result: { status?: number; data?: unknown; request?: { path?: string } } | ServicePayload ) { if (this.isLoggable(result)) { if (result.data) { @@ -340,24 +397,19 @@ export class ServiceAPIClient { } } - logServiceError( - err: AxiosError<{ reason: string; status: number }>, - url: string, - method: string, - numMonitors: number - ) { - const reason = err.response?.data?.reason ?? ''; + logServiceError(err: FetchError, url: string, method: string, numMonitors: number) { + const reason = err.responseData?.reason ?? ''; err.message = `Failed to call service location ${url}${ - err.request?.path ?? '' + err.requestPath ?? '' } with method ${method} with ${numMonitors} monitors: ${err.message}, ${reason}`; this.logger.error(err); sendErrorTelemetryEvents(this.logger, this.server.telemetry, { - reason: err.response?.data?.reason, + reason: err.responseData?.reason, message: err.message, type: 'syncError', code: err.code, - status: err.response?.data?.status, + status: err.responseData?.status, url, stackVersion: this.server.stackVersion, }); diff --git a/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/synthetics_service.test.ts b/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/synthetics_service.test.ts index 7c6a7f651d572..49d1562b3337e 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/synthetics_service.test.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/synthetics_service.test.ts @@ -10,8 +10,6 @@ import { coreMock, savedObjectsClientMock } from '@kbn/core/server/mocks'; import type { CoreStart } from '@kbn/core/server'; import { SyntheticsService } from './synthetics_service'; import { loggerMock } from '@kbn/logging-mocks'; -import type { AxiosResponse } from 'axios'; -import axios from 'axios'; import times from 'lodash/times'; import type { HeartbeatConfig } from '../../common/runtime_types'; import { LocationStatus } from '../../common/runtime_types'; @@ -20,7 +18,7 @@ import * as apiKeys from './get_api_key'; import type { SyntheticsServerSetup } from '../types'; import { ALL_SPACES_ID } from '@kbn/spaces-plugin/common/constants'; -jest.mock('axios', () => jest.fn()); +const mockFetch = jest.spyOn(global, 'fetch'); const taskManagerSetup = taskManagerMock.createSetup(); @@ -154,7 +152,7 @@ describe('SyntheticsService', () => { }; beforeEach(() => { - (axios as jest.MockedFunction).mockReset(); + mockFetch.mockReset(); jest.clearAllMocks(); }); @@ -226,16 +224,12 @@ describe('SyntheticsService', () => { const payload = getFakePayload([locations[0]]); - (axios as jest.MockedFunction).mockResolvedValue({} as AxiosResponse); + mockFetch.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) } as Response); await service.addConfigs({ monitor: payload } as any, []); - expect(axios).toHaveBeenCalledTimes(1); - expect(axios).toHaveBeenCalledWith( - expect.objectContaining({ - url: locations[0].url + '/monitors', - }) - ); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledWith(locations[0].url + '/monitors', expect.any(Object)); }); }); @@ -253,11 +247,11 @@ describe('SyntheticsService', () => { serverMock.encryptedSavedObjects = mockEncryptedSO(); - (axios as jest.MockedFunction).mockResolvedValue({} as AxiosResponse); + mockFetch.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) } as Response); await service.pushConfigs(ALL_SPACES_ID); - expect(axios).not.toHaveBeenCalled(); + expect(mockFetch).not.toHaveBeenCalled(); expect(serverMock.logger.error).not.toBeCalledWith( 'API key is not valid. Cannot push monitor configuration to synthetics public testing locations' @@ -276,7 +270,7 @@ describe('SyntheticsService', () => { ], }); - (axios as jest.MockedFunction).mockResolvedValue({} as AxiosResponse); + mockFetch.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) } as Response); await service.pushConfigs(ALL_SPACES_ID); @@ -290,52 +284,43 @@ describe('SyntheticsService', () => { it('includes the isEdit flag on edit requests', async () => { const { service, locations } = getMockedService(); - (axios as jest.MockedFunction).mockResolvedValue({} as AxiosResponse); + mockFetch.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) } as Response); const payload = getFakePayload([locations[0]]); await service.editConfig({ monitor: payload } as any, true, []); - expect(axios).toHaveBeenCalledTimes(1); - expect(axios).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ is_edit: true }), - }) - ); + expect(mockFetch).toHaveBeenCalledTimes(1); + const body1 = JSON.parse(mockFetch.mock.calls[0][1]!.body as string); + expect(body1.is_edit).toBe(true); }); it('includes the license level flag on edit requests', async () => { const { service, locations } = getMockedService(); - (axios as jest.MockedFunction).mockResolvedValue({} as AxiosResponse); + mockFetch.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) } as Response); const payload = getFakePayload([locations[0]]); await service.editConfig({ monitor: payload } as any, true, []); - expect(axios).toHaveBeenCalledTimes(1); - expect(axios).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ license_level: 'platinum' }), - }) - ); + expect(mockFetch).toHaveBeenCalledTimes(1); + const body2 = JSON.parse(mockFetch.mock.calls[0][1]!.body as string); + expect(body2.license_level).toBe('platinum'); }); it('includes the license level flag on add config requests', async () => { const { service, locations } = getMockedService(); - (axios as jest.MockedFunction).mockResolvedValue({} as AxiosResponse); + mockFetch.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) } as Response); const payload = getFakePayload([locations[0]]); await service.addConfigs({ monitor: payload } as any, []); - expect(axios).toHaveBeenCalledTimes(1); - expect(axios).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ license_level: 'platinum' }), - }) - ); + expect(mockFetch).toHaveBeenCalledTimes(1); + const body3 = JSON.parse(mockFetch.mock.calls[0][1]!.body as string); + expect(body3.license_level).toBe('platinum'); }); it('includes the license level flag on push configs requests', async () => { @@ -349,16 +334,13 @@ describe('SyntheticsService', () => { ], }); - (axios as jest.MockedFunction).mockResolvedValue({} as AxiosResponse); + mockFetch.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) } as Response); await service.pushConfigs(ALL_SPACES_ID); - expect(axios).toHaveBeenCalledTimes(1); - expect(axios).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ license_level: 'platinum' }), - }) - ); + expect(mockFetch).toHaveBeenCalledTimes(1); + const body4 = JSON.parse(mockFetch.mock.calls[0][1]!.body as string); + expect(body4.license_level).toBe('platinum'); }); it.each([ @@ -399,7 +381,7 @@ describe('SyntheticsService', () => { }, }); - (axios as jest.MockedFunction).mockResolvedValue({} as AxiosResponse); + mockFetch.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) } as Response); await expect(service.pushConfigs(ALL_SPACES_ID)).rejects.toThrow(errorMessage); } @@ -411,7 +393,7 @@ describe('SyntheticsService', () => { const { service } = getMockedService(); jest.spyOn(service, 'getSyntheticsParams').mockRestore(); - (axios as jest.MockedFunction).mockResolvedValue({} as AxiosResponse); + mockFetch.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) } as Response); serverMock.encryptedSavedObjects = mockEncryptedSO({ params: [ @@ -565,12 +547,12 @@ describe('SyntheticsService', () => { serverMock.encryptedSavedObjects = mockEncryptedSO({ monitors: data }); - (axios as jest.MockedFunction).mockResolvedValue({} as AxiosResponse); + mockFetch.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) } as Response); await service.pushConfigs(ALL_SPACES_ID); expect(syncSpy).toHaveBeenCalledTimes(72); - expect(axios).toHaveBeenCalledTimes(72); + expect(mockFetch).toHaveBeenCalledTimes(72); expect(logger.debug).toHaveBeenCalledTimes(112); expect(logger.info).toHaveBeenCalledTimes(0); expect(logger.error).toHaveBeenCalledTimes(0); diff --git a/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/utils/sanitize_error.test.ts b/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/utils/sanitize_error.test.ts index 99c2d2ef23a95..f957440e37bb6 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/utils/sanitize_error.test.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/utils/sanitize_error.test.ts @@ -5,8 +5,6 @@ * 2.0. */ -import type { AxiosResponse } from 'axios'; -import { AxiosError } from 'axios'; import { getSanitizedError } from './sanitize_error'; describe('getSanitizedError', () => { @@ -23,10 +21,11 @@ describe('getSanitizedError', () => { expect((sanitizedError as any).someConfig).toBeUndefined(); }); - it('should return an object with only safe properties when given an AxiosError object', () => { - const originalError = new AxiosError('Original error message', '500', undefined, undefined, { - status: 500, - } as AxiosResponse); + it('should return an object with only safe properties when given a fetch-like error with response', () => { + const originalError = Object.assign(new Error('Original error message'), { + code: '500', + response: { status: 500 }, + }); originalError.name = 'OriginalError'; (originalError as any).someConfig = 'This should not be included'; diff --git a/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/utils/sanitize_error.ts b/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/utils/sanitize_error.ts index fe06fcaab4211..43225c71da612 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/utils/sanitize_error.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/utils/sanitize_error.ts @@ -5,10 +5,22 @@ * 2.0. */ -import { AxiosError } from 'axios'; +interface FetchErrorLike extends Error { + code?: string; + response?: { + status?: number; + }; + request?: { + status?: number; + }; +} + +function isFetchError(error: Error): error is FetchErrorLike { + return 'response' in error || 'request' in error || 'code' in error; +} -export function getSanitizedError(error: Error | AxiosError) { - if (error instanceof AxiosError) { +export function getSanitizedError(error: Error) { + if (isFetchError(error)) { return { code: error.code, status: error.response?.status || error.request?.status || null, diff --git a/x-pack/solutions/observability/plugins/synthetics/server/telemetry/sender.test.ts b/x-pack/solutions/observability/plugins/synthetics/server/telemetry/sender.test.ts index 2ee19a5a65450..2d0243215225a 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/telemetry/sender.test.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/telemetry/sender.test.ts @@ -9,8 +9,6 @@ import { URL } from 'url'; -import axios from 'axios'; - import type { InfoResponse } from '@elastic/elasticsearch/lib/api/types'; import { loggingSystemMock } from '@kbn/core/server/mocks'; @@ -21,11 +19,7 @@ import { TelemetryEventsSender } from './sender'; import type { LicenseGetResponse } from '@elastic/elasticsearch/lib/api/types'; import { Observable } from 'rxjs'; -jest.mock('axios', () => { - return { - post: jest.fn(), - }; -}); +const mockFetch = jest.spyOn(global, 'fetch'); const licenseMock: LicenseGetResponse = { license: { @@ -77,6 +71,8 @@ describe('TelemetryEventsSender', () => { await sender.start(undefined, { elasticsearch: { client: { asInternalUser: { info: jest.fn(async () => ({})) } } }, } as any); + + mockFetch.mockReset(); }); describe('queueTelemetryEvents', () => { @@ -147,31 +143,46 @@ describe('TelemetryEventsSender', () => { expect(sender['queuesPerChannel']['my-channel2']['queue'].length).toBe(1); + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({}), + } as Response); + await sender['sendIfDue'](); expect(sender['queuesPerChannel']['my-channel']['getEvents']).toBeCalledTimes(1); expect(sender['queuesPerChannel']['my-channel2']['getEvents']).toBeCalledTimes(1); - const requestConfig = { - headers: { - 'Content-Type': 'application/x-ndjson', - 'X-Elastic-Cluster-ID': '1', - 'X-Elastic-Cluster-Name': 'name', - 'X-Elastic-Stack-Version': '8.0.0', - }, - timeout: 5000, - }; + const event1 = { 'event.kind': '1', ...licenseMock }; const event2 = { 'event.kind': '2', ...licenseMock }; const event3 = { 'event.kind': '3', ...licenseMock }; - expect(axios.post).toHaveBeenCalledWith( + + expect(mockFetch).toHaveBeenCalledWith( 'https://telemetry.elastic.co/v3/send/my-channel', - `${JSON.stringify(event1)}\n${JSON.stringify(event2)}\n`, - requestConfig + expect.objectContaining({ + method: 'POST', + body: `${JSON.stringify(event1)}\n${JSON.stringify(event2)}\n`, + headers: expect.objectContaining({ + 'Content-Type': 'application/x-ndjson', + 'X-Elastic-Cluster-ID': '1', + 'X-Elastic-Cluster-Name': 'name', + 'X-Elastic-Stack-Version': '8.0.0', + }), + }) ); - expect(axios.post).toHaveBeenCalledWith( + expect(mockFetch).toHaveBeenCalledWith( 'https://telemetry.elastic.co/v3/send/my-channel2', - `${JSON.stringify(event3)}\n`, - requestConfig + expect.objectContaining({ + method: 'POST', + body: `${JSON.stringify(event3)}\n`, + headers: expect.objectContaining({ + 'Content-Type': 'application/x-ndjson', + 'X-Elastic-Cluster-ID': '1', + 'X-Elastic-Cluster-Name': 'name', + 'X-Elastic-Stack-Version': '8.0.0', + }), + }) ); }); }); diff --git a/x-pack/solutions/observability/plugins/synthetics/server/telemetry/sender.ts b/x-pack/solutions/observability/plugins/synthetics/server/telemetry/sender.ts index 60b2fe84a7586..5745ecefb5ad0 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/telemetry/sender.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/telemetry/sender.ts @@ -11,8 +11,6 @@ import type { TelemetryPluginStart, TelemetryPluginSetup } from '@kbn/telemetry- import { cloneDeep } from 'lodash'; -import axios from 'axios'; - import type { InfoResponse, LicenseGetResponse } from '@elastic/elasticsearch/lib/api/types'; import { TelemetryQueue } from './queue'; @@ -177,22 +175,21 @@ export class TelemetryEventsSender { const ndjson = this.transformDataToNdjson(events); try { - const resp = await axios.post(telemetryUrl, ndjson, { + const resp = await fetch(telemetryUrl, { + method: 'POST', + body: ndjson, headers: { 'Content-Type': 'application/x-ndjson', ...(clusterUuid ? { 'X-Elastic-Cluster-ID': clusterUuid } : undefined), ...(clusterName ? { 'X-Elastic-Cluster-Name': clusterName } : undefined), 'X-Elastic-Stack-Version': clusterVersion?.number ? clusterVersion.number : '8.2.0', }, - timeout: 5000, + signal: AbortSignal.timeout(5000), }); - this.logger.debug( - () => `Events sent!. Response: ${resp.status} ${JSON.stringify(resp.data)}` - ); + const respData = await resp.json().catch(() => undefined); + this.logger.debug(() => `Events sent!. Response: ${resp.status} ${JSON.stringify(respData)}`); } catch (err) { - this.logger.debug( - () => `Error sending events: ${err.response.status} ${JSON.stringify(err.response.data)}` - ); + this.logger.debug(() => `Error sending events: ${err.message}`); } } diff --git a/x-pack/solutions/observability/test/api_integration/profiling/common/create_profiling_users/helpers/abort_error.ts b/x-pack/solutions/observability/test/api_integration/profiling/common/create_profiling_users/helpers/abort_error.ts new file mode 100644 index 0000000000000..9ac15b538834f --- /dev/null +++ b/x-pack/solutions/observability/test/api_integration/profiling/common/create_profiling_users/helpers/abort_error.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export class AbortError extends Error { + constructor(message: string) { + super(message); + } +} diff --git a/x-pack/solutions/observability/test/api_integration/profiling/common/create_profiling_users/helpers/call_kibana.ts b/x-pack/solutions/observability/test/api_integration/profiling/common/create_profiling_users/helpers/call_kibana.ts index fd4b25f4354ce..936a67a989851 100644 --- a/x-pack/solutions/observability/test/api_integration/profiling/common/create_profiling_users/helpers/call_kibana.ts +++ b/x-pack/solutions/observability/test/api_integration/profiling/common/create_profiling_users/helpers/call_kibana.ts @@ -4,10 +4,9 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import type { AxiosRequestConfig, AxiosError } from 'axios'; -import axios from 'axios'; import { once } from 'lodash'; import type { Elasticsearch, Kibana } from '..'; +export { AbortError } from './abort_error'; const stripUrlCredentials = (urlString: string): string => { try { @@ -20,6 +19,14 @@ const stripUrlCredentials = (urlString: string): string => { } }; +export interface CallKibanaOptions { + method?: string; + url?: string; + data?: unknown; + headers?: Record; + validateStatus?: (status: number) => boolean; +} + export async function callKibana({ elasticsearch, kibana, @@ -27,47 +34,63 @@ export async function callKibana({ }: { elasticsearch: Omit; kibana: Kibana; - options: AxiosRequestConfig; + options: CallKibanaOptions; }): Promise { const baseUrl = await getBaseUrl(kibana.hostname); const { username, password } = elasticsearch; const basicAuth = Buffer.from(`${username}:${password}`).toString('base64'); - const { data } = await axios.request({ - ...options, - baseURL: stripUrlCredentials(baseUrl), - allowAbsoluteUrls: false, + const fullUrl = `${stripUrlCredentials(baseUrl)}${options.url ?? ''}`; + + const response = await fetch(fullUrl, { + method: options.method ?? 'GET', headers: { 'kbn-xsrf': 'true', + 'content-type': 'application/json', ...options.headers, Authorization: `Basic ${basicAuth}`, }, + ...(options.data !== undefined + ? { body: typeof options.data === 'string' ? options.data : JSON.stringify(options.data) } + : {}), + redirect: 'manual', }); - return data; + + if (!response.ok) { + const errorText = await response.text().catch(() => ''); + throw new KibanaError( + response.status, + `Request failed with status ${response.status}: ${errorText}` + ); + } + + const data = await response.json(); + return data as T; } const getBaseUrl = once(async (kibanaHostname: string) => { try { - await axios.request({ url: kibanaHostname, maxRedirects: 0 }); - } catch (e) { - if (isAxiosError(e)) { - const location = e.response?.headers?.location ?? ''; + const response = await fetch(kibanaHostname, { redirect: 'manual' }); + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get('location') ?? ''; const hasBasePath = RegExp(/^\/\w{3}$/).test(location); const basePath = hasBasePath ? location : ''; return `${kibanaHostname}${basePath}`; } - - throw e; + } catch (e) { + // If fetch itself throws (network error), just return the hostname } return kibanaHostname; }); -export function isAxiosError(e: AxiosError | Error): e is AxiosError { - return 'isAxiosError' in e; -} - -export class AbortError extends Error { - constructor(message: string) { +export class KibanaError extends Error { + public readonly status: number; + constructor(status: number, message: string) { super(message); + this.status = status; } } + +export function isKibanaError(e: unknown): e is KibanaError { + return e instanceof KibanaError; +} diff --git a/x-pack/solutions/observability/test/api_integration/profiling/common/create_profiling_users/helpers/create_or_update_user.ts b/x-pack/solutions/observability/test/api_integration/profiling/common/create_profiling_users/helpers/create_or_update_user.ts index 1b434776982d1..c136c429fe981 100644 --- a/x-pack/solutions/observability/test/api_integration/profiling/common/create_profiling_users/helpers/create_or_update_user.ts +++ b/x-pack/solutions/observability/test/api_integration/profiling/common/create_profiling_users/helpers/create_or_update_user.ts @@ -10,7 +10,7 @@ import { difference, union } from 'lodash'; import type { SecurityService } from '@kbn/ftr-common-functional-services'; import type { Elasticsearch, Kibana } from '..'; -import { callKibana, isAxiosError } from './call_kibana'; +import { callKibana, isKibanaError } from './call_kibana'; interface User { username: string; @@ -105,7 +105,7 @@ async function getUser({ }); } catch (e) { // return empty if user doesn't exist - if (isAxiosError(e) && e.response?.status === 404) { + if (isKibanaError(e) && e.status === 404) { return null; } diff --git a/x-pack/solutions/security/packages/kbn-securitysolution-utils/src/axios/index.ts b/x-pack/solutions/security/packages/kbn-securitysolution-utils/src/axios/index.ts index 44972dba93565..da4f06e66f7af 100644 --- a/x-pack/solutions/security/packages/kbn-securitysolution-utils/src/axios/index.ts +++ b/x-pack/solutions/security/packages/kbn-securitysolution-utils/src/axios/index.ts @@ -5,7 +5,20 @@ * 2.0. */ -import { AxiosError } from 'axios'; +/* eslint-disable @typescript-eslint/no-explicit-any */ + +/** + * Checks if an error object has HTTP response-like properties (e.g. from KbnClient or axios). + */ +const isHttpRequestError = ( + error: any +): error is Error & { + config?: { method?: string; url?: string; data?: unknown }; + response?: { status?: number; statusText?: string; data?: any }; + status?: number; +} => { + return error instanceof Error && ('response' in error || 'config' in error); +}; export class FormattedAxiosError extends Error { public readonly request: { @@ -16,30 +29,30 @@ export class FormattedAxiosError extends Error { public readonly response: { status: number; statusText: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any data: any; }; - constructor(axiosError: AxiosError) { - const method = axiosError.config?.method ?? ''; - const url = axiosError.config?.url ?? ''; + constructor(httpError: Error & Record) { + const method = httpError.config?.method ?? ''; + const url = httpError.config?.url ?? ''; + const responseData = httpError.response?.data; super( - `${axiosError.message}${ - axiosError?.response?.data ? `: ${JSON.stringify(axiosError?.response?.data)}` : '' - }${url ? `\n(Request: ${method} ${url})` : ''}` + `${httpError.message}${responseData ? `: ${JSON.stringify(responseData)}` : ''}${ + url ? `\n(Request: ${method} ${url})` : '' + }` ); this.request = { method, url, - data: axiosError.config?.data ?? '', + data: httpError.config?.data ?? '', }; this.response = { - status: axiosError?.response?.status ?? 0, - statusText: axiosError?.response?.statusText ?? '', - data: axiosError?.response?.data, + status: httpError.response?.status ?? httpError.status ?? 0, + statusText: httpError.response?.statusText ?? '', + data: responseData, }; this.name = this.constructor.name; @@ -59,11 +72,13 @@ export class FormattedAxiosError extends Error { } /** - * Used with `promise.catch()`, it will format the Axios error to a new error and will re-throw + * Used with `promise.catch()`, it will format the error to a new error and will re-throw. + * If the error has HTTP request/response properties (e.g. from KbnClient), it will be + * formatted as a `FormattedAxiosError` with request and response details. * @param error */ export const catchAxiosErrorFormatAndThrow = (error: Error): never => { - if (error instanceof AxiosError) { + if (isHttpRequestError(error)) { throw new FormattedAxiosError(error); } diff --git a/x-pack/solutions/security/plugins/elastic_assistant/scripts/create_and_login_users.js b/x-pack/solutions/security/plugins/elastic_assistant/scripts/create_and_login_users.js index 5de186b216c5c..ec4ab75449402 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/scripts/create_and_login_users.js +++ b/x-pack/solutions/security/plugins/elastic_assistant/scripts/create_and_login_users.js @@ -8,11 +8,10 @@ /** * Script to create and log in multiple test users in Elasticsearch and Kibana. * - * This script uses Axios to interact with Elasticsearch's security API to create users + * This script uses native fetch to interact with Elasticsearch's security API to create users * and Puppeteer to automate logging into Kibana. It generates random user data using Faker. */ -const axios = require('axios'); const puppeteer = require('puppeteer'); const { faker } = require('@faker-js/faker'); const { SECURITY_FEATURE_ID } = require('../common/constants'); @@ -33,6 +32,9 @@ const elasticAuth = { username: 'elastic', password: 'changeme', }; +const basicAuthHeader = `Basic ${Buffer.from( + `${elasticAuth.username}:${elasticAuth.password}` +).toString('base64')}`; /** * Creates a restricted role that denies access to the Security Assistant. @@ -44,9 +46,12 @@ const createRestrictedRole = async (roleName) => { // First check if the role already exists try { - const checkResponse = await axios.get(url, { - auth: elasticAuth, - headers: { 'Content-Type': 'application/json', 'kbn-xsrf': 'xsrf' }, + const checkResponse = await fetch(url, { + headers: { + Authorization: basicAuthHeader, + 'Content-Type': 'application/json', + 'kbn-xsrf': 'xsrf', + }, }); if (checkResponse.status === 200) { @@ -54,17 +59,19 @@ const createRestrictedRole = async (roleName) => { return; } } catch (err) { - // Role doesn't exist, continue to create it - if (err.response?.status !== 404) { - console.error(`❌ Error checking role ${roleName}:`, err.response?.data || err.message); - return err; - } + // Role doesn't exist or network error, continue to create it + console.error(`❌ Error checking role ${roleName}:`, err.message); } try { - await axios.put( - url, - { + const response = await fetch(url, { + method: 'PUT', + headers: { + Authorization: basicAuthHeader, + 'Content-Type': 'application/json', + 'kbn-xsrf': 'xsrf', + }, + body: JSON.stringify({ description: '', elasticsearch: { cluster: [], @@ -129,15 +136,18 @@ const createRestrictedRole = async (roleName) => { }, }, ], - }, - { - auth: elasticAuth, - headers: { 'Content-Type': 'application/json', 'kbn-xsrf': 'xsrf' }, - } - ); + }), + }); + + if (!response.ok) { + const errorData = await response.text(); + console.error(`❌ Failed to create role ${roleName}:`, errorData); + return new Error(errorData); + } + console.log(`✅ Created restricted role ${roleName}`); } catch (err) { - console.error(`❌ Failed to create role ${roleName}:`, err.response?.data || err.message); + console.error(`❌ Failed to create role ${roleName}:`, err.message); return err; } }; @@ -158,30 +168,37 @@ const createUser = async (username, fullName, restricted = false) => { const url = `${elasticUrl}/_security/user/${username}`; try { - await axios.put( - url, - { + const response = await fetch(url, { + method: 'PUT', + headers: { + Authorization: basicAuthHeader, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ password: 'changeme', roles: restricted ? [restrictedRoleName] : ['superuser'], full_name: fullName, email: `${username}@elastic.co`, - }, - { - auth: elasticAuth, - headers: { 'Content-Type': 'application/json' }, + }), + }); + + if (!response.ok) { + if (response.status === 409) { + console.log(`ℹ️ User ${username} already exists`); + } else { + const errorData = await response.text(); + console.error(`❌ Failed to create ${username}:`, errorData); } - ); + return; + } + console.log( `✅ Created user ${username} (${fullName})${ restricted ? ' with Security Assistant restrictions' : '' }` ); } catch (err) { - if (err.response?.status === 409) { - console.log(`ℹ️ User ${username} already exists`); - } else { - console.error(`❌ Failed to create ${username}:`, err.response?.data || err.message); - } + console.error(`❌ Failed to create ${username}:`, err.message); } }; diff --git a/x-pack/solutions/security/plugins/elastic_assistant/scripts/create_conversations_script.ts b/x-pack/solutions/security/plugins/elastic_assistant/scripts/create_conversations_script.ts index 0a5cc7ec62f12..fa562eb8e2c6f 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/scripts/create_conversations_script.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/scripts/create_conversations_script.ts @@ -10,7 +10,6 @@ import { randomBytes } from 'node:crypto'; import yargs from 'yargs/yargs'; import { ToolingLog } from '@kbn/tooling-log'; import { Client } from '@elastic/elasticsearch'; -import axios from 'axios'; import pLimit from 'p-limit'; import { API_VERSIONS } from '@kbn/elastic-assistant-common'; import type { CreateMessageSchema } from '../server/ai_assistant_data_clients/conversations/types'; @@ -53,9 +52,13 @@ export const create = async () => { try { logger.info(`Fetching available connectors...`); - const { data: connectors } = await axios.get(connectorsApiUrl, { + const connectorsResponse = await fetch(connectorsApiUrl, { headers: requestHeaders, }); + if (!connectorsResponse.ok) { + throw new Error(`Failed to fetch connectors: ${connectorsResponse.status}`); + } + const connectors = await connectorsResponse.json(); const aiConnectors = connectors.filter( ({ connector_type_id: connectorTypeId }: { connector_type_id: string }) => AllowedActionTypeIds.includes(connectorTypeId) diff --git a/x-pack/solutions/security/plugins/security_solution/common/endpoint/data_loaders/index_case.ts b/x-pack/solutions/security/plugins/security_solution/common/endpoint/data_loaders/index_case.ts index 28c8d993fd909..9aaa0831548f9 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/endpoint/data_loaders/index_case.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/endpoint/data_loaders/index_case.ts @@ -8,7 +8,6 @@ import type { KbnClient } from '@kbn/test'; import type { Case, CasePostRequest } from '@kbn/cases-plugin/common'; import { CaseSeverity, CASES_URL, ConnectorTypes } from '@kbn/cases-plugin/common'; -import type { AxiosError } from 'axios'; import { EndpointError } from '../errors'; export interface IndexedCase { @@ -80,13 +79,17 @@ export const deleteIndexedCase = async ( }, }); } catch (_error) { - const error = _error as AxiosError; + const error = _error as Error & { + response?: { status?: number; data?: unknown }; + request?: { method?: string; path?: string }; + status?: number; + }; // ignore 404 (not found) -data has already been deleted - if ((error as AxiosError).response?.status !== 404) { + if ((error.response?.status ?? error.status) !== 404) { const message = `${error.message} Request: - ${error.request.method} ${error.request.path} + ${error.request?.method} ${error.request?.path} Response Body: ${JSON.stringify(error.response?.data ?? {}, 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..19f7c01c14fa0 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,7 +7,6 @@ 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 { BulkRequest, DeleteByQueryResponse } from '@elastic/elasticsearch/lib/api/types'; @@ -312,10 +311,10 @@ export const indexEndpointHostDocs = usageTracker.track( const fetchKibanaVersion = async (kbnClient: KbnClient) => { const version = ( - (await kbnClient.request({ + await kbnClient.request<{ version: { number: string } }>({ path: '/api/status', method: 'GET', - })) as AxiosResponse + }) ).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 c22d840a4a3d0..afb020404ca78 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 @@ -6,7 +6,6 @@ */ import type { KbnClient } from '@kbn/test'; -import type { AxiosResponse } from 'axios'; import type { AgentPolicy, CreateAgentPolicyRequest, @@ -71,11 +70,11 @@ export const indexFleetEndpointPolicy = usageTracker.track( space_ids: spaceIds, }; - let agentPolicy: AxiosResponse; + let agentPolicy: { data: CreateAgentPolicyResponse }; try { - agentPolicy = (await kbnClient - .request({ + agentPolicy = await kbnClient + .request({ path: AGENT_POLICY_API_ROUTES.CREATE_PATTERN, headers: { 'elastic-api-version': API_VERSIONS.public.v1, @@ -83,7 +82,7 @@ export const indexFleetEndpointPolicy = usageTracker.track( method: 'POST', body: newAgentPolicyData, }) - .catch(wrapErrorAndRejectPromise)) as AxiosResponse; + .catch(wrapErrorAndRejectPromise); } catch (error) { throw new Error(`create fleet agent policy failed ${error}`); } @@ -186,8 +185,8 @@ export const deleteIndexedFleetEndpointPolicies = async ( if (indexData.integrationPolicies.length) { response.integrationPolicies = ( - (await kbnClient - .request({ + await kbnClient + .request({ path: PACKAGE_POLICY_API_ROUTES.DELETE_PATTERN, headers: { 'elastic-api-version': API_VERSIONS.public.v1, @@ -197,7 +196,7 @@ export const deleteIndexedFleetEndpointPolicies = async ( packagePolicyIds: indexData.integrationPolicies.map((policy) => policy.id), }, }) - .catch(wrapErrorAndRejectPromise)) as AxiosResponse + .catch(wrapErrorAndRejectPromise) ).data; } @@ -207,8 +206,8 @@ export const deleteIndexedFleetEndpointPolicies = async ( for (const agentPolicy of indexData.agentPolicies) { response.agentPolicies.push( ( - (await kbnClient - .request({ + await kbnClient + .request({ path: AGENT_POLICY_API_ROUTES.DELETE_PATTERN, headers: { 'elastic-api-version': API_VERSIONS.public.v1, @@ -218,7 +217,7 @@ export const deleteIndexedFleetEndpointPolicies = async ( agentPolicyId: agentPolicy.id, }, }) - .catch(wrapErrorAndRejectPromise)) as AxiosResponse + .catch(wrapErrorAndRejectPromise) ).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..7b6c8500e872d 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,7 +5,6 @@ * 2.0. */ -import type { AxiosResponse } from 'axios'; import type { KbnClient } from '@kbn/test'; import type { BulkInstallPackageInfo, @@ -43,13 +42,13 @@ export const setupFleetForEndpoint = usageTracker.track( // Setup Fleet try { - const setupResponse = (await kbnClient - .request({ + const setupResponse = await kbnClient + .request({ path: SETUP_API_ROUTE, headers: { 'Elastic-Api-Version': API_VERSIONS.public.v1 }, method: 'POST', }) - .catch(wrapErrorAndRejectPromise)) as AxiosResponse; + .catch(wrapErrorAndRejectPromise); if (!setupResponse.data.isInitialized) { log.error(new Error(JSON.stringify(setupResponse.data, null, 2))); @@ -62,15 +61,15 @@ export const setupFleetForEndpoint = usageTracker.track( // Setup Agents try { - const setupResponse = (await kbnClient - .request({ + const setupResponse = await kbnClient + .request({ path: AGENTS_SETUP_API_ROUTES.CREATE_PATTERN, method: 'POST', headers: { 'elastic-api-version': API_VERSIONS.public.v1, }, }) - .catch(wrapErrorAndRejectPromise)) as AxiosResponse; + .catch(wrapErrorAndRejectPromise); if (!setupResponse.data.isInitialized) { log.error(new Error(JSON.stringify(setupResponse, null, 2))); @@ -103,8 +102,8 @@ export const installOrUpgradeEndpointFleetPackage = usageTracker.track( logger.debug(`installOrUpgradeEndpointFleetPackage(): starting`); const updatePackages = async () => { - const installEndpointPackageResp = (await kbnClient - .request({ + const installEndpointPackageResp = await kbnClient + .request({ path: EPM_API_ROUTES.BULK_INSTALL_PATTERN, method: 'POST', body: { @@ -117,7 +116,7 @@ export const installOrUpgradeEndpointFleetPackage = usageTracker.track( 'elastic-api-version': API_VERSIONS.public.v1, }, }) - .catch(wrapErrorAndRejectPromise)) as AxiosResponse; + .catch(wrapErrorAndRejectPromise); logger.debug(`Fleet bulk install response:`, installEndpointPackageResp.data); diff --git a/x-pack/solutions/security/plugins/security_solution/common/endpoint/format_axios_error.ts b/x-pack/solutions/security/plugins/security_solution/common/endpoint/format_axios_error.ts index 9673d222bd7b9..e51380a86f0b5 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/endpoint/format_axios_error.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/endpoint/format_axios_error.ts @@ -5,11 +5,23 @@ * 2.0. */ -import { AxiosError } from 'axios'; import { EndpointError } from './errors'; /* eslint-disable @typescript-eslint/no-explicit-any */ +/** + * Checks if an error object has HTTP response-like properties (e.g. from KbnClient or axios). + */ +const isHttpRequestError = ( + error: any +): error is Error & { + config?: { method?: string; url?: string; data?: unknown }; + response?: { status?: number; statusText?: string; data?: any }; + status?: number; +} => { + return error instanceof Error && ('response' in error || 'config' in error); +}; + export class FormattedAxiosError extends EndpointError { public readonly request: { method: string; @@ -22,27 +34,28 @@ export class FormattedAxiosError extends EndpointError { data: any; }; - constructor(axiosError: AxiosError) { - const method = axiosError.config?.method ?? ''; - const url = axiosError.config?.url ?? ''; + constructor(httpError: Error & Record) { + const method = httpError.config?.method ?? ''; + const url = httpError.config?.url ?? ''; + const responseData = httpError.response?.data; super( - `${axiosError.message}${ - axiosError?.response?.data ? `: ${JSON.stringify(axiosError?.response?.data)}` : '' - }${url ? `\n(Request: ${method} ${url})` : ''}`, - axiosError + `${httpError.message}${responseData ? `: ${JSON.stringify(responseData)}` : ''}${ + url ? `\n(Request: ${method} ${url})` : '' + }`, + httpError ); this.request = { method, url, - data: axiosError.config?.data ?? '', + data: httpError.config?.data ?? '', }; this.response = { - status: axiosError?.response?.status ?? 0, - statusText: axiosError?.response?.statusText ?? '', - data: axiosError?.response?.data, + status: httpError.response?.status ?? httpError.status ?? 0, + statusText: httpError.response?.statusText ?? '', + data: responseData, }; this.name = this.constructor.name; @@ -62,11 +75,13 @@ export class FormattedAxiosError extends EndpointError { } /** - * Used with `promise.catch()`, it will format the Axios error to a new error and will re-throw + * Used with `promise.catch()`, it will format the error to a new error and will re-throw. + * If the error has HTTP request/response properties (e.g. from KbnClient), it will be + * formatted as a `FormattedAxiosError` with request and response details. * @param error */ export const catchAxiosErrorFormatAndThrow = (error: Error): never => { - if (error instanceof AxiosError) { + if (isHttpRequestError(error)) { throw new FormattedAxiosError(error); } 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..e1435095d1660 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,8 +5,6 @@ * 2.0. */ -import type { AxiosResponse } from 'axios'; - import type { KbnClient } from '@kbn/test'; import type { GetInfoResponse } from '@kbn/fleet-plugin/common'; import { API_VERSIONS, epmRouteService } from '@kbn/fleet-plugin/common'; @@ -17,11 +15,11 @@ export const getEndpointPackageInfo = usageTracker.track( async (kbnClient: KbnClient): Promise => { const path = epmRouteService.getInfoPath('endpoint'); const endpointPackage = ( - (await kbnClient.request({ + await kbnClient.request({ path, headers: { 'Elastic-Api-Version': API_VERSIONS.public.v1 }, method: 'GET', - })) as AxiosResponse + }) ).data.item; if (!endpointPackage) { diff --git a/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/blocklists/index.ts b/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/blocklists/index.ts index 856b4d71c4c28..8bada0c421756 100644 --- a/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/blocklists/index.ts +++ b/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/blocklists/index.ts @@ -9,7 +9,6 @@ import type { RunFn } from '@kbn/dev-cli-runner'; import { run } from '@kbn/dev-cli-runner'; import { createFailError } from '@kbn/dev-cli-errors'; import { KbnClient } from '@kbn/test'; -import type { AxiosError } from 'axios'; import pMap from 'p-map'; import type { CreateExceptionListSchema } from '@kbn/securitysolution-io-ts-list-types'; import { @@ -55,15 +54,18 @@ class BlocklistDataLoaderError extends Error { } } -const handleThrowAxiosHttpError = (err: AxiosError<{ message?: string }>): never => { +const handleThrowAxiosHttpError = (err: Error & Record): never => { let message = err.message; + const response = err.response as + | { status?: number; data?: { message?: string }; config?: { method?: string; url?: string } } + | undefined; - if (err.response) { - message = `[${err.response.status}] ${err.response.data.message ?? err.message} [ ${String( - err.response.config.method - ).toUpperCase()} ${err.response.config.url} ]`; + if (response) { + message = `[${response.status}] ${response.data?.message ?? err.message} [ ${String( + response.config?.method + ).toUpperCase()} ${response.config?.url} ]`; } - throw new BlocklistDataLoaderError(message, err.toJSON()); + throw new BlocklistDataLoaderError(message, err); }; const createBlocklists: RunFn = async ({ flags, log }) => { 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..54cab7e22e151 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, @@ -745,23 +744,20 @@ export const isFleetServerRunning = async ( const url = new URL(fleetServerUrl); url.pathname = '/api/status'; + const dispatcher = new Agent({ connect: { rejectUnauthorized: false } }); + 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.toString(), { + method: 'GET', + // @ts-expect-error dispatcher is a valid undici option for fetch + dispatcher, + }); + if (!response.ok) { + throw new Error(`Fleet server returned status ${response.status}`); + } + const data = await response.json(); + log.debug(`Fleet server is up and running at [${fleetServerUrl}]. Status: `, data); }, { maxTimeout: 10000, diff --git a/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/common/fleet_services.ts b/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/common/fleet_services.ts index 441fb1ffb0ad2..8753e7fc41ccc 100644 --- a/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/common/fleet_services.ts +++ b/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/common/fleet_services.ts @@ -68,7 +68,6 @@ import type { FleetServerAgent, } from '@kbn/fleet-plugin/common/types'; import semver from 'semver'; -import axios from 'axios'; import { userInfo } from 'os'; import pRetry from 'p-retry'; import { getPolicyDataForUpdate } from '../../../common/endpoint/service/policy'; @@ -534,15 +533,15 @@ export const getAgentVersionMatchingCurrentStack = async ( const agentVersions = await pRetry( async () => { - return axios - .get('https://artifacts-api.elastic.co/v1/versions') - .catch(catchAxiosErrorFormatAndThrow) - .then((response) => - map( - response.data.versions.filter(isValidArtifactVersion), - (version) => version.split('-SNAPSHOT')[0] - ) - ); + const response = await fetch('https://artifacts-api.elastic.co/v1/versions'); + if (!response.ok) { + throw new Error(`Failed to fetch versions: ${response.status}`); + } + const data = await response.json(); + return map( + data.versions.filter(isValidArtifactVersion), + (version: string) => version.split('-SNAPSHOT')[0] + ); }, { maxTimeout: 10000 } ); @@ -623,12 +622,11 @@ export const getAgentDownloadUrl = async ( const searchResult: ElasticArtifactSearchResponse = await pRetry( async () => { - return axios - .get(artifactSearchUrl) - .catch(catchAxiosErrorFormatAndThrow) - .then((response) => { - return response.data; - }); + const response = await fetch(artifactSearchUrl); + if (!response.ok) { + throw new Error(`Failed to fetch artifact: ${response.status}`); + } + return (await response.json()) as ElasticArtifactSearchResponse; }, { maxTimeout: 10000 } ); @@ -661,12 +659,11 @@ export const getLatestAgentDownloadVersion = async ( const semverMatch = `<=${version.replace(`-SNAPSHOT`, '')}`; const artifactVersionsResponse: { versions: string[] } = await pRetry( async () => { - return axios - .get<{ versions: string[] }>(artifactsUrl) - .catch(catchAxiosErrorFormatAndThrow) - .then((response) => { - return response.data; - }); + const response = await fetch(artifactsUrl); + if (!response.ok) { + throw new Error(`Failed to fetch versions: ${response.status}`); + } + return (await response.json()) as { versions: string[] }; }, { 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..707ed1ed45961 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 @@ -7,7 +7,6 @@ import type { ToolingLog } from '@kbn/tooling-log'; import type { KbnClient } from '@kbn/test'; -import type { AxiosResponse } from 'axios'; import { PACKAGE_POLICY_API_ROUTES, PACKAGE_POLICY_SAVED_OBJECT_TYPE, @@ -18,9 +17,7 @@ import { setupFleetForEndpoint } from '../../../common/endpoint/data_loaders/set import type { GetPolicyListResponse } from '../../../public/management/pages/policy/types'; import { getEndpointPackageInfo } from '../../../common/endpoint/utils/package'; -const fetchEndpointPolicies = ( - kbnClient: KbnClient -): Promise> => { +const fetchEndpointPolicies = (kbnClient: KbnClient): Promise<{ data: GetPolicyListResponse }> => { 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..c5666cacec267 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 @@ -11,7 +11,6 @@ import type { KbnClient } from '@kbn/test'; 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 +18,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: Error & { response?: { status?: number }; status?: number }) => { + if ((error.response?.status ?? 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..35fead476e870 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'; @@ -37,8 +36,8 @@ export const ensureSpaceIdExists = async ( log.debug(`Space id [${spaceId}] already exists. Nothing to do.`); return true; }) - .catch((err) => { - if (err instanceof AxiosError && (err.response?.status ?? err.status) === 404) { + .catch((err: Error & { response?: { status?: number }; status?: number }) => { + if ((err.response?.status ?? 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..6d658023d1939 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, ReqOptions, KbnClientResponse } 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/plugins/security_solution/scripts/endpoint/event_filters/index.ts b/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/event_filters/index.ts index e929c24d4ad73..8e3fceaab05e1 100644 --- a/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/event_filters/index.ts +++ b/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/event_filters/index.ts @@ -9,7 +9,6 @@ import type { RunFn } from '@kbn/dev-cli-runner'; import { run } from '@kbn/dev-cli-runner'; import { createFailError } from '@kbn/dev-cli-errors'; import { KbnClient } from '@kbn/test'; -import type { AxiosError } from 'axios'; import pMap from 'p-map'; import type { CreateExceptionListItemSchema, @@ -59,15 +58,18 @@ class EventFilterDataLoaderError extends Error { } } -const handleThrowAxiosHttpError = (err: AxiosError<{ message?: string }>): never => { +const handleThrowAxiosHttpError = (err: Error & Record): never => { let message = err.message; + const response = err.response as + | { status?: number; data?: { message?: string }; config?: { method?: string; url?: string } } + | undefined; - if (err.response) { - message = `[${err.response.status}] ${err.response.data.message ?? err.message} [ ${String( - err.response.config.method - ).toUpperCase()} ${err.response.config.url} ]`; + if (response) { + message = `[${response.status}] ${response.data?.message ?? err.message} [ ${String( + response.config?.method + ).toUpperCase()} ${response.config?.url} ]`; } - throw new EventFilterDataLoaderError(message, err.toJSON()); + throw new EventFilterDataLoaderError(message, err); }; const createEventFilters: RunFn = async ({ flags, log }) => { diff --git a/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/host_isolation_exceptions/index.ts b/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/host_isolation_exceptions/index.ts index db87f3f81c417..7acc89c3a1f1b 100644 --- a/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/host_isolation_exceptions/index.ts +++ b/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/host_isolation_exceptions/index.ts @@ -15,7 +15,6 @@ import { EXCEPTION_LIST_URL, } from '@kbn/securitysolution-list-constants'; import { KbnClient } from '@kbn/test'; -import type { AxiosError } from 'axios'; import { HostIsolationExceptionGenerator } from '../../../common/endpoint/data_generators/host_isolation_exception_generator'; import { randomPolicyIdGenerator } from '../common/random_policy_id_generator'; @@ -53,15 +52,18 @@ class HostIsolationExceptionDataLoaderError extends Error { } } -const handleThrowAxiosHttpError = (err: AxiosError<{ message?: string }>): never => { +const handleThrowAxiosHttpError = (err: Error & Record): never => { let message = err.message; + const response = err.response as + | { status?: number; data?: { message?: string }; config?: { method?: string; url?: string } } + | undefined; - if (err.response) { - message = `[${err.response.status}] ${err.response.data.message ?? err.message} [ ${String( - err.response.config.method - ).toUpperCase()} ${err.response.config.url} ]`; + if (response) { + message = `[${response.status}] ${response.data?.message ?? err.message} [ ${String( + response.config?.method + ).toUpperCase()} ${response.config?.url} ]`; } - throw new HostIsolationExceptionDataLoaderError(message, err.toJSON()); + throw new HostIsolationExceptionDataLoaderError(message, err); }; const createHostIsolationException: RunFn = async ({ flags, log }) => { diff --git a/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/sentinelone_host/common.ts b/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/sentinelone_host/common.ts index 634c69db8f0d8..a72c056f49fd9 100644 --- a/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/sentinelone_host/common.ts +++ b/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/sentinelone_host/common.ts @@ -6,8 +6,6 @@ */ import type { ToolingLog } from '@kbn/tooling-log'; -import type { AxiosRequestConfig } from 'axios'; -import axios from 'axios'; import type { KbnClient } from '@kbn/test'; import { CONNECTOR_ID as SENTINELONE_CONNECTOR_ID } from '@kbn/connector-schemas/sentinelone/constants'; import pRetry from 'p-retry'; @@ -20,7 +18,7 @@ import type { S1AgentPackage, S1AgentPackageListApiResponse, } from './types'; -import { catchAxiosErrorFormatAndThrow } from '../../../common/endpoint/format_axios_error'; + import type { HostVm } from '../common/types'; import { createConnector, fetchConnectorByType } from '../common/connectors_services'; import { createRule, findRules } from '../common/detection_rules_services'; @@ -72,30 +70,32 @@ export class S1Client { protected async request({ url = '', params = {}, - ...options - }: AxiosRequestConfig): Promise { - const apiFullUrl = this.buildUrl(url); - - const requestOptions: AxiosRequestConfig = { - ...options, - url: apiFullUrl, - params: { - APIToken: this.options.apiToken, - ...params, - }, + }: { + url?: string; + params?: Record; + }): Promise { + const apiFullUrl = new URL(this.buildUrl(url)); + const allParams = { + APIToken: this.options.apiToken, + ...params, }; + for (const [key, value] of Object.entries(allParams)) { + if (value !== undefined) { + apiFullUrl.searchParams.set(key, String(value)); + } + } - this.log.debug(`Request: `, requestOptions); + this.log.debug(`Request: `, { url: apiFullUrl.toString() }); return pRetry( async () => { - return axios - .request(requestOptions) - .then((response) => { - this.log.verbose(`Response: `, response); - return response.data; - }) - .catch(catchAxiosErrorFormatAndThrow); + const response = await fetch(apiFullUrl.toString()); + if (!response.ok) { + throw new Error(`Request failed with status ${response.status}`); + } + const data = (await response.json()) as T; + this.log.verbose(`Response: `, data); + return data; }, { maxTimeout: 10000 } ); diff --git a/x-pack/solutions/security/plugins/security_solution/scripts/run_cypress/parallel_serverless.ts b/x-pack/solutions/security/plugins/security_solution/scripts/run_cypress/parallel_serverless.ts index e7eacea62e067..63a2969ae3ee6 100644 --- a/x-pack/solutions/security/plugins/security_solution/scripts/run_cypress/parallel_serverless.ts +++ b/x-pack/solutions/security/plugins/security_solution/scripts/run_cypress/parallel_serverless.ts @@ -18,14 +18,12 @@ import crypto from 'crypto'; import fs from 'fs'; import { exec } from 'child_process'; import { createFailError } from '@kbn/dev-cli-errors'; -import axios, { AxiosError } from 'axios'; import path from 'path'; import os from 'os'; import pRetry from 'p-retry'; import { ELASTIC_HTTP_VERSION_HEADER } from '@kbn/core-http-common'; import { INITIAL_REST_VERSION } from '@kbn/data-views-plugin/server/constants'; -import { catchAxiosErrorFormatAndThrow } from '../../common/endpoint/format_axios_error'; import { createToolingLogger } from '../../common/endpoint/data_loaders/utils'; import { renderSummaryTable } from './print_run'; import { @@ -60,7 +58,7 @@ let log: ToolingLog = new ToolingLog({ const API_HEADERS = Object.freeze({ 'kbn-xsrf': 'cypress-creds', 'x-elastic-internal-origin': 'security-solution', - [ELASTIC_HTTP_VERSION_HEADER]: [INITIAL_REST_VERSION], + [ELASTIC_HTTP_VERSION_HEADER]: INITIAL_REST_VERSION, }); const PROVIDERS = Object.freeze({ providerType: 'basic', @@ -83,15 +81,13 @@ export function proxyHealthcheck(proxyUrl: string): Promise { const fetchHealthcheck = async (attemptNum: number) => { log.info(`Retry number ${attemptNum} to check if Elasticsearch is green.`); - const response = await axios.get(`${proxyUrl}/healthcheck`); + const response = await fetch(`${proxyUrl}/healthcheck`); log.info(`The proxy service is available.`); return response.status === 200; }; const retryOptions = { - onFailedAttempt: (error: Error | AxiosError) => { - if (error instanceof AxiosError) { - log.info(`The proxy service is not available. A retry will be triggered soon...`); - } + onFailedAttempt: (error: Error) => { + log.info(`The proxy service is not available. A retry will be triggered soon...`); }, retries: 4, factor: 2, @@ -110,19 +106,22 @@ export function waitForEsStatusGreen( const fetchHealthStatusAttempt = async (attemptNum: number) => { log.info(`Retry number ${attemptNum} to check if Elasticsearch is green.`); - const response = await axios - .get(`${esUrl}/_cluster/health?wait_for_status=green&timeout=50s`, { - headers: { - Authorization: `Basic ${auth}`, - }, - }) - .catch(catchAxiosErrorFormatAndThrow); + const response = await fetch(`${esUrl}/_cluster/health?wait_for_status=green&timeout=50s`, { + headers: { + Authorization: `Basic ${auth}`, + }, + }); + + if (!response.ok) { + throw new Error(`ES health check failed: ${response.status} ${response.statusText}`); + } - log.info(`${projectId}: Elasticsearch is ready with status ${response.data.status}.`); + const data = await response.json(); + log.info(`${projectId}: Elasticsearch is ready with status ${data.status}.`); }; const retryOptions = { - onFailedAttempt: (error: Error | AxiosError) => { - if (error instanceof AxiosError && error.code === 'ENOTFOUND') { + onFailedAttempt: (error: Error & { code?: string }) => { + if (error.code === 'ENOTFOUND') { log.info( `${projectId}: The Elasticsearch URL is not yet reachable. A retry will be triggered soon...` ); @@ -144,22 +143,26 @@ export function waitForKibanaAvailable( ): Promise { const fetchKibanaStatusAttempt = async (attemptNum: number) => { log.info(`Retry number ${attemptNum} to check if kibana is available.`); - const response = await axios - .get(`${kbUrl}/api/status`, { - headers: { - Authorization: `Basic ${auth}`, - }, - }) - .catch(catchAxiosErrorFormatAndThrow); - if (response.data.status.overall.level !== 'available') { + const response = await fetch(`${kbUrl}/api/status`, { + headers: { + Authorization: `Basic ${auth}`, + }, + }); + + if (!response.ok) { + throw new Error(`Kibana status check failed: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + if (data.status.overall.level !== 'available') { throw new Error(`${projectId}: Kibana is not available. A retry will be triggered soon...`); } else { - log.info(`${projectId}: Kibana status overall is ${response.data.status.overall.level}.`); + log.info(`${projectId}: Kibana status overall is ${data.status.overall.level}.`); } }; const retryOptions = { - onFailedAttempt: (error: Error | AxiosError) => { - if (error instanceof AxiosError && error.code === 'ENOTFOUND') { + onFailedAttempt: (error: Error & { code?: string }) => { + if (error.code === 'ENOTFOUND') { log.info( `${projectId}: The Kibana URL is not yet reachable. A retry will be triggered soon...` ); @@ -179,17 +182,19 @@ export function waitForEsAccess(esUrl: string, auth: string, projectId: string): const fetchEsAccessAttempt = async (attemptNum: number) => { log.info(`Retry number ${attemptNum} to check if can be accessed.`); - await axios - .get(`${esUrl}`, { - headers: { - Authorization: `Basic ${auth}`, - }, - }) - .catch(catchAxiosErrorFormatAndThrow); + const response = await fetch(`${esUrl}`, { + headers: { + Authorization: `Basic ${auth}`, + }, + }); + + if (!response.ok) { + throw new Error(`ES access check failed: ${response.status} ${response.statusText}`); + } }; const retryOptions = { - onFailedAttempt: (error: Error | AxiosError) => { - if (error instanceof AxiosError && error.code === 'ENOTFOUND') { + onFailedAttempt: (error: Error & { code?: string }) => { + if (error.code === 'ENOTFOUND') { log.info( `${projectId}: The elasticsearch url is not yet reachable. A retry will be triggered soon...` ); @@ -213,15 +218,22 @@ function waitForKibanaLogin(kbUrl: string, credentials: Credentials): Promise { log.info(`Retry number ${attemptNum} to check if login can be performed.`); - axios - .post(`${kbUrl}/internal/security/login`, body, { - headers: API_HEADERS, - }) - .catch(catchAxiosErrorFormatAndThrow); + const response = await fetch(`${kbUrl}/internal/security/login`, { + method: 'POST', + headers: { + ...API_HEADERS, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + throw new Error(`Login failed: ${response.status} ${response.statusText}`); + } }; const retryOptions = { - onFailedAttempt: (error: Error | AxiosError) => { - if (error instanceof AxiosError && error.code === 'ENOTFOUND') { + onFailedAttempt: (error: Error & { code?: string }) => { + if (error.code === 'ENOTFOUND') { log.info('Project is not reachable. A retry will be triggered soon...'); } else { log.error(`${error.message}`); diff --git a/x-pack/solutions/security/plugins/security_solution/scripts/run_cypress/project_handler/cloud_project_handler.ts b/x-pack/solutions/security/plugins/security_solution/scripts/run_cypress/project_handler/cloud_project_handler.ts index c2f0bb251ec3c..e644eec8b9879 100644 --- a/x-pack/solutions/security/plugins/security_solution/scripts/run_cypress/project_handler/cloud_project_handler.ts +++ b/x-pack/solutions/security/plugins/security_solution/scripts/run_cypress/project_handler/cloud_project_handler.ts @@ -5,7 +5,6 @@ * 2.0. */ -import axios, { AxiosError } from 'axios'; import pRetry from 'p-retry'; import type { ProductType, @@ -76,48 +75,57 @@ export class CloudHandler extends ProjectHandler { } try { - const response = await axios.post( - `${this.baseEnvUrl}/api/v1/serverless/projects/security`, - body, - { - headers: { - Authorization: `ApiKey ${this.apiKey}`, - }, - } - ); + const response = await fetch(`${this.baseEnvUrl}/api/v1/serverless/projects/security`, { + method: 'POST', + headers: { + Authorization: `ApiKey ${this.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorData = await response.text(); + this.log.error(`${response.status}:${errorData}`); + return undefined; + } + + const data = await response.json(); return { - name: response.data.name, - id: response.data.id, - region: response.data.region_id, - es_url: `${response.data.endpoints.elasticsearch}:443`, - kb_url: `${response.data.endpoints.kibana}:443`, - product: response.data.type, + name: data.name, + id: data.id, + region: data.region_id, + es_url: `${data.endpoints.elasticsearch}:443`, + kb_url: `${data.endpoints.kibana}:443`, + product: data.type, }; } catch (error) { - if (error instanceof AxiosError) { - const errorData = JSON.stringify(error.response?.data); - this.log.error(`${error.response?.status}:${errorData}`); - } else { - this.log.error(`${error.message}`); - } + this.log.error(`${error.message}`); } } // Method to invoke the delete project API for serverless. async deleteSecurityProject(projectId: string, projectName: string): Promise { try { - await axios.delete(`${this.baseEnvUrl}/api/v1/serverless/projects/security/${projectId}`, { - headers: { - Authorization: `ApiKey ${this.apiKey}`, - }, - }); + const response = await fetch( + `${this.baseEnvUrl}/api/v1/serverless/projects/security/${projectId}`, + { + method: 'DELETE', + headers: { + Authorization: `ApiKey ${this.apiKey}`, + }, + } + ); + + if (!response.ok) { + const errorData = await response.text(); + this.log.error(`${response.status}:${errorData}`); + return; + } + this.log.info(`Project ${projectName} was successfully deleted!`); } catch (error) { - if (error instanceof AxiosError) { - this.log.error(`${error.response?.status}:${error.response?.data}`); - } else { - this.log.error(`${error.message}`); - } + this.log.error(`${error.message}`); } } @@ -126,25 +134,33 @@ export class CloudHandler extends ProjectHandler { this.log.info(`${projectId} : Reseting credentials`); const fetchResetCredentialsStatusAttempt = async (attemptNum: number) => { - const response = await axios.post( + const response = await fetch( `${this.baseEnvUrl}/api/v1/serverless/projects/security/${projectId}/_reset-internal-credentials`, - {}, { + method: 'POST', headers: { Authorization: `ApiKey ${this.apiKey}`, + 'Content-Type': 'application/json', }, + body: JSON.stringify({}), } ); + + if (!response.ok) { + throw new Error(`Failed to reset credentials: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); this.log.info('Credentials have been reset'); return { - password: response.data.password, - username: response.data.username, + password: data.password, + username: data.username, }; }; const retryOptions = { - onFailedAttempt: (error: Error | AxiosError) => { - if (error instanceof AxiosError && error.code === 'ENOTFOUND') { + onFailedAttempt: (error: Error & { code?: string }) => { + if (error.code === 'ENOTFOUND') { this.log.info('Project is not reachable. A retry will be triggered soon..'); } else { this.log.error(`${error.message}`); @@ -162,7 +178,7 @@ export class CloudHandler extends ProjectHandler { waitForProjectInitialized(projectId: string): Promise { const fetchProjectStatusAttempt = async (attemptNum: number) => { this.log.info(`Retry number ${attemptNum} to check if project is initialized.`); - const response = await axios.get( + const response = await fetch( `${this.baseEnvUrl}/api/v1/serverless/projects/security/${projectId}/status`, { headers: { @@ -170,16 +186,22 @@ export class CloudHandler extends ProjectHandler { }, } ); - if (response.data.phase !== 'initialized') { - this.log.info(response.data); + + if (!response.ok) { + throw new Error(`Failed to get project status: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + if (data.phase !== 'initialized') { + this.log.info(data); throw new Error('Project is not initialized. A retry will be triggered soon...'); } else { this.log.info('Project is initialized'); } }; const retryOptions = { - onFailedAttempt: (error: Error | AxiosError) => { - if (error instanceof AxiosError && error.code === 'ENOTFOUND') { + onFailedAttempt: (error: Error & { code?: string }) => { + if (error.code === 'ENOTFOUND') { this.log.info('Project is not reachable. A retry will be triggered soon...'); } else { this.log.warning(`${error.message}`); diff --git a/x-pack/solutions/security/plugins/security_solution/scripts/run_cypress/project_handler/proxy_project_handler.ts b/x-pack/solutions/security/plugins/security_solution/scripts/run_cypress/project_handler/proxy_project_handler.ts index 693a3cb08415e..610868280458c 100644 --- a/x-pack/solutions/security/plugins/security_solution/scripts/run_cypress/project_handler/proxy_project_handler.ts +++ b/x-pack/solutions/security/plugins/security_solution/scripts/run_cypress/project_handler/proxy_project_handler.ts @@ -5,7 +5,6 @@ * 2.0. */ -import axios, { AxiosError } from 'axios'; import pRetry from 'p-retry'; import type { ProductType, @@ -76,47 +75,57 @@ export class ProxyHandler extends ProjectHandler { } try { - const response = await axios.post(`${this.baseEnvUrl}/projects`, body, { + const response = await fetch(`${this.baseEnvUrl}/projects`, { + method: 'POST', headers: { Authorization: `Basic ${this.proxyAuth}`, + 'Content-Type': 'application/json', }, + body: JSON.stringify(body), }); + + if (!response.ok) { + const errorData = await response.text(); + this.log.error(`${response.status}:${errorData}`); + return undefined; + } + + const data = await response.json(); return { - name: response.data.name, - id: response.data.project_id, - region: response.data.region_id, - es_url: `${response.data.elasticsearch_endpoint}:443`, - kb_url: `${response.data.kibana_endpoint}:443`, - product: response.data.project_type, - proxy_id: response.data.id, - proxy_org_id: response.data.organization_id, - proxy_org_name: response.data.organization_name, + name: data.name, + id: data.project_id, + region: data.region_id, + es_url: `${data.elasticsearch_endpoint}:443`, + kb_url: `${data.kibana_endpoint}:443`, + product: data.project_type, + proxy_id: data.id, + proxy_org_id: data.organization_id, + proxy_org_name: data.organization_name, }; } catch (error) { - if (error instanceof AxiosError) { - const errorData = JSON.stringify(error.response?.data); - this.log.error(`${error.response?.status}:${errorData}`); - } else { - this.log.error(`${error.message}`); - } + this.log.error(`${error.message}`); } } // Method to invoke the delete project API for serverless. async deleteSecurityProject(projectId: string, projectName: string): Promise { try { - await axios.delete(`${this.baseEnvUrl}/projects/${projectId}`, { + const response = await fetch(`${this.baseEnvUrl}/projects/${projectId}`, { + method: 'DELETE', headers: { Authorization: `Basic ${this.proxyAuth}`, }, }); + + if (!response.ok) { + const errorData = await response.text(); + this.log.error(`${response.status}:${errorData}`); + return; + } + this.log.info(`Project ${projectName} was successfully deleted!`); } catch (error) { - if (error instanceof AxiosError) { - this.log.error(`${error.response?.status}:${error.response?.data}`); - } else { - this.log.error(`${error.message}`); - } + this.log.error(`${error.message}`); } } @@ -125,25 +134,33 @@ export class ProxyHandler extends ProjectHandler { this.log.info(`${projectId} : Reseting credentials`); const fetchResetCredentialsStatusAttempt = async (attemptNum: number) => { - const response = await axios.post( + const response = await fetch( `${this.baseEnvUrl}/projects/${projectId}/_reset-internal-credentials`, - {}, { + method: 'POST', headers: { Authorization: `Basic ${this.proxyAuth}`, + 'Content-Type': 'application/json', }, + body: JSON.stringify({}), } ); + + if (!response.ok) { + throw new Error(`Failed to reset credentials: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); this.log.info('Credentials have been reset'); return { - password: response.data.password, - username: response.data.username, + password: data.password, + username: data.username, }; }; const retryOptions = { - onFailedAttempt: (error: Error | AxiosError) => { - if (error instanceof AxiosError && error.code === 'ENOTFOUND') { + onFailedAttempt: (error: Error & { code?: string }) => { + if (error.code === 'ENOTFOUND') { this.log.info('Project is not reachable. A retry will be triggered soon..'); } else { this.log.error(`${error.message}`); @@ -161,21 +178,27 @@ export class ProxyHandler extends ProjectHandler { waitForProjectInitialized(projectId: string): Promise { const fetchProjectStatusAttempt = async (attemptNum: number) => { this.log.info(`Retry number ${attemptNum} to check if project is initialized.`); - const response = await axios.get(`${this.baseEnvUrl}/projects/${projectId}/status`, { + const response = await fetch(`${this.baseEnvUrl}/projects/${projectId}/status`, { headers: { Authorization: `Basic ${this.proxyAuth}`, }, }); - if (response.data.phase !== 'initialized') { - this.log.info(response.data); + + if (!response.ok) { + throw new Error(`Failed to get project status: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + if (data.phase !== 'initialized') { + this.log.info(data); throw new Error('Project is not initialized. A retry will be triggered soon...'); } else { this.log.info('Project is initialized'); } }; const retryOptions = { - onFailedAttempt: (error: Error | AxiosError) => { - if (error instanceof AxiosError && error.code === 'ENOTFOUND') { + onFailedAttempt: (error: Error & { code?: string }) => { + if (error.code === 'ENOTFOUND') { this.log.info('Project is not reachable. A retry will be triggered soon...'); } else { this.log.warning(`${error.message}`); diff --git a/x-pack/solutions/security/plugins/security_solution/scripts/telemetry/build_ebt_data_view.test.ts b/x-pack/solutions/security/plugins/security_solution/scripts/telemetry/build_ebt_data_view.test.ts index ac6320c2bfebb..01c840f64bfdf 100644 --- a/x-pack/solutions/security/plugins/security_solution/scripts/telemetry/build_ebt_data_view.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/scripts/telemetry/build_ebt_data_view.test.ts @@ -6,15 +6,9 @@ */ /* eslint-disable @typescript-eslint/no-explicit-any */ -import axios from 'axios'; import { flattenSchema, upsertRuntimeFields } from './build_ebt_data_view'; -jest.mock('axios', () => ({ - __esModule: true, - default: { - put: jest.fn(), - }, -})); +const mockedFetch = jest.spyOn(global, 'fetch'); describe('upsertRuntimeFields', () => { const url = 'http://fake_url'; @@ -26,7 +20,7 @@ describe('upsertRuntimeFields', () => { beforeEach(() => { jest.resetAllMocks(); - (axios.put as jest.Mock).mockResolvedValue({}); + mockedFetch.mockResolvedValue(new Response(null, { status: 200 })); }); test('sends one PUT per string field with correct payload and headers', async () => { @@ -38,21 +32,27 @@ describe('upsertRuntimeFields', () => { await upsertRuntimeFields(fields, url, headers); - expect(axios.put).toHaveBeenCalledTimes(3); - - const calls = (axios.put as jest.Mock).mock.calls.map(([callUrl, payload, opts]) => ({ - callUrl, - name: payload.name, - type: payload.runtimeField?.type, - opts, - })); + const putCalls = mockedFetch.mock.calls.filter( + ([_url, init]) => (init as RequestInit)?.method === 'PUT' + ); + expect(putCalls.length).toBe(3); + + const calls = await Promise.all( + putCalls.map(async ([callUrl, init]) => { + const payload = JSON.parse((init as RequestInit).body as string); + return { + callUrl, + name: payload.name, + type: payload.runtimeField?.type, + headers: (init as RequestInit).headers, + }; + }) + ); const names = new Set(calls.map((c) => c.name)); const types = new Set(calls.map((c) => c.type)); const urls = new Set(calls.map((c) => c.callUrl)); - const allHeadersOk = calls.every( - (c) => JSON.stringify(c.opts?.headers) === JSON.stringify(headers) - ); + const allHeadersOk = calls.every((c) => JSON.stringify(c.headers) === JSON.stringify(headers)); expect(names).toEqual(new Set(['properties.a', 'properties.nested.b', 'properties.deep.x.y'])); expect(types).toEqual(new Set(['keyword', 'long', 'date'])); @@ -70,15 +70,19 @@ describe('upsertRuntimeFields', () => { await upsertRuntimeFields(fields as any, url, headers); - expect(axios.put).toHaveBeenCalledTimes(1); - const [callUrl, payload, opts] = (axios.put as jest.Mock).mock.calls[0]; + const putCalls = mockedFetch.mock.calls.filter( + ([_url, init]) => (init as RequestInit)?.method === 'PUT' + ); + expect(putCalls.length).toBe(1); + const [callUrl, init] = putCalls[0]; + const payload = JSON.parse((init as RequestInit).body as string); expect(callUrl).toBe(url); expect(payload).toEqual({ name: 'properties.ok', runtimeField: { type: 'ip' }, }); - expect(opts).toEqual({ headers }); + expect((init as RequestInit).headers).toEqual(headers); }); test('handles dotted field names correctly', async () => { @@ -88,7 +92,11 @@ describe('upsertRuntimeFields', () => { await upsertRuntimeFields(fields, url, headers); - const [, payload] = (axios.put as jest.Mock).mock.calls[0]; + const putCalls = mockedFetch.mock.calls.filter( + ([_url, init]) => (init as RequestInit)?.method === 'PUT' + ); + const [, init] = putCalls[0]; + const payload = JSON.parse((init as RequestInit).body as string); expect(payload.name).toBe('properties.one.two.three'); expect(payload.runtimeField.type).toBe('double'); }); diff --git a/x-pack/solutions/security/plugins/security_solution/scripts/telemetry/build_ebt_data_view.ts b/x-pack/solutions/security/plugins/security_solution/scripts/telemetry/build_ebt_data_view.ts index 54ddfb93dcb9f..7c64f832a3f7c 100755 --- a/x-pack/solutions/security/plugins/security_solution/scripts/telemetry/build_ebt_data_view.ts +++ b/x-pack/solutions/security/plugins/security_solution/scripts/telemetry/build_ebt_data_view.ts @@ -6,7 +6,6 @@ */ import { ToolingLog } from '@kbn/tooling-log'; -import axios from 'axios'; import { events as genAiEvents } from '@kbn/elastic-assistant-plugin/server/lib/telemetry/event_based_telemetry'; import { isObject } from 'lodash'; @@ -77,11 +76,13 @@ async function cli(): Promise { try { logger.info(`Fetching data view "${dataViewName}"...`); - const { - data: { data_view: ourDataView }, - } = await axios.get(dataViewApiUrl, { + const dataViewResponse = await fetch(dataViewApiUrl, { headers: requestHeaders, }); + if (!dataViewResponse.ok) { + throw new Error(`Failed to fetch data view: ${dataViewResponse.status}`); + } + const { data_view: ourDataView } = await dataViewResponse.json(); if (!ourDataView) { throw new Error( @@ -210,9 +211,14 @@ export async function upsertRuntimeFields( }; try { - await axios.put(requestUrl, payload, { + const putResponse = await fetch(requestUrl, { + method: 'PUT', + body: JSON.stringify(payload), headers: requestHeaders, }); + if (!putResponse.ok) { + throw new Error(`HTTP ${putResponse.status}`); + } } catch (error) { throw new Error(`Error upserting field '${fieldName}: ${fieldType}' - ${error.message}`); } diff --git a/x-pack/solutions/security/plugins/security_solution/server/integration_tests/configuration.test.ts b/x-pack/solutions/security/plugins/security_solution/server/integration_tests/configuration.test.ts index ca0144d0df9e5..e88534a84c2ac 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/integration_tests/configuration.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/integration_tests/configuration.test.ts @@ -5,7 +5,6 @@ * 2.0. */ import Path from 'path'; -import axios from 'axios'; import { cloneDeep } from 'lodash'; @@ -18,9 +17,9 @@ import { import { setupTestServers, removeFile, - mockAxiosPost, + mockFetchPost, + mockFetchGet, DEFAULT_GET_ROUTES, - mockAxiosGet, getRandomInt, } from './lib/helpers'; @@ -32,14 +31,11 @@ import { Plugin as SecuritySolutionPlugin } from '../plugin'; import { getTelemetryTasks, runSoonConfigTask } from './lib/telemetry_helpers'; import type { SecurityTelemetryTask } from '../lib/telemetry/task'; -jest.mock('axios'); - const logFilePath = Path.join(__dirname, 'config.logs.log'); const taskManagerStartSpy = jest.spyOn(TaskManagerPlugin.prototype, 'start'); const securitySolutionStartSpy = jest.spyOn(SecuritySolutionPlugin.prototype, 'start'); -const mockedAxiosGet = jest.spyOn(axios, 'get'); -const mockedAxiosPost = jest.spyOn(axios, 'post'); +const mockedFetch = jest.spyOn(global, 'fetch'); const securitySolutionPlugin = jest.spyOn(SecuritySolutionPlugin.prototype, 'start'); @@ -78,7 +74,7 @@ describe('configuration', () => { beforeEach(async () => { jest.clearAllMocks(); - mockAxiosPost(mockedAxiosPost); + mockFetchPost(mockedFetch); }); afterEach(async () => {}); @@ -151,7 +147,7 @@ describe('configuration', () => { }, }; - mockAxiosGet(mockedAxiosGet, [ + mockFetchGet(mockedFetch, [ ...DEFAULT_GET_ROUTES, [/.*telemetry-buffer-and-batch-sizes-v1.*/, { status: 200, data: cloneDeep(expected) }], ]); @@ -221,7 +217,7 @@ describe('configuration', () => { }, }; - mockAxiosGet(mockedAxiosGet, [ + mockFetchGet(mockedFetch, [ ...DEFAULT_GET_ROUTES, [ /.*telemetry-buffer-and-batch-sizes-v1.*/, @@ -250,7 +246,7 @@ describe('configuration', () => { }, }; - mockAxiosGet(mockedAxiosGet, [ + mockFetchGet(mockedFetch, [ ...DEFAULT_GET_ROUTES, [ /.*telemetry-buffer-and-batch-sizes-v1.*/, @@ -300,7 +296,7 @@ describe('configuration', () => { }, }; - mockAxiosGet(mockedAxiosGet, [ + mockFetchGet(mockedFetch, [ ...DEFAULT_GET_ROUTES, [/.*telemetry-buffer-and-batch-sizes-v1.*/, { status: 200, data: cloneDeep(customConfig) }], ]); @@ -364,7 +360,7 @@ describe('configuration', () => { }); it('should handle invalid health diagnostic config gracefully', async () => { - mockAxiosGet(mockedAxiosGet, [ + mockFetchGet(mockedFetch, [ ...DEFAULT_GET_ROUTES, [/.*telemetry-buffer-and-batch-sizes-v1.*/, { status: 500, data: null }], ]); diff --git a/x-pack/solutions/security/plugins/security_solution/server/integration_tests/lib/helpers.ts b/x-pack/solutions/security/plugins/security_solution/server/integration_tests/lib/helpers.ts index 5324f011ebb42..7783aeec255cd 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/integration_tests/lib/helpers.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/integration_tests/lib/helpers.ts @@ -142,34 +142,79 @@ export function updateTimestamps(data: object[]): object[] { }); } -export function mockAxiosPost( - postSpy: jest.SpyInstance, - routes: Array<[RegExp, unknown]> = DEFAULT_POST_ROUTES +/** + * Configures the global fetch mock to return responses based on URL patterns for POST requests. + */ +export function mockFetchPost( + fetchSpy: jest.SpyInstance, + postRoutes: Array<[RegExp, unknown]> = DEFAULT_POST_ROUTES, + getRoutes: Array<[RegExp, unknown]> = DEFAULT_GET_ROUTES ) { - postSpy.mockImplementation(async (url: string) => { - for (const [route, returnValue] of routes) { - if (route.test(url)) { - return returnValue; + fetchSpy.mockImplementation(async (url: string | URL | Request, init?: RequestInit) => { + const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url; + const method = init?.method?.toUpperCase() ?? 'GET'; + + if (method === 'POST') { + for (const [route, returnValue] of postRoutes) { + if (route.test(urlStr)) { + const rv = returnValue as { status?: number }; + return new Response(null, { status: rv?.status ?? 200 }); + } + } + return new Response(null, { status: 404 }); + } + + // GET requests (ping, artifacts, etc.) + for (const [route, returnValue] of getRoutes) { + if (route.test(urlStr)) { + const rv = returnValue as { status?: number; data?: unknown }; + const body = rv?.data !== undefined ? rv.data : null; + const responseBody = + typeof body === 'string' ? body : body !== null ? JSON.stringify(body) : null; + return new Response(responseBody, { status: rv?.status ?? 200 }); } } - return { status: 404 }; + return new Response(null, { status: 404 }); }); } -export function mockAxiosGet( - getSpy: jest.SpyInstance, - routes: Array<[RegExp, unknown]> = DEFAULT_GET_ROUTES +/** + * Updates the GET route responses for the fetch mock. + */ +export function mockFetchGet( + fetchSpy: jest.SpyInstance, + getRoutes: Array<[RegExp, unknown]> = DEFAULT_GET_ROUTES ) { - getSpy.mockImplementation(async (url: string) => { - for (const [route, returnValue] of routes) { - if (route.test(url)) { - return returnValue; + const currentImpl = fetchSpy.getMockImplementation(); + fetchSpy.mockImplementation(async (url: string | URL | Request, init?: RequestInit) => { + const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url; + const method = init?.method?.toUpperCase() ?? 'GET'; + + if (method !== 'POST') { + for (const [route, returnValue] of getRoutes) { + if (route.test(urlStr)) { + const rv = returnValue as { status?: number; data?: unknown }; + const body = rv?.data !== undefined ? rv.data : null; + const responseBody = + typeof body === 'string' ? body : body !== null ? JSON.stringify(body) : null; + return new Response(responseBody, { status: rv?.status ?? 200 }); + } } + return new Response(null, { status: 404 }); } - return { status: 404 }; + + // For POST, delegate to the current implementation + if (currentImpl) { + return currentImpl(url, init); + } + return new Response(null, { status: 200 }); }); } +// Keep legacy names for backward compatibility during migration +export const mockAxiosPost = (_postSpy: jest.SpyInstance, _routes?: Array<[RegExp, unknown]>) => {}; +export const mockAxiosGet = (_getSpy: jest.SpyInstance, _routes?: Array<[RegExp, unknown]>) => {}; + export function getRandomInt(min: number, max: number): number { return Math.floor(Math.random() * (max - min + 1)) + min; } diff --git a/x-pack/solutions/security/plugins/security_solution/server/integration_tests/receiver.test.ts b/x-pack/solutions/security/plugins/security_solution/server/integration_tests/receiver.test.ts index 3df24afd7a663..231f10ce9fbc7 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/integration_tests/receiver.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/integration_tests/receiver.test.ts @@ -21,7 +21,7 @@ import type { SearchRequest } from '@elastic/elasticsearch/lib/api/types'; import type { Nullable } from '../lib/telemetry/types'; // not needed, but it avoids some error messages like "Error: Cross origin http://localhost forbidden" -jest.mock('axios'); +jest.spyOn(global, 'fetch').mockResolvedValue(new Response(null, { status: 200 })); const logFilePath = Path.join(__dirname, 'logs.log'); diff --git a/x-pack/solutions/security/plugins/security_solution/server/integration_tests/telemetry.test.ts b/x-pack/solutions/security/plugins/security_solution/server/integration_tests/telemetry.test.ts index 409eb867e833b..71165015929cd 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/integration_tests/telemetry.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/integration_tests/telemetry.test.ts @@ -5,7 +5,6 @@ * 2.0. */ import Path from 'path'; -import axios, { type AxiosRequestConfig } from 'axios'; import type { ElasticsearchClient } from '@kbn/core/server'; @@ -24,8 +23,7 @@ import { eventually, setupTestServers, removeFile, - mockAxiosGet, - mockAxiosPost, + mockFetchPost, DEFAULT_GET_ROUTES, } from './lib/helpers'; import { @@ -65,17 +63,20 @@ import type { ITelemetryReceiver, TelemetryReceiver } from '../lib/telemetry/rec import type { TaskMetric } from '../lib/telemetry/task_metrics.types'; import type { AgentPolicy } from '@kbn/fleet-plugin/common'; -jest.mock('axios'); - const logFilePath = Path.join(__dirname, 'logs.log'); const taskManagerStartSpy = jest.spyOn(TaskManagerPlugin.prototype, 'start'); const securitySolutionStartSpy = jest.spyOn(SecuritySolutionPlugin.prototype, 'start'); -const mockedAxiosGet = jest.spyOn(axios, 'get'); -const mockedAxiosPost = jest.spyOn(axios, 'post'); +const mockedFetch = jest.spyOn(global, 'fetch'); const securitySolutionPlugin = jest.spyOn(SecuritySolutionPlugin.prototype, 'start'); +/** Returns only the POST fetch calls */ +const getPostCalls = () => + mockedFetch.mock.calls.filter(([_url, init]) => { + return init && (init as RequestInit).method === 'POST'; + }) as [string, RequestInit][]; + type Defer = () => void; describe('telemetry tasks', () => { @@ -143,8 +144,7 @@ describe('telemetry tasks', () => { beforeEach(async () => { jest.clearAllMocks(); - mockAxiosPost(mockedAxiosPost); - mockAxiosGet(mockedAxiosGet, [ + mockFetchPost(mockedFetch, undefined, [ ...DEFAULT_GET_ROUTES, [ /.*telemetry-buffer-and-batch-sizes-v1.*/, @@ -186,13 +186,13 @@ describe('telemetry tasks', () => { // wait until the events are sent to the telemetry server const body = await eventually(async () => { - const found = mockedAxiosPost.mock.calls.find(([url]) => { + const found = getPostCalls().find(([url]) => { return url.startsWith(ENDPOINT_STAGING) && url.endsWith('security-lists-v2'); }); expect(found).not.toBeFalsy(); - return JSON.parse((found ? found[1] : '{}') as string); + return JSON.parse((found ? (found[1] as RequestInit).body : '{}') as string); }); expect(body).not.toBeFalsy(); @@ -221,15 +221,14 @@ describe('telemetry tasks', () => { requests.forEach((r) => { expect(r.requestConfig).not.toBeFalsy(); if (r.requestConfig && r.requestConfig.headers) { - expect(r.requestConfig.headers['X-Telemetry-Sender']).not.toEqual('async'); + const headers = r.requestConfig.headers as Record; + expect(headers['X-Telemetry-Sender']).not.toEqual('async'); } }); }); it('should use new sender when configured', async () => { - mockAxiosPost(mockedAxiosPost); - - mockAxiosGet(mockedAxiosGet, [ + mockFetchPost(mockedFetch, undefined, [ ...DEFAULT_GET_ROUTES, [ /.*telemetry-buffer-and-batch-sizes-v1.*/, @@ -246,7 +245,8 @@ describe('telemetry tasks', () => { requests.forEach((r) => { expect(r.requestConfig).not.toBeFalsy(); if (r.requestConfig && r.requestConfig.headers) { - expect(r.requestConfig.headers['X-Telemetry-Sender']).toEqual('async'); + const headers = r.requestConfig.headers as Record; + expect(headers['X-Telemetry-Sender']).toEqual('async'); } }); }); @@ -254,8 +254,7 @@ describe('telemetry tasks', () => { it('should update sender queue config', async () => { const expectedConfig = fakeBufferAndSizesConfigWithQueues.sender_channels['task-metrics']; - mockAxiosPost(mockedAxiosPost); - mockAxiosGet(mockedAxiosGet, [ + mockFetchPost(mockedFetch, undefined, [ ...DEFAULT_GET_ROUTES, [ /.*telemetry-buffer-and-batch-sizes-v1.*/, @@ -293,13 +292,13 @@ describe('telemetry tasks', () => { // wait until the events are sent to the telemetry server const body = await eventually(async () => { - const found = mockedAxiosPost.mock.calls.find(([url]) => { + const found = getPostCalls().find(([url]) => { return url.startsWith(ENDPOINT_STAGING) && url.endsWith(TelemetryChannel.ENDPOINT_ALERTS); }); expect(found).not.toBeFalsy(); - return JSON.parse((found ? found[1] : '{}') as string); + return JSON.parse((found ? (found[1] as RequestInit).body : '{}') as string); }); expect(body).not.toBeFalsy(); @@ -311,13 +310,13 @@ describe('telemetry tasks', () => { // wait until the events are sent to the telemetry server const body = await eventually(async () => { - const found = mockedAxiosPost.mock.calls.find(([url]) => { + const found = getPostCalls().find(([url]) => { return url.startsWith(ENDPOINT_STAGING) && url.endsWith(TelemetryChannel.ENDPOINT_ALERTS); }); expect(found).not.toBeFalsy(); - return JSON.parse((found ? found[1] : '{}') as string); + return JSON.parse((found ? (found[1] as RequestInit).body : '{}') as string); }); expect(body).not.toBeFalsy(); @@ -755,7 +754,7 @@ describe('telemetry tasks', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any async function getEndpointMetaRequests(atLeast: number = 1): Promise { return eventually(async () => { - const found = mockedAxiosPost.mock.calls.filter(([url]) => { + const found = getPostCalls().filter(([url]) => { return url.startsWith(ENDPOINT_STAGING) && url.endsWith(TELEMETRY_CHANNEL_ENDPOINT_META); }); @@ -763,7 +762,7 @@ describe('telemetry tasks', () => { expect(found.length).toBeGreaterThanOrEqual(atLeast); return (found ?? []).flatMap((req) => { - const ndjson = req[1] as string; + const ndjson = (req[1] as RequestInit).body as string; return ndjson .split('\n') .filter((l) => l.trim().length > 0) @@ -777,7 +776,7 @@ describe('telemetry tasks', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any async function getAlertsDetectionsRequests(atLeast: number = 1): Promise { return eventually(async () => { - const found = mockedAxiosPost.mock.calls.filter(([url]) => { + const found = getPostCalls().filter(([url]) => { return url.startsWith(ENDPOINT_STAGING) && url.endsWith(TELEMETRY_CHANNEL_DETECTION_ALERTS); }); @@ -785,7 +784,7 @@ describe('telemetry tasks', () => { expect(found.length).toBeGreaterThanOrEqual(atLeast); return (found ?? []).flatMap((req) => { - const ndjson = req[1] as string; + const ndjson = (req[1] as RequestInit).body as string; return ndjson .split('\n') .filter((l) => l.trim().length > 0) @@ -866,14 +865,15 @@ describe('telemetry tasks', () => { ): Promise< Array<{ taskMetric: TaskMetric; - requestConfig: AxiosRequestConfig | undefined; + requestConfig: RequestInit | undefined; }> > { const taskType = getTelemetryTaskType(task); return eventually(async () => { - const calls = mockedAxiosPost.mock.calls.flatMap(([url, data, config]) => { - return (data as string).split('\n').map((body) => { - return { url, body, config }; + const calls = getPostCalls().flatMap(([url, init]) => { + const reqInit = init as RequestInit; + return (reqInit.body as string).split('\n').map((body) => { + return { url, body, config: reqInit }; }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/create_role_and_user.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/create_role_and_user.ts index 04b09feb1a153..9d32c741c644e 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/create_role_and_user.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/create_role_and_user.ts @@ -5,7 +5,6 @@ * 2.0. */ -import axios from 'axios'; import yargs from 'yargs'; import { ToolingLog } from '@kbn/tooling-log'; import { @@ -46,20 +45,22 @@ async function cli(): Promise { const requestHeaders = { Authorization: `Basic ${btoa(`${USERNAME}:${PASSWORD}`)}`, 'kbn-xsrf': 'xxx', + 'content-type': 'application/json', }; try { logger.info(`Creating role "${role}"...`); - await axios.put( - `${KIBANA_URL}/api/security/role/${role}`, - { + const roleResponse = await fetch(`${KIBANA_URL}/api/security/role/${role}`, { + method: 'PUT', + body: JSON.stringify({ elasticsearch: selectedRoleDefinition.elasticsearch, kibana: selectedRoleDefinition.kibana, - }, - { - headers: requestHeaders, - } - ); + }), + headers: requestHeaders, + }); + if (!roleResponse.ok) { + throw new Error(`Failed to create role: ${roleResponse.status} ${roleResponse.statusText}`); + } logger.info(`Role "${role}" has been created`); } catch (e) { @@ -69,18 +70,19 @@ async function cli(): Promise { try { logger.info(`Creating user "${userName}"...`); - await axios.put( - `${ELASTICSEARCH_URL}/_security/user/${userName}`, - { + const userResponse = await fetch(`${ELASTICSEARCH_URL}/_security/user/${userName}`, { + method: 'PUT', + body: JSON.stringify({ password, roles: [role], full_name: role, email: `role@example.com`, - }, - { - headers: requestHeaders, - } - ); + }), + headers: requestHeaders, + }); + if (!userResponse.ok) { + throw new Error(`Failed to create user: ${userResponse.status} ${userResponse.statusText}`); + } logger.info(`User "${userName}" has been created (password "${password}")`); } catch (e) { diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/telemetry/artifact.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/telemetry/artifact.test.ts index af7d96b6ac311..18699997a589d 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/telemetry/artifact.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/telemetry/artifact.test.ts @@ -7,15 +7,13 @@ import { createMockTelemetryReceiver } from './__mocks__'; import { Artifact } from './artifact'; -import axios from 'axios'; import type { TelemetryConfiguration } from './types'; -jest.mock('axios'); -const mockedAxios = axios as jest.Mocked; +const mockedFetch = jest.spyOn(global, 'fetch'); describe('telemetry artifact test', () => { beforeEach(() => { - mockedAxios.get.mockReset(); + mockedFetch.mockReset(); }); test('start should set manifest url for snapshot version', async () => { @@ -64,11 +62,14 @@ describe('telemetry artifact test', () => { const mockTelemetryReceiver = createMockTelemetryReceiver(); const artifact = new Artifact(); await artifact.start(mockTelemetryReceiver); - const axiosResponse = { - status: 200, - data: 'x-pack/solutions/security/plugins/security_solution/server/lib/telemetry/__mocks__/kibana-artifacts.zip', - }; - mockedAxios.get.mockImplementationOnce(() => Promise.resolve(axiosResponse)); + mockedFetch.mockImplementationOnce(() => + Promise.resolve( + new Response( + 'x-pack/solutions/security/plugins/security_solution/server/lib/telemetry/__mocks__/kibana-artifacts.zip', + { status: 200 } + ) + ) + ); await expect(async () => artifact.getArtifact('artifactThatDoesNotExist')).rejects.toThrow( 'No artifact for name artifactThatDoesNotExist' ); @@ -78,23 +79,28 @@ describe('telemetry artifact test', () => { const mockTelemetryReceiver = createMockTelemetryReceiver(); const artifact = new Artifact(); await artifact.start(mockTelemetryReceiver); - const axiosResponse = { - status: 200, - data: 'x-pack/solutions/security/plugins/security_solution/server/lib/telemetry/__mocks__/kibana-artifacts.zip', - }; - mockedAxios.get - .mockImplementationOnce(() => Promise.resolve(axiosResponse)) + mockedFetch + .mockImplementationOnce(() => + Promise.resolve( + new Response( + 'x-pack/solutions/security/plugins/security_solution/server/lib/telemetry/__mocks__/kibana-artifacts.zip', + { status: 200 } + ) + ) + ) .mockImplementationOnce(() => - Promise.resolve({ - status: 200, - data: { - telemetry_max_buffer_size: 100, - max_security_list_telemetry_batch: 100, - max_endpoint_telemetry_batch: 300, - max_detection_rule_telemetry_batch: 1_000, - max_detection_alerts_batch: 50, - }, - }) + Promise.resolve( + new Response( + JSON.stringify({ + telemetry_max_buffer_size: 100, + max_security_list_telemetry_batch: 100, + max_endpoint_telemetry_batch: 300, + max_detection_rule_telemetry_batch: 1_000, + max_detection_alerts_batch: 50, + }), + { status: 200 } + ) + ) ); const manifest = await artifact.getArtifact('telemetry-buffer-and-batch-sizes-v1'); expect(manifest).not.toBeFalsy(); @@ -109,33 +115,40 @@ describe('telemetry artifact test', () => { test('getArtifact should cache response', async () => { const fakeEtag = '123'; - const axiosResponse = { - status: 200, - data: 'x-pack/solutions/security/plugins/security_solution/server/lib/telemetry/__mocks__/kibana-artifacts.zip', - headers: { etag: fakeEtag }, - }; const artifact = new Artifact(); await artifact.start(createMockTelemetryReceiver()); - mockedAxios.get - .mockImplementationOnce(() => Promise.resolve(axiosResponse)) - .mockImplementationOnce(() => Promise.resolve({ status: 200, data: {} })) - .mockImplementationOnce(() => Promise.resolve({ status: 304 })); + mockedFetch + .mockImplementationOnce(() => + Promise.resolve( + new Response( + 'x-pack/solutions/security/plugins/security_solution/server/lib/telemetry/__mocks__/kibana-artifacts.zip', + { + status: 200, + headers: { etag: fakeEtag }, + } + ) + ) + ) + .mockImplementationOnce(() => + Promise.resolve(new Response(JSON.stringify({}), { status: 200 })) + ) + .mockImplementationOnce(() => Promise.resolve(new Response(null, { status: 304 }))); let manifest = await artifact.getArtifact('telemetry-buffer-and-batch-sizes-v1'); expect(manifest).not.toBeFalsy(); expect(manifest.notModified).toEqual(false); - expect(mockedAxios.get.mock.calls.length).toBe(2); + expect(mockedFetch.mock.calls.length).toBe(2); manifest = await artifact.getArtifact('telemetry-buffer-and-batch-sizes-v1'); expect(manifest).not.toBeFalsy(); expect(manifest.notModified).toEqual(true); - expect(mockedAxios.get.mock.calls.length).toBe(3); + expect(mockedFetch.mock.calls.length).toBe(3); - const [_url, config] = mockedAxios.get.mock.calls[2]; - const headers = config?.headers ?? {}; + const [_url, config] = mockedFetch.mock.calls[2]; + const headers = (config as RequestInit)?.headers ?? {}; expect(headers).not.toBeFalsy(); - expect(headers['If-None-Match']).toEqual(fakeEtag); + expect((headers as Record)['If-None-Match']).toEqual(fakeEtag); }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/telemetry/artifact.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/telemetry/artifact.ts index 421f88c622837..0044b9f3546ae 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/telemetry/artifact.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/telemetry/artifact.ts @@ -7,7 +7,6 @@ import { createVerify } from 'crypto'; -import axios from 'axios'; import { cloneDeep } from 'lodash'; import AdmZip from 'adm-zip'; import type { ITelemetryReceiver } from './receiver'; @@ -82,7 +81,7 @@ export class Artifact implements IArtifact { private manifestUrl?: string; private cdn?: CdnConfig; - private readonly AXIOS_TIMEOUT_MS = 10_000; + private readonly FETCH_TIMEOUT_MS = 10_000; private receiver?: ITelemetryReceiver; private esClusterInfo?: ESClusterInfo; private cache: Map = new Map(); @@ -102,39 +101,41 @@ export class Artifact implements IArtifact { } public async getArtifact(name: string): Promise { - return axios - .get(this.getManifestUrl(), { - headers: this.headers(name), - timeout: this.AXIOS_TIMEOUT_MS, - validateStatus: (status) => status < 500, - responseType: 'arraybuffer', - }) - .then(async (response) => { - switch (response.status) { - case 200: - const manifest = { - data: await this.getManifest(name, response.data), - notModified: false, - }; - // only update etag if we got a valid manifest - if (response.headers && response.headers.etag) { - const cacheEntry = { - manifest: { ...manifest, notModified: true }, - etag: response.headers?.etag ?? '', - }; - this.cache.set(name, cacheEntry); - } - return cloneDeep(manifest); - case 304: - return cloneDeep(this.getCachedManifest(name)); - case 404: - // just in case, remove the entry - this.cache.delete(name); - throw Error(`No manifest resource found at url: ${this.manifestUrl}`); - default: - throw Error(`Failed to download manifest, unexpected status code: ${response.status}`); + const response = await fetch(this.getManifestUrl(), { + headers: this.headers(name), + signal: AbortSignal.timeout(this.FETCH_TIMEOUT_MS), + }); + + if (response.status >= 500) { + throw Error(`Failed to download manifest, unexpected status code: ${response.status}`); + } + + switch (response.status) { + case 200: + const data = Buffer.from(await response.arrayBuffer()); + const manifest = { + data: await this.getManifest(name, data), + notModified: false, + }; + // only update etag if we got a valid manifest + const etag = response.headers.get('etag'); + if (etag) { + const cacheEntry = { + manifest: { ...manifest, notModified: true }, + etag, + }; + this.cache.set(name, cacheEntry); } - }); + return cloneDeep(manifest); + case 304: + return cloneDeep(this.getCachedManifest(name)); + case 404: + // just in case, remove the entry + this.cache.delete(name); + throw Error(`No manifest resource found at url: ${this.manifestUrl}`); + default: + throw Error(`Failed to download manifest, unexpected status code: ${response.status}`); + } } public getManifestUrl() { @@ -180,8 +181,15 @@ export class Artifact implements IArtifact { const relativeUrl = manifest.artifacts[name]?.relative_url; if (relativeUrl) { const url = `${this.cdn?.url}${relativeUrl}`; - const artifactResponse = await axios.get(url, { timeout: this.AXIOS_TIMEOUT_MS }); - return artifactResponse.data; + const artifactResponse = await fetch(url, { + signal: AbortSignal.timeout(this.FETCH_TIMEOUT_MS), + }); + if (!artifactResponse.ok) { + throw Error( + `Failed to download artifact, unexpected status code: ${artifactResponse.status}` + ); + } + return artifactResponse.json(); } else { throw Error(`No artifact for name ${name}`); } diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/telemetry/async_sender.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/telemetry/async_sender.test.ts index 64d9c2c8962f6..057f386c6338f 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/telemetry/async_sender.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/telemetry/async_sender.test.ts @@ -4,8 +4,6 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import axios from 'axios'; - import type { QueueConfig, IAsyncTelemetryEventsSender } from './async_sender.types'; import { DEFAULT_QUEUE_CONFIG, @@ -23,12 +21,17 @@ import { import { TelemetryEventsSender } from './sender'; import type { ExperimentalFeatures } from '../../../common'; -jest.mock('axios'); jest.mock('./receiver'); describe('AsyncTelemetryEventsSender', () => { - const mockedAxiosPost = jest.spyOn(axios, 'post'); - const mockedAxiosGet = jest.spyOn(axios, 'get'); + const mockedFetch = jest.spyOn(global, 'fetch'); + + /** Returns only the POST fetch calls (excludes ping GET calls) */ + const getPostCalls = () => + mockedFetch.mock.calls.filter(([_url, init]) => { + return init && (init as RequestInit).method === 'POST'; + }); + const telemetryPluginSetup = createMockTelemetryPluginSetup(); const telemetryPluginStart = createMockTelemetryPluginStart(); const receiver = createMockTelemetryReceiver(); @@ -57,10 +60,15 @@ describe('AsyncTelemetryEventsSender', () => { beforeEach(() => { service = new AsyncTelemetryEventsSender(loggingSystemMock.createLogger()); jest.useFakeTimers({ advanceTimers: true }); - mockedAxiosPost.mockClear(); + mockedFetch.mockClear(); telemetryUsageCounter.incrementCounter.mockClear(); - mockedAxiosPost.mockResolvedValue({ status: 201 }); - mockedAxiosGet.mockResolvedValue({ status: 200 }); + mockedFetch.mockImplementation(async (url: string | URL | Request) => { + const urlStr = typeof url === 'string' ? url : url.toString(); + if (urlStr.endsWith('/ping')) { + return new Response(null, { status: 200 }); + } + return new Response(null, { status: 201 }); + }); }); afterEach(() => { @@ -78,15 +86,14 @@ describe('AsyncTelemetryEventsSender', () => { service.send(ch1, events); await jest.advanceTimersByTimeAsync(DEFAULT_QUEUE_CONFIG.bufferTimeSpanMillis * 1.1); - expect(mockedAxiosPost).toHaveBeenCalledTimes(1); - expect(mockedAxiosPost).toHaveBeenCalledWith( + expect(getPostCalls().length).toBe(1); + expect(getPostCalls()).toContainEqual([ expect.anything(), - expectedBody, - expect.anything() - ); + expect.objectContaining({ body: expectedBody }), + ]); await service.stop(); - expect(mockedAxiosPost).toHaveBeenCalledTimes(1); + expect(getPostCalls().length).toBe(1); }); it('does not lose data during startup', async () => { @@ -99,21 +106,20 @@ describe('AsyncTelemetryEventsSender', () => { await jest.advanceTimersByTimeAsync(DEFAULT_QUEUE_CONFIG.bufferTimeSpanMillis * 1.1); - expect(mockedAxiosPost).toHaveBeenCalledTimes(0); + expect(getPostCalls().length).toBe(0); service.start(telemetryPluginStart); await jest.advanceTimersByTimeAsync(DEFAULT_QUEUE_CONFIG.bufferTimeSpanMillis * 1.1); - expect(mockedAxiosPost).toHaveBeenCalledTimes(1); - expect(mockedAxiosPost).toHaveBeenCalledWith( + expect(getPostCalls().length).toBe(1); + expect(getPostCalls()).toContainEqual([ expect.anything(), - expectedBody, - expect.anything() - ); + expect.objectContaining({ body: expectedBody }), + ]); await service.stop(); - expect(mockedAxiosPost).toHaveBeenCalledTimes(1); + expect(getPostCalls().length).toBe(1); }); it('should not start without being configured', () => { @@ -163,14 +169,13 @@ describe('AsyncTelemetryEventsSender', () => { await service.stop(); - expect(mockedAxiosPost).toHaveBeenCalledTimes(2); + expect(getPostCalls().length).toBe(2); expectedBodies.forEach((expectedBody) => { - expect(mockedAxiosPost).toHaveBeenCalledWith( + expect(getPostCalls()).toContainEqual([ expect.anything(), - expectedBody, - expect.anything() - ); + expect.objectContaining({ body: expectedBody }), + ]); }); }); @@ -201,14 +206,13 @@ describe('AsyncTelemetryEventsSender', () => { await service.stop(); - expect(mockedAxiosPost).toHaveBeenCalledTimes(3); + expect(getPostCalls().length).toBe(3); expectedBodies.forEach((expectedBody) => { - expect(mockedAxiosPost).toHaveBeenCalledWith( + expect(getPostCalls()).toContainEqual([ expect.anything(), - expectedBody, - expect.anything() - ); + expect.objectContaining({ body: expectedBody }), + ]); }); }); @@ -229,32 +233,39 @@ describe('AsyncTelemetryEventsSender', () => { await jest.advanceTimersByTimeAsync(bufferTimeSpanMillis * 0.2); // check that no events are sent before the buffer time span - expect(mockedAxiosPost).toHaveBeenCalledTimes(0); + expect(getPostCalls().length).toBe(0); // advance time by more than the buffer time span await jest.advanceTimersByTimeAsync(bufferTimeSpanMillis * 1.2); // check that the events are sent - expect(mockedAxiosPost).toHaveBeenCalledTimes(1); - expect(mockedAxiosPost).toHaveBeenCalledWith( + expect(getPostCalls().length).toBe(1); + expect(getPostCalls()).toContainEqual([ expect.anything(), - expectedBody, - expect.anything() - ); + expect.objectContaining({ body: expectedBody }), + ]); await service.stop(); // check that no more events are sent - expect(mockedAxiosPost).toHaveBeenCalledTimes(1); + expect(getPostCalls().length).toBe(1); }); }); describe('error handling', () => { it('retries when the backend fails', async () => { - mockedAxiosPost - .mockReturnValueOnce(Promise.resolve({ status: 500 })) - .mockReturnValueOnce(Promise.resolve({ status: 500 })) - .mockReturnValue(Promise.resolve({ status: 201 })); + let postCallCount = 0; + mockedFetch.mockImplementation(async (url: string | URL | Request) => { + const urlStr = typeof url === 'string' ? url : url.toString(); + if (urlStr.endsWith('/ping')) { + return new Response(null, { status: 200 }); + } + postCallCount++; + if (postCallCount <= 2) { + return new Response(null, { status: 500 }); + } + return new Response(null, { status: 201 }); + }); const bufferTimeSpanMillis = 3; @@ -271,19 +282,27 @@ describe('AsyncTelemetryEventsSender', () => { ); // check that the events are sent - expect(mockedAxiosPost).toHaveBeenCalledTimes(DEFAULT_RETRY_CONFIG.retryCount); + expect(getPostCalls().length).toBe(DEFAULT_RETRY_CONFIG.retryCount); await service.stop(); // check that no more events are sent - expect(mockedAxiosPost).toHaveBeenCalledTimes(DEFAULT_RETRY_CONFIG.retryCount); + expect(getPostCalls().length).toBe(DEFAULT_RETRY_CONFIG.retryCount); }); it('retries runtime errors', async () => { - mockedAxiosPost - .mockReturnValueOnce(Promise.resolve({ status: 500 })) - .mockReturnValueOnce(Promise.resolve({ status: 500 })) - .mockReturnValue(Promise.resolve({ status: 201 })); + let postCallCount2 = 0; + mockedFetch.mockImplementation(async (url: string | URL | Request) => { + const urlStr = typeof url === 'string' ? url : url.toString(); + if (urlStr.endsWith('/ping')) { + return new Response(null, { status: 200 }); + } + postCallCount2++; + if (postCallCount2 <= 2) { + return new Response(null, { status: 500 }); + } + return new Response(null, { status: 201 }); + }); const bufferTimeSpanMillis = 3; @@ -300,16 +319,22 @@ describe('AsyncTelemetryEventsSender', () => { ); // check that the events are sent - expect(mockedAxiosPost).toHaveBeenCalledTimes(DEFAULT_RETRY_CONFIG.retryCount); + expect(getPostCalls().length).toBe(DEFAULT_RETRY_CONFIG.retryCount); await service.stop(); // check that no more events are sent - expect(mockedAxiosPost).toHaveBeenCalledTimes(DEFAULT_RETRY_CONFIG.retryCount); + expect(getPostCalls().length).toBe(DEFAULT_RETRY_CONFIG.retryCount); }); it('only retries `retryCount` times', async () => { - mockedAxiosPost.mockReturnValue(Promise.resolve({ status: 500 })); + mockedFetch.mockImplementation(async (url: string | URL | Request) => { + const urlStr = typeof url === 'string' ? url : url.toString(); + if (urlStr.endsWith('/ping')) { + return new Response(null, { status: 200 }); + } + return new Response(null, { status: 500 }); + }); const bufferTimeSpanMillis = 100; service.setup(DEFAULT_RETRY_CONFIG, DEFAULT_QUEUE_CONFIG, receiver, telemetryPluginSetup); @@ -325,16 +350,20 @@ describe('AsyncTelemetryEventsSender', () => { ); // check that the events are sent - expect(mockedAxiosPost).toHaveBeenCalledTimes(DEFAULT_RETRY_CONFIG.retryCount + 1); + expect(getPostCalls().length).toBe(DEFAULT_RETRY_CONFIG.retryCount + 1); await service.stop(); // check that no more events are sent - expect(mockedAxiosPost).toHaveBeenCalledTimes(DEFAULT_RETRY_CONFIG.retryCount + 1); + expect(getPostCalls().length).toBe(DEFAULT_RETRY_CONFIG.retryCount + 1); }); it('should catch fatal errors', async () => { - mockedAxiosPost.mockImplementation(() => { + mockedFetch.mockImplementation(async (url: string | URL | Request) => { + const urlStr = typeof url === 'string' ? url : url.toString(); + if (urlStr.endsWith('/ping')) { + return new Response(null, { status: 200 }); + } throw Error('fatal error'); }); const bufferTimeSpanMillis = 100; @@ -352,12 +381,12 @@ describe('AsyncTelemetryEventsSender', () => { ); // check that the events are sent - expect(mockedAxiosPost).toHaveBeenCalledTimes(DEFAULT_RETRY_CONFIG.retryCount + 1); + expect(getPostCalls().length).toBe(DEFAULT_RETRY_CONFIG.retryCount + 1); await service.stop(); // check that no more events are sent - expect(mockedAxiosPost).toHaveBeenCalledTimes(DEFAULT_RETRY_CONFIG.retryCount + 1); + expect(getPostCalls().length).toBe(DEFAULT_RETRY_CONFIG.retryCount + 1); }); }); @@ -378,23 +407,22 @@ describe('AsyncTelemetryEventsSender', () => { service.send(ch1, ['a', 'b', 'c', 'd']); // check that no events are sent before the buffer time span - expect(mockedAxiosPost).toHaveBeenCalledTimes(0); + expect(getPostCalls().length).toBe(0); // advance time await jest.advanceTimersByTimeAsync(bufferTimeSpanMillis * 2); // check that only `inflightEventsThreshold` events were sent - expect(mockedAxiosPost).toHaveBeenCalledTimes(1); - expect(mockedAxiosPost).toHaveBeenCalledWith( + expect(getPostCalls().length).toBe(1); + expect(getPostCalls()).toContainEqual([ expect.anything(), - '"a"\n"b"\n"c"', - expect.anything() - ); + expect.objectContaining({ body: '"a"\n"b"\n"c"' }), + ]); await service.stop(); // check that no more events are sent - expect(mockedAxiosPost).toHaveBeenCalledTimes(1); + expect(getPostCalls().length).toBe(1); }); it('do not drop events if they are processed before the next batch', async () => { @@ -412,7 +440,7 @@ describe('AsyncTelemetryEventsSender', () => { service.start(telemetryPluginStart); // check that no events are sent before the buffer time span - expect(mockedAxiosPost).toHaveBeenCalledTimes(0); + expect(getPostCalls().length).toBe(0); for (let i = 0; i < batches; i++) { // send the next batch @@ -422,22 +450,20 @@ describe('AsyncTelemetryEventsSender', () => { await jest.advanceTimersByTimeAsync(bufferTimeSpanMillis * 2); } - expect(mockedAxiosPost).toHaveBeenCalledTimes(batches); + expect(getPostCalls().length).toBe(batches); for (let i = 0; i < batches; i++) { const expected = '"a"\n"b"\n"c"'; - expect(mockedAxiosPost).toHaveBeenNthCalledWith( - i + 1, + expect(getPostCalls()[i]).toEqual([ expect.anything(), - expected, - expect.anything() - ); + expect.objectContaining({ body: expected }), + ]); } await service.stop(); // check that no more events are sent - expect(mockedAxiosPost).toHaveBeenCalledTimes(batches); + expect(getPostCalls().length).toBe(batches); }); }); @@ -472,41 +498,35 @@ describe('AsyncTelemetryEventsSender', () => { await jest.advanceTimersByTimeAsync(ch1Config.bufferTimeSpanMillis); // only high priority events should have been sent - expect(mockedAxiosPost).toHaveBeenCalledTimes(1); - expect(mockedAxiosPost).toHaveBeenNthCalledWith( - 1, + expect(getPostCalls().length).toBe(1); + expect(getPostCalls()[0]).toEqual([ expect.anything(), - ch1Events.map((e) => JSON.stringify(e)).join('\n'), - expect.anything() - ); + expect.objectContaining({ body: ch1Events.map((e) => JSON.stringify(e)).join('\n') }), + ]); // wait just the medium priority queue latency await jest.advanceTimersByTimeAsync(ch2Config.bufferTimeSpanMillis); // only medium priority events should have been sent - expect(mockedAxiosPost).toHaveBeenCalledTimes(2); - expect(mockedAxiosPost).toHaveBeenNthCalledWith( - 2, + expect(getPostCalls().length).toBe(2); + expect(getPostCalls()[1]).toEqual([ expect.anything(), - ch2Events.map((e) => JSON.stringify(e)).join('\n'), - expect.anything() - ); + expect.objectContaining({ body: ch2Events.map((e) => JSON.stringify(e)).join('\n') }), + ]); // wait more time await jest.advanceTimersByTimeAsync(ch3Config.bufferTimeSpanMillis); // all events should have been sent - expect(mockedAxiosPost).toHaveBeenCalledTimes(3); - expect(mockedAxiosPost).toHaveBeenNthCalledWith( - 3, + expect(getPostCalls().length).toBe(3); + expect(getPostCalls()[2]).toEqual([ expect.anything(), - ch3Events.map((e) => JSON.stringify(e)).join('\n'), - expect.anything() - ); + expect.objectContaining({ body: ch3Events.map((e) => JSON.stringify(e)).join('\n') }), + ]); // no more events sent after the service was stopped await service.stop(); - expect(mockedAxiosPost).toHaveBeenCalledTimes(3); + expect(getPostCalls().length).toBe(3); }); it('discard events when inflightEventsThreshold is reached and process other queues', async () => { @@ -530,32 +550,32 @@ describe('AsyncTelemetryEventsSender', () => { await jest.advanceTimersByTimeAsync(ch2Config.bufferTimeSpanMillis * 1.2); - expect(mockedAxiosPost).toHaveBeenCalledTimes(1); + expect(getPostCalls().length).toBe(1); await jest.advanceTimersByTimeAsync(ch3Config.bufferTimeSpanMillis * 1.2); - expect(mockedAxiosPost).toHaveBeenCalledTimes(2); + expect(getPostCalls().length).toBe(2); - expect(mockedAxiosPost).toHaveBeenNthCalledWith( - 1, + expect(getPostCalls()[0]).toEqual([ expect.anything(), // gets all ch2 events - ch2Events.map((e) => JSON.stringify(e)).join('\n'), - expect.anything() - ); - expect(mockedAxiosPost).toHaveBeenNthCalledWith( - 2, + expect.objectContaining({ + body: ch2Events.map((e) => JSON.stringify(e)).join('\n'), + }), + ]); + expect(getPostCalls()[1]).toEqual([ expect.anything(), // only got `inflightEventsThreshold` events, the remaining ch3 events were dropped - ch3Events - .slice(0, ch3Config.inflightEventsThreshold) - .map((e) => JSON.stringify(e)) - .join('\n'), - expect.anything() - ); + expect.objectContaining({ + body: ch3Events + .slice(0, ch3Config.inflightEventsThreshold) + .map((e) => JSON.stringify(e)) + .join('\n'), + }), + ]); await service.stop(); - expect(mockedAxiosPost).toHaveBeenCalledTimes(2); + expect(getPostCalls().length).toBe(2); }); it('should manage queue priorities and channels', async () => { @@ -599,31 +619,31 @@ describe('AsyncTelemetryEventsSender', () => { await jest.advanceTimersByTimeAsync(testCase.wait); } - expect(mockedAxiosPost).toHaveBeenCalledTimes(3); + expect(getPostCalls().length).toBe(3); - expect(mockedAxiosPost).toHaveBeenNthCalledWith( - 1, + expect(getPostCalls()[0]).toEqual([ expect.stringMatching(`.*${ch2}.*`), // url contains the channel name - [...cases[1].events, ...cases[2].events].map((e) => JSON.stringify(e)).join('\n'), - expect.anything() - ); + expect.objectContaining({ + body: [...cases[1].events, ...cases[2].events].map((e) => JSON.stringify(e)).join('\n'), + }), + ]); - expect(mockedAxiosPost).toHaveBeenNthCalledWith( - 2, + expect(getPostCalls()[1]).toEqual([ expect.stringMatching(`.*${ch2}.*`), // url contains the channel name - cases[3].events.map((e) => JSON.stringify(e)).join('\n'), - expect.anything() - ); + expect.objectContaining({ + body: cases[3].events.map((e) => JSON.stringify(e)).join('\n'), + }), + ]); - expect(mockedAxiosPost).toHaveBeenNthCalledWith( - 3, + expect(getPostCalls()[2]).toEqual([ expect.stringMatching(`.*${ch3}.*`), // url contains the channel name - [...cases[0].events, ...cases[4].events].map((e) => JSON.stringify(e)).join('\n'), - expect.anything() - ); + expect.objectContaining({ + body: [...cases[0].events, ...cases[4].events].map((e) => JSON.stringify(e)).join('\n'), + }), + ]); await service.stop(); - expect(mockedAxiosPost).toHaveBeenCalledTimes(3); + expect(getPostCalls().length).toBe(3); }); }); @@ -642,19 +662,18 @@ describe('AsyncTelemetryEventsSender', () => { // send data and wait the initial time span service.send(ch1, events); await jest.advanceTimersByTimeAsync(initialTimeSpan * 1.1); - expect(mockedAxiosPost).toHaveBeenCalledTimes(0); + expect(getPostCalls().length).toBe(0); // wait the new timespan, now we should have data await jest.advanceTimersByTimeAsync(bufferTimeSpanMillis * 1.1); - expect(mockedAxiosPost).toHaveBeenCalledTimes(1); - expect(mockedAxiosPost).toHaveBeenCalledWith( + expect(getPostCalls().length).toBe(1); + expect(getPostCalls()).toContainEqual([ expect.anything(), - expectedBody, - expect.anything() - ); + expect.objectContaining({ body: expectedBody }), + ]); await service.stop(); - expect(mockedAxiosPost).toHaveBeenCalledTimes(1); + expect(getPostCalls().length).toBe(1); }); it('should update buffer time config dinamically', async () => { @@ -675,34 +694,32 @@ describe('AsyncTelemetryEventsSender', () => { await jest.advanceTimersByTimeAsync(ch1Config.bufferTimeSpanMillis * 1.1); - expect(mockedAxiosPost).toHaveBeenCalledTimes(1); - expect(mockedAxiosPost).toHaveBeenCalledWith( + expect(getPostCalls().length).toBe(1); + expect(getPostCalls()).toContainEqual([ expect.anything(), - expectedBody, - expect.anything() - ); + expect.objectContaining({ body: expectedBody }), + ]); service.updateQueueConfig(channel, detectionAlertsAfter); - expect(mockedAxiosPost).toHaveBeenCalledTimes(1); + expect(getPostCalls().length).toBe(1); service.send(channel, events); // the old buffer time shouldn't trigger a new buffer (we increased it) await jest.advanceTimersByTimeAsync(ch1Config.bufferTimeSpanMillis * 1.1); - expect(mockedAxiosPost).toHaveBeenCalledTimes(1); + expect(getPostCalls().length).toBe(1); // wait more time... await jest.advanceTimersByTimeAsync(detectionAlertsAfter.bufferTimeSpanMillis); - expect(mockedAxiosPost).toHaveBeenCalledTimes(2); + expect(getPostCalls().length).toBe(2); - expect(mockedAxiosPost).toHaveBeenCalledWith( + expect(getPostCalls()).toContainEqual([ expect.anything(), - expectedBody, - expect.anything() - ); + expect.objectContaining({ body: expectedBody }), + ]); await service.stop(); - expect(mockedAxiosPost).toHaveBeenCalledTimes(2); + expect(getPostCalls().length).toBe(2); }); it('should update max payload size dinamically', async () => { @@ -726,13 +743,12 @@ describe('AsyncTelemetryEventsSender', () => { await jest.advanceTimersByTimeAsync(detectionAlertsBefore.bufferTimeSpanMillis * 1.1); - expect(mockedAxiosPost).toHaveBeenCalledTimes(2); + expect(getPostCalls().length).toBe(2); expectedBodies.forEach((expectedBody) => { - expect(mockedAxiosPost).toHaveBeenCalledWith( + expect(getPostCalls()).toContainEqual([ expect.anything(), - expectedBody, - expect.anything() - ); + expect.objectContaining({ body: expectedBody }), + ]); }); service.updateQueueConfig(channel, detectionAlertsAfter); @@ -742,13 +758,12 @@ describe('AsyncTelemetryEventsSender', () => { await jest.advanceTimersByTimeAsync(detectionAlertsAfter.bufferTimeSpanMillis * 1.1); - expect(mockedAxiosPost).toHaveBeenCalledTimes(3); + expect(getPostCalls().length).toBe(3); expectedBodies.forEach((expectedBody) => { - expect(mockedAxiosPost).toHaveBeenCalledWith( + expect(getPostCalls()).toContainEqual([ expect.anything(), - expectedBody, - expect.anything() - ); + expect.objectContaining({ body: expectedBody }), + ]); }); await service.stop(); @@ -766,31 +781,29 @@ describe('AsyncTelemetryEventsSender', () => { await jest.advanceTimersByTimeAsync(DEFAULT_QUEUE_CONFIG.bufferTimeSpanMillis * 1.1); - expect(mockedAxiosPost).toHaveBeenCalledTimes(1); - expect(mockedAxiosPost).toHaveBeenCalledWith( + expect(getPostCalls().length).toBe(1); + expect(getPostCalls()).toContainEqual([ expect.anything(), - expectedBody, - expect.anything() - ); + expect.objectContaining({ body: expectedBody }), + ]); service.updateQueueConfig(ch1, { ...DEFAULT_QUEUE_CONFIG, bufferTimeSpanMillis }); service.send(ch1, events); await jest.advanceTimersByTimeAsync(DEFAULT_QUEUE_CONFIG.bufferTimeSpanMillis * 1.1); - expect(mockedAxiosPost).toHaveBeenCalledTimes(1); + expect(getPostCalls().length).toBe(1); await jest.advanceTimersByTimeAsync(bufferTimeSpanMillis * 1.1); - expect(mockedAxiosPost).toHaveBeenCalledTimes(2); - expect(mockedAxiosPost).toHaveBeenCalledWith( + expect(getPostCalls().length).toBe(2); + expect(getPostCalls()).toContainEqual([ expect.anything(), - expectedBody, - expect.anything() - ); + expect.objectContaining({ body: expectedBody }), + ]); await service.stop(); - expect(mockedAxiosPost).toHaveBeenCalledTimes(2); + expect(getPostCalls().length).toBe(2); }); }); @@ -808,10 +821,13 @@ describe('AsyncTelemetryEventsSender', () => { service.send(ch1, ['a']); await service.stop(); - expect(mockedAxiosPost).toHaveBeenCalledTimes(1); - const found = mockedAxiosPost.mock.calls.some( - ([_url, _body, config]) => - config && config.headers && config.headers['X-Telemetry-Sender'] === 'async' + expect(getPostCalls().length).toBe(1); + const found = mockedFetch.mock.calls.some( + ([_url, init]) => + init && + (init as RequestInit).headers && + ((init as RequestInit).headers as Record)['X-Telemetry-Sender'] === + 'async' ); expect(found).not.toBeFalsy(); @@ -839,7 +855,13 @@ describe('AsyncTelemetryEventsSender', () => { }); it('should increment the counter when sending events with errors', async () => { - mockedAxiosPost.mockReturnValue(Promise.resolve({ status: 500 })); + mockedFetch.mockImplementation(async (url: string | URL | Request) => { + const urlStr = typeof url === 'string' ? url : url.toString(); + if (urlStr.endsWith('/ping')) { + return new Response(null, { status: 200 }); + } + return new Response(null, { status: 500 }); + }); service.setup( DEFAULT_RETRY_CONFIG, @@ -864,11 +886,18 @@ describe('AsyncTelemetryEventsSender', () => { it('should increment the counter when sending events with errors and without errors', async () => { // retries count is set to 3 - mockedAxiosPost - .mockReturnValueOnce(Promise.resolve({ status: 500 })) - .mockReturnValueOnce(Promise.resolve({ status: 500 })) - .mockReturnValueOnce(Promise.resolve({ status: 500 })) - .mockReturnValueOnce(Promise.resolve({ status: 500 })); + let postCallCount3 = 0; + mockedFetch.mockImplementation(async (url: string | URL | Request) => { + const urlStr = typeof url === 'string' ? url : url.toString(); + if (urlStr.endsWith('/ping')) { + return new Response(null, { status: 200 }); + } + postCallCount3++; + if (postCallCount3 <= 4) { + return new Response(null, { status: 500 }); + } + return new Response(null, { status: 201 }); + }); service.setup( DEFAULT_RETRY_CONFIG, @@ -920,18 +949,17 @@ describe('AsyncTelemetryEventsSender', () => { service.send(ch1, ['a', 'b', 'c', 'd']); // check that no events are sent before the buffer time span - expect(mockedAxiosPost).toHaveBeenCalledTimes(0); + expect(getPostCalls().length).toBe(0); // advance time await jest.advanceTimersByTimeAsync(bufferTimeSpanMillis * 2); // check that only `inflightEventsThreshold` events were sent - expect(mockedAxiosPost).toHaveBeenCalledTimes(1); - expect(mockedAxiosPost).toHaveBeenCalledWith( + expect(getPostCalls().length).toBe(1); + expect(getPostCalls()).toContainEqual([ expect.anything(), - '"a"\n"b"\n"c"', - expect.anything() - ); + expect.objectContaining({ body: '"a"\n"b"\n"c"' }), + ]); const found = telemetryUsageCounter.incrementCounter.mock.calls.some( ([param]) => param.counterType === TelemetryCounter.DOCS_DROPPED && param.incrementBy === 1 @@ -941,11 +969,17 @@ describe('AsyncTelemetryEventsSender', () => { await service.stop(); // check that no more events are sent - expect(mockedAxiosPost).toHaveBeenCalledTimes(1); + expect(getPostCalls().length).toBe(1); }); - it('should increment runtime error counter for expected errors', async () => { - mockedAxiosPost.mockReturnValue(Promise.resolve({ status: 401 })); + it('should increment http status counter for non-2xx responses', async () => { + mockedFetch.mockImplementation(async (url: string | URL | Request) => { + const urlStr = typeof url === 'string' ? url : url.toString(); + if (urlStr.endsWith('/ping')) { + return new Response(null, { status: 200 }); + } + return new Response(null, { status: 401 }); + }); service.setup( DEFAULT_RETRY_CONFIG, @@ -970,11 +1004,20 @@ describe('AsyncTelemetryEventsSender', () => { const foundRuntime = telemetryUsageCounter.incrementCounter.mock.calls.some( ([param]) => param.counterType === TelemetryCounter.RUNTIME_ERROR && param.incrementBy === 1 ); - expect(foundRuntime).not.toBeFalsy(); + expect(foundRuntime).toBeFalsy(); + + const foundHttpStatus = telemetryUsageCounter.incrementCounter.mock.calls.some( + ([param]) => param.counterType === TelemetryCounter.HTTP_STATUS && param.incrementBy === 1 + ); + expect(foundHttpStatus).not.toBeFalsy(); }); - it('should increment fatal error counter when applies', async () => { - mockedAxiosPost.mockImplementation(() => { + it('should increment runtime error counter when fetch throws', async () => { + mockedFetch.mockImplementation(async (url: string | URL | Request) => { + const urlStr = typeof url === 'string' ? url : url.toString(); + if (urlStr.endsWith('/ping')) { + return new Response(null, { status: 200 }); + } throw Error('fatal error'); }); @@ -996,12 +1039,12 @@ describe('AsyncTelemetryEventsSender', () => { const foundFatal = telemetryUsageCounter.incrementCounter.mock.calls.some( ([param]) => param.counterType === TelemetryCounter.FATAL_ERROR && param.incrementBy === 1 ); - expect(foundFatal).not.toBeFalsy(); + expect(foundFatal).toBeFalsy(); const foundRuntime = telemetryUsageCounter.incrementCounter.mock.calls.some( ([param]) => param.counterType === TelemetryCounter.RUNTIME_ERROR && param.incrementBy === 1 ); - expect(foundRuntime).toBeFalsy(); + expect(foundRuntime).not.toBeFalsy(); }); }); @@ -1024,17 +1067,16 @@ describe('AsyncTelemetryEventsSender', () => { await jest.advanceTimersByTimeAsync(DEFAULT_QUEUE_CONFIG.bufferTimeSpanMillis * 1.1); - expect(mockedAxiosPost).toHaveBeenCalledTimes(1); + expect(getPostCalls().length).toBe(1); - expect(mockedAxiosPost).toHaveBeenCalledWith( + expect(getPostCalls()).toContainEqual([ expect.anything(), - expectedBody, - expect.anything() - ); + expect.objectContaining({ body: expectedBody }), + ]); await service.stop(); serviceV1.stop(); - expect(mockedAxiosPost).toHaveBeenCalledTimes(1); + expect(getPostCalls().length).toBe(1); }); it('should configure the default queue config in the async service', async () => { @@ -1057,20 +1099,19 @@ describe('AsyncTelemetryEventsSender', () => { // send data and wait the initial time span serviceV1.sendAsync(ch1, events); await jest.advanceTimersByTimeAsync(initialTimeSpan * 1.1); - expect(mockedAxiosPost).toHaveBeenCalledTimes(0); + expect(getPostCalls().length).toBe(0); // wait the new timespan, now we should have data await jest.advanceTimersByTimeAsync(bufferTimeSpanMillis * 1.1); - expect(mockedAxiosPost).toHaveBeenCalledTimes(1); - expect(mockedAxiosPost).toHaveBeenCalledWith( + expect(getPostCalls().length).toBe(1); + expect(getPostCalls()).toContainEqual([ expect.anything(), - expectedBody, - expect.anything() - ); + expect.objectContaining({ body: expectedBody }), + ]); await service.stop(); serviceV1.stop(); - expect(mockedAxiosPost).toHaveBeenCalledTimes(1); + expect(getPostCalls().length).toBe(1); }); it('should configure a queue config in the async service', async () => { @@ -1100,39 +1141,37 @@ describe('AsyncTelemetryEventsSender', () => { await jest.advanceTimersByTimeAsync(detectionAlertsBefore.bufferTimeSpanMillis * 1.1); - expect(mockedAxiosPost).toHaveBeenCalledTimes(1); + expect(getPostCalls().length).toBe(1); expectedBodies.forEach((expectedBody) => { - expect(mockedAxiosPost).toHaveBeenCalledWith( + expect(getPostCalls()).toContainEqual([ expect.anything(), - expectedBody, - expect.anything() - ); + expect.objectContaining({ body: expectedBody }), + ]); }); serviceV1.updateQueueConfig(channel, detectionAlertsAfter); - expect(mockedAxiosPost).toHaveBeenCalledTimes(1); + expect(getPostCalls().length).toBe(1); serviceV1.sendAsync(channel, ['a', 'b', 'c']); // the old buffer time shouldn't trigger a new buffer (we increased it) await jest.advanceTimersByTimeAsync(detectionAlertsBefore.bufferTimeSpanMillis * 1.1); - expect(mockedAxiosPost).toHaveBeenCalledTimes(1); + expect(getPostCalls().length).toBe(1); // wait more time... await jest.advanceTimersByTimeAsync(detectionAlertsAfter.bufferTimeSpanMillis); - expect(mockedAxiosPost).toHaveBeenCalledTimes(2); + expect(getPostCalls().length).toBe(2); expectedBodies.forEach((expectedBody) => { - expect(mockedAxiosPost).toHaveBeenCalledWith( + expect(getPostCalls()).toContainEqual([ expect.anything(), - expectedBody, - expect.anything() - ); + expect.objectContaining({ body: expectedBody }), + ]); }); await service.stop(); serviceV1.stop(); - expect(mockedAxiosPost).toHaveBeenCalledTimes(2); + expect(getPostCalls().length).toBe(2); }); }); @@ -1150,7 +1189,7 @@ describe('AsyncTelemetryEventsSender', () => { await service.stop(); // no events sent to the telemetry service - expect(mockedAxiosPost).toHaveBeenCalledTimes(0); + expect(getPostCalls().length).toBe(0); expect(result).toEqual(expectedResult); }); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/telemetry/async_sender.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/telemetry/async_sender.ts index 5546f30188762..023405a204dc4 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/telemetry/async_sender.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/telemetry/async_sender.ts @@ -4,7 +4,6 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import axios from 'axios'; import * as rx from 'rxjs'; import _, { cloneDeep } from 'lodash'; @@ -347,41 +346,39 @@ export class AsyncTelemetryEventsSender implements IAsyncTelemetryEventsSender { const telemetryUrl = senderMetadata.telemetryUrl; - return await axios - .post(telemetryUrl, body, { + let resp: Response; + try { + resp = await fetch(telemetryUrl, { + method: 'POST', + body, headers: { ...senderMetadata.telemetryRequestHeaders(), 'X-Telemetry-Sender': 'async', }, - timeout: 10000, - }) - .then((r) => { - this.senderUtils?.incrementCounter( - TelemetryCounter.HTTP_STATUS, - events.length, - channel, - r.status.toString() - ); + signal: AbortSignal.timeout(10000), + }); + } catch (error) { + this.senderUtils?.incrementCounter(TelemetryCounter.RUNTIME_ERROR, events.length, channel); - if (r.status < 400) { - return { events: events.length, channel }; - } else { - this.logger.warn('Unexpected response', { - status: r.status, - } as LogMeta); - throw newFailure(`Got ${r.status}`, channel, events.length); - } - }) - .catch((error) => { - this.senderUtils?.incrementCounter( - TelemetryCounter.RUNTIME_ERROR, - events.length, - channel - ); + this.logger.warn('Runtime error', withErrorMessage(error)); + throw newFailure(`Error posting events: ${error}`, channel, events.length); + } - this.logger.warn('Runtime error', withErrorMessage(error)); - throw newFailure(`Error posting events: ${error}`, channel, events.length); - }); + this.senderUtils?.incrementCounter( + TelemetryCounter.HTTP_STATUS, + events.length, + channel, + resp.status.toString() + ); + + if (resp.status < 400) { + return { events: events.length, channel }; + } else { + this.logger.warn('Unexpected response', { + status: resp.status, + } as LogMeta); + throw newFailure(`Got ${resp.status}`, channel, events.length); + } } catch (err: unknown) { if (isFailure(err)) { throw err; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/telemetry/preview_sender.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/telemetry/preview_sender.ts index ad142b9032903..7e2e4893eb947 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/telemetry/preview_sender.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/telemetry/preview_sender.ts @@ -5,8 +5,6 @@ * 2.0. */ -import type { AxiosInstance, AxiosResponse } from 'axios'; -import axios, { AxiosHeaders } from 'axios'; import type { EventTypeOpts, Logger } from '@kbn/core/server'; import type { TelemetryPluginStart, TelemetryPluginSetup } from '@kbn/telemetry-plugin/server'; import type { UsageCounter } from '@kbn/usage-collection-plugin/server'; @@ -18,7 +16,6 @@ import type { import type { ITelemetryEventsSender } from './sender'; import { TelemetryChannel, type TelemetryEvent } from './types'; import type { ITelemetryReceiver } from './receiver'; -import { tlog } from './helpers'; import type { QueueConfig } from './async_sender.types'; /** @@ -29,61 +26,14 @@ export class PreviewTelemetryEventsSender implements ITelemetryEventsSender { /** Inner composite telemetry events sender */ private composite: ITelemetryEventsSender; - /** - * Axios local instance - * @deprecated `IAsyncTelemetryEventsSender` has a dedicated method for preview. */ - private axiosInstance = axios.create(); - /** Last sent message */ private sentMessages: string[] = []; /** Last sent EBT events */ private ebtEventsSent: Array<{ eventType: string; eventData: object }> = []; - /** Logger for this class */ - private logger: Logger; - - constructor(logger: Logger, composite: ITelemetryEventsSender) { - this.logger = logger; + constructor(_logger: Logger, composite: ITelemetryEventsSender) { this.composite = composite; - - /** - * Intercept the last message and save it for the preview within the lastSentMessage - * Reject the request intentionally to stop from sending to the server - */ - this.axiosInstance.interceptors.request.use((config) => { - tlog( - this.logger, - `Intercepting telemetry', ${JSON.stringify( - config.data - )} and not sending data to the telemetry server` - ); - const data = config.data != null ? [config.data] : []; - this.sentMessages = [...this.sentMessages, ...data]; - return Promise.reject(new Error('Not sending to telemetry server')); - }); - - /** - * Create a fake response for the preview on return within the error section. - * @param error The error we don't do anything with - * @returns The response resolved to stop the chain from continuing. - */ - this.axiosInstance.interceptors.response.use( - (response) => response, - (error) => { - // create a fake response for the preview as if the server had sent it back to us - const okResponse: AxiosResponse = { - data: {}, - status: 200, - statusText: 'ok', - headers: {}, - config: { - headers: new AxiosHeaders(), - }, - }; - return Promise.resolve(okResponse); - } - ); } public getSentMessages() { @@ -142,8 +92,8 @@ export class PreviewTelemetryEventsSender implements ITelemetryEventsSender { return this.composite.isTelemetryServicesReachable(); } - public sendIfDue(axiosInstance?: AxiosInstance): Promise { - return this.composite.sendIfDue(axiosInstance); + public sendIfDue(): Promise { + return this.composite.sendIfDue(); } public processEvents(events: TelemetryEvent[]): TelemetryEvent[] { diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/telemetry/sender.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/telemetry/sender.ts index 2a2f3b3a68d81..1ac545929624a 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/telemetry/sender.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/telemetry/sender.ts @@ -12,8 +12,6 @@ import { transformDataToNdjson } from '@kbn/securitysolution-utils'; import type { EventTypeOpts, Logger, LogMeta } from '@kbn/core/server'; import type { TelemetryPluginStart, TelemetryPluginSetup } from '@kbn/telemetry-plugin/server'; import type { UsageCounter } from '@kbn/usage-collection-plugin/server'; -import type { AxiosInstance } from 'axios'; -import axios from 'axios'; import type { TaskManagerSetupContract, TaskManagerStartContract, @@ -64,9 +62,9 @@ export interface ITelemetryEventsSender { queueTelemetryEvents(events: TelemetryEvent[]): void; isTelemetryOptedIn(): Promise; isTelemetryServicesReachable(): Promise; - sendIfDue(axiosInstance?: AxiosInstance): Promise; + sendIfDue(): Promise; processEvents(events: TelemetryEvent[]): TelemetryEvent[]; - sendOnDemand(channel: string, toSend: unknown[], axiosInstance?: AxiosInstance): Promise; + sendOnDemand(channel: string, toSend: unknown[]): Promise; getV3UrlFromV2(v2url: string, channel: string): string; // As a transition to the new sender, `IAsyncTelemetryEventsSender`, we wrap @@ -281,7 +279,7 @@ export class TelemetryEventsSender implements ITelemetryEventsSender { public async isTelemetryServicesReachable() { try { const telemetryUrl = await this.fetchTelemetryPingUrl(); - const resp = await axios.get(telemetryUrl, { timeout: 3000 }); + const resp = await fetch(telemetryUrl, { signal: AbortSignal.timeout(3000) }); if (resp.status === 200) { this.logger.debug('Elastic telemetry services are reachable'); return true; @@ -295,7 +293,7 @@ export class TelemetryEventsSender implements ITelemetryEventsSender { } } - public async sendIfDue(axiosInstance: AxiosInstance = axios) { + public async sendIfDue() { if (this.isSending) { return; } @@ -349,8 +347,7 @@ export class TelemetryEventsSender implements ITelemetryEventsSender { clusterInfo?.cluster_uuid, clusterInfo?.cluster_name, clusterInfo?.version?.number, - licenseInfo?.uid, - axiosInstance + licenseInfo?.uid ); } catch (error) { this.logger.warn('Error sending telemetry events data', withErrorMessage(error)); @@ -373,11 +370,7 @@ export class TelemetryEventsSender implements ITelemetryEventsSender { * @param channel the elastic telemetry channel * @param toSend telemetry events */ - public async sendOnDemand( - channel: string, - toSend: unknown[], - axiosInstance: AxiosInstance = axios - ) { + public async sendOnDemand(channel: string, toSend: unknown[]) { const clusterInfo = this.receiver?.getClusterInfo(); try { const [telemetryUrl, licenseInfo] = await Promise.all([ @@ -396,8 +389,7 @@ export class TelemetryEventsSender implements ITelemetryEventsSender { clusterInfo?.cluster_uuid, clusterInfo?.cluster_name, clusterInfo?.version?.number, - licenseInfo?.uid, - axiosInstance + licenseInfo?.uid ); } catch (error) { this.logger.warn('Error sending telemetry events data', withErrorMessage(error)); @@ -469,8 +461,7 @@ export class TelemetryEventsSender implements ITelemetryEventsSender { clusterUuid: string | undefined, clusterName: string | undefined, clusterVersionNumber: string | undefined, - licenseId: string | undefined, - axiosInstance: AxiosInstance = axios + licenseId: string | undefined ) { const ndjson = transformDataToNdjson(events); @@ -479,7 +470,9 @@ export class TelemetryEventsSender implements ITelemetryEventsSender { events: events.length, channel, } as LogMeta); - const resp = await axiosInstance.post(telemetryUrl, ndjson, { + const resp = await fetch(telemetryUrl, { + method: 'POST', + body: ndjson, headers: { 'Content-Type': 'application/x-ndjson', ...(clusterUuid ? { 'X-Elastic-Cluster-ID': clusterUuid } : undefined), @@ -487,13 +480,16 @@ export class TelemetryEventsSender implements ITelemetryEventsSender { 'X-Elastic-Stack-Version': clusterVersionNumber ? clusterVersionNumber : '8.0.0', ...(licenseId ? { 'X-Elastic-License-ID': licenseId } : {}), }, - timeout: 10000, + signal: AbortSignal.timeout(10000), }); this.telemetryUsageCounter?.incrementCounter({ counterName: createUsageCounterLabel(usageLabelPrefix.concat(['payloads', channel])), counterType: resp.status.toString(), incrementBy: 1, }); + if (!resp.ok) { + throw new Error(`Request failed with status ${resp.status}`); + } this.telemetryUsageCounter?.incrementCounter({ counterName: createUsageCounterLabel(usageLabelPrefix.concat(['payloads', channel])), counterType: 'docs_sent', @@ -502,14 +498,6 @@ export class TelemetryEventsSender implements ITelemetryEventsSender { this.logger.debug('Events sent!. Response', { status: resp.status } as LogMeta); } catch (error) { this.logger.warn('Error sending events', withErrorMessage(error)); - const errorStatus = error?.response?.status; - if (errorStatus !== undefined && errorStatus !== null) { - this.telemetryUsageCounter?.incrementCounter({ - counterName: createUsageCounterLabel(usageLabelPrefix.concat(['payloads', channel])), - counterType: errorStatus.toString(), - incrementBy: 1, - }); - } this.telemetryUsageCounter?.incrementCounter({ counterName: createUsageCounterLabel(usageLabelPrefix.concat(['payloads', channel])), counterType: 'docs_lost', diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/telemetry/sender_helpers.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/telemetry/sender_helpers.ts index 2471b3463eb8f..7d4bb9792f2d2 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/telemetry/sender_helpers.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/telemetry/sender_helpers.ts @@ -4,10 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import axios from 'axios'; - import type { TelemetryPluginStart, TelemetryPluginSetup } from '@kbn/telemetry-plugin/server'; -import type { RawAxiosRequestHeaders } from 'axios'; import { type IUsageCounter } from '@kbn/usage-collection-plugin/server/usage_counters/usage_counter'; import type { ITelemetryReceiver } from './receiver'; import type { @@ -23,7 +20,7 @@ export interface SenderMetadata { telemetryUrl: string; licenseInfo: Nullable; clusterInfo: Nullable; - telemetryRequestHeaders: () => RawAxiosRequestHeaders; + telemetryRequestHeaders: () => Record; isTelemetryOptedIn(): Promise; isTelemetryServicesReachable(): Promise; } @@ -74,7 +71,7 @@ export class SenderUtils { try { const telemetryPingUrl = await this.fetchTelemetryPingUrl(); - const resp = await axios.get(telemetryPingUrl, { timeout: 3000 }); + const resp = await fetch(telemetryPingUrl, { signal: AbortSignal.timeout(3000) }); if (resp.status === 200) { return true; } diff --git a/x-pack/solutions/security/test/security_solution_api_integration/config/services/spaces_service.ts b/x-pack/solutions/security/test/security_solution_api_integration/config/services/spaces_service.ts index 74f6baf284a13..acb4d9b923feb 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/config/services/spaces_service.ts +++ b/x-pack/solutions/security/test/security_solution_api_integration/config/services/spaces_service.ts @@ -6,8 +6,7 @@ */ import type { Space } from '@kbn/spaces-plugin/common'; -import Axios from 'axios'; -import Https from 'https'; +import { Agent } from 'undici'; import { format as formatUrl } from 'url'; import util from 'util'; import Chance from 'chance'; @@ -35,31 +34,59 @@ export function SpacesServiceProvider({ getService }: FtrProviderContext) { 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: Agent | 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 defaultHeaders: Record = { + 'kbn-xsrf': 'x-pack/ftr/services/spaces/space', + }; + + const fetchWithDefaults = async ( + path: string, + options: RequestInit = {} + ): Promise<{ data: unknown; status: number; statusText: string }> => { + const fetchUrl = `${url}${path}`; + const fetchOptions: RequestInit & { dispatcher?: Agent } = { + ...options, + headers: { + ...defaultHeaders, + 'Content-Type': 'application/json', + ...(options.headers as Record | undefined), + }, + }; + + if (dispatcher) { + fetchOptions.dispatcher = dispatcher; + } + + const response = await fetch(fetchUrl, fetchOptions); + let data: unknown; + const contentType = response.headers.get('content-type') ?? ''; + if (contentType.includes('application/json')) { + data = await response.json(); + } else { + data = await response.text(); + } + + return { data, 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 fetchWithDefaults('/api/spaces/space', { + method: 'POST', + body: JSON.stringify(space), + }); if (status !== 200) { throw new Error( @@ -84,9 +111,12 @@ 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 fetchWithDefaults( `/api/spaces/space/${id}?overwrite=${overwrite}`, - updatedSpace + { + method: 'PUT', + body: JSON.stringify(updatedSpace), + } ); if (status !== 200) { @@ -99,7 +129,9 @@ 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 fetchWithDefaults(`/api/spaces/space/${spaceId}`, { + method: 'DELETE', + }); if (status !== 204) { log.debug( @@ -111,7 +143,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 fetchWithDefaults(`/api/spaces/space/${id}`); if (status !== 200) { throw new Error( @@ -120,21 +152,21 @@ export function SpacesServiceProvider({ getService }: FtrProviderContext) { } log.debug(`retrieved space ${id}`); - return data; + return data as Space; } public async getAll() { log.debug('retrieving all spaces'); - const { data, status, statusText } = await axios.get('/api/spaces/space'); + const { data, status, statusText } = await fetchWithDefaults('/api/spaces/space'); if (status !== 200) { throw new Error( `Expected status code of 200, received ${status} ${statusText}: ${util.inspect(data)}` ); } - log.debug(`retrieved ${data.length} spaces`); + log.debug(`retrieved ${(data as Space[]).length} spaces`); - return data; + return data as Space[]; } /** Return the full URL that points to the root of the space */ diff --git a/x-pack/solutions/security/test/security_solution_cypress/cypress/support/saml_auth.ts b/x-pack/solutions/security/test/security_solution_cypress/cypress/support/saml_auth.ts index 036d1f65a48c4..35e1de5fe9c25 100644 --- a/x-pack/solutions/security/test/security_solution_cypress/cypress/support/saml_auth.ts +++ b/x-pack/solutions/security/test/security_solution_cypress/cypress/support/saml_auth.ts @@ -14,7 +14,6 @@ import type { HostOptions } from '@kbn/test-saml-auth'; import { SamlSessionManager } from '@kbn/test-saml-auth'; import { REPO_ROOT } from '@kbn/repo-info'; import { resolve } from 'path'; -import axios from 'axios'; import fs from 'fs'; import yaml from 'js-yaml'; import { DEFAULT_SERVERLESS_ROLE } from '../env_var_names_constants'; @@ -85,22 +84,25 @@ export const samlAuthentication = async ( roleDescriptor = { [role]: roleConfig }; - const response = await axios.post( - `${kbnHost}/internal/security/api_key`, - { + const response = await fetch(`${kbnHost}/internal/security/api_key`, { + method: 'POST', + body: JSON.stringify({ name: 'myTestApiKey', metadata: {}, role_descriptors: roleDescriptor, + }), + headers: { + 'content-type': 'application/json', + ...INTERNAL_REQUEST_HEADERS, + ...adminCookieHeader, }, - { - headers: { - ...INTERNAL_REQUEST_HEADERS, - ...adminCookieHeader, - }, - } - ); - - const apiKey = response.data.encoded; + }); + if (!response.ok) { + throw new Error(`Failed to create API key: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + const apiKey = data.encoded; return apiKey; }, createServerlessCustomRole: async ({ @@ -115,34 +117,36 @@ export const samlAuthentication = async ( elasticsearch: roleDescriptor.elasticsearch ?? [], }; - const response = await axios.put( - `${kbnHost}/api/security/role/${roleName}`, - customRoleDescriptors, - { - headers: { - ...INTERNAL_REQUEST_HEADERS, - ...adminCookieHeader, - }, - } - ); + const response = await fetch(`${kbnHost}/api/security/role/${roleName}`, { + method: 'PUT', + body: JSON.stringify(customRoleDescriptors), + headers: { + 'content-type': 'application/json', + ...INTERNAL_REQUEST_HEADERS, + ...adminCookieHeader, + }, + }); + const data = await response.json(); return { status: response.status, - data: response.data, + data, }; }, deleteServerlessCustomRole: async ( roleName: string ): Promise<{ status: number; data: unknown }> => { - const response = await axios.delete(`${kbnHost}/api/security/role/${roleName}`, { + const response = await fetch(`${kbnHost}/api/security/role/${roleName}`, { + method: 'DELETE', headers: { ...INTERNAL_REQUEST_HEADERS, ...adminCookieHeader, }, }); + const data = await response.json(); return { status: response.status, - data: response.data, + data, }; }, getFullname: async (