From 29e37920c0a1d2788c9475cf2bb8504a88e312e4 Mon Sep 17 00:00:00 2001 From: Cacie Prins Date: Fri, 18 Apr 2025 09:29:17 -0400 Subject: [PATCH 1/3] set rejectUnauthorized=true on agents for axios --- packages/network/lib/agent.ts | 4 +- .../server/lib/cloud/api/cloud_request.ts | 20 ++++- packages/server/lib/cloud/api/index.ts | 9 ++- .../server/test/unit/cloud/api/api_spec.js | 74 +++++++++++-------- .../test/unit/cloud/api/cloud_request_spec.ts | 11 +++ 5 files changed, 76 insertions(+), 42 deletions(-) diff --git a/packages/network/lib/agent.ts b/packages/network/lib/agent.ts index 0b3d55f79aa4..259c5d34e5af 100644 --- a/packages/network/lib/agent.ts +++ b/packages/network/lib/agent.ts @@ -257,7 +257,7 @@ const getProxyOrTargetOverrideForUrl = (href) => { return getProxyForUrl(href) } -class HttpAgent extends http.Agent { +export class HttpAgent extends http.Agent { httpsAgent: https.Agent constructor (opts: http.AgentOptions = {}) { @@ -317,7 +317,7 @@ class HttpAgent extends http.Agent { } } -class HttpsAgent extends https.Agent { +export class HttpsAgent extends https.Agent { constructor (opts: https.AgentOptions = {}) { opts.keepAlive = true super(opts) diff --git a/packages/server/lib/cloud/api/cloud_request.ts b/packages/server/lib/cloud/api/cloud_request.ts index 3f74da4c0be6..867eff1df32d 100644 --- a/packages/server/lib/cloud/api/cloud_request.ts +++ b/packages/server/lib/cloud/api/cloud_request.ts @@ -6,7 +6,7 @@ import os from 'os' import axios, { AxiosInstance } from 'axios' import pkg from '@packages/root' -import { httpAgent, httpsAgent } from '@packages/network/lib/agent' +import { HttpAgent, HttpsAgent } from '@packages/network/lib/agent' import app_config from '../../../config/app.json' import { installErrorTransform } from './axios_middleware/transform_error' @@ -18,18 +18,30 @@ export const _create = (): AxiosInstance => { const instance = axios.create({ baseURL: app_config[cfgKey].api_url, - httpAgent, - httpsAgent, + httpAgent: new HttpAgent(), + httpsAgent: new HttpsAgent({ rejectUnauthorized: true }), headers: { - 'x-os-name': os.platform(), 'x-cypress-version': pkg.version, 'User-Agent': `cypress/${pkg.version}`, + 'x-os-name': os.platform(), }, + // this must be disabled, because we are using our own agents + proxy: false, }) installLogging(instance) installErrorTransform(instance) + // Because of how sinon.stub works, this needs to query `os` at + // request time rather than creation time + if (process.env.CYPRESS_INTERNAL_ENV === 'test') { + instance.interceptors.request.use((cfg) => { + cfg.headers['x-os-name'] = os.platform() + + return cfg + }) + } + return instance } diff --git a/packages/server/lib/cloud/api/index.ts b/packages/server/lib/cloud/api/index.ts index 612ea445287a..c44d8da431b3 100644 --- a/packages/server/lib/cloud/api/index.ts +++ b/packages/server/lib/cloud/api/index.ts @@ -17,7 +17,7 @@ import Bluebird from 'bluebird' import type { AfterSpecDurations } from '@packages/types' import { agent } from '@packages/network' import type { CombinedAgent } from '@packages/network/lib/agent' - +import { CloudRequest } from './cloud_request' import { apiUrl, apiRoutes, makeRoutes } from '../routes' import { getText } from '../../util/status_code' import * as enc from '../encryption' @@ -363,9 +363,10 @@ export default { } }, - ping () { - return rp.get(apiRoutes.ping()) - .catch(tagError) + async ping () { + const { data } = await CloudRequest.get(apiRoutes.ping()) + + return Bluebird.resolve(data) }, getAuthUrls () { diff --git a/packages/server/test/unit/cloud/api/api_spec.js b/packages/server/test/unit/cloud/api/api_spec.js index 3557728da314..884ea19ab244 100644 --- a/packages/server/test/unit/cloud/api/api_spec.js +++ b/packages/server/test/unit/cloud/api/api_spec.js @@ -9,9 +9,6 @@ const _ = require('lodash') const os = require('os') const encryption = require('../../../../lib/cloud/encryption') -const { - agent, -} = require('@packages/network') const pkg = require('@packages/root') const api = require('../../../../lib/cloud/api').default const cache = require('../../../../lib/cache').cache @@ -28,6 +25,8 @@ const AUTH_URLS = { 'dashboardLogoutUrl': 'http://localhost:3000/logout', } +const { httpAgent, default: agent } = require('@packages/network/lib/agent') + const { PROTOCOL_STUB_VALID, } = require('@tooling/system-tests/lib/protocol-stubs/protocolStubResponse') @@ -141,36 +140,44 @@ describe('lib/cloud/api', () => { context('.rp', () => { beforeEach(() => { sinon.spy(agent, 'addRequest') + sinon.spy(httpAgent, 'addRequest') return nock.enableNetConnect() }) // nock will prevent requests from reaching the agent - it('makes calls using the correct agent', () => { + it('makes calls using the correct agent', async () => { nock.cleanAll() - return api.ping() - .thenThrow() - .catch(() => { - expect(agent.addRequest).to.be.calledOnce - - expect(agent.addRequest).to.be.calledWithMatch(sinon.match.any, { - href: 'http://localhost:1234/ping', + try { + await api.ping() + } catch (e) { + // noop + } finally { + expect(httpAgent.addRequest).to.be.calledOnce + + expect(httpAgent.addRequest).to.be.calledWithMatch(sinon.match.any, { + host: 'localhost', + port: '1234', + path: '/ping', }) - }) + } }) - it('sets rejectUnauthorized on the request', () => { + // .ping() uses axios, which sets rejectUnauthorized on the agents, rather than the + // request. + // eslint-disable-next-line @cypress/dev/skip-comment + it.skip('sets rejectUnauthorized on the request', async () => { nock.cleanAll() - return api.ping() - .thenThrow() - .catch(() => { - expect(agent.addRequest).to.be.calledOnce + try { + await api.ping() + } catch (e) { + // noop + } finally { + expect(httpAgent.addRequest).to.be.calledOnce - expect(agent.addRequest).to.be.calledWithMatch(sinon.match.any, { - rejectUnauthorized: true, - }) - }) + expect(httpAgent.addRequest.firstCall.firstArg.rejectUnauthorized).to.be.true + } }) context('with a proxy defined', () => { @@ -178,19 +185,22 @@ describe('lib/cloud/api', () => { nock.cleanAll() }) - it('makes calls using the correct agent', () => { - process.env.HTTP_PROXY = (process.env.HTTPS_PROXY = 'http://foo.invalid:1234') - process.env.NO_PROXY = '' - - return api.ping() - .thenThrow() - .catch(() => { - expect(agent.addRequest).to.be.calledOnce + it('makes calls using the correct agent', async () => { + nock.cleanAll() - expect(agent.addRequest).to.be.calledWithMatch(sinon.match.any, { - href: 'http://localhost:1234/ping', + try { + await api.ping() + } catch (e) { + // noop + } finally { + expect(httpAgent.addRequest).to.be.calledOnce + + expect(httpAgent.addRequest).to.be.calledWithMatch(sinon.match.any, { + host: 'localhost', + port: '1234', + path: '/ping', }) - }) + } }) }) }) diff --git a/packages/server/test/unit/cloud/api/cloud_request_spec.ts b/packages/server/test/unit/cloud/api/cloud_request_spec.ts index 50a99551fdd5..64218eb12e7d 100644 --- a/packages/server/test/unit/cloud/api/cloud_request_spec.ts +++ b/packages/server/test/unit/cloud/api/cloud_request_spec.ts @@ -9,15 +9,25 @@ import os from 'os' import pkg from '@packages/root' import { transformError } from '../../../../lib/cloud/api/axios_middleware/transform_error' +import * as agents from '@packages/network/lib/agent' + chai.use(sinonChai) describe('CloudRequest', () => { beforeEach(() => { sinon.stub(axios, 'create').callThrough() + sinon.stub(agents, 'HttpAgent').returns(agents.httpAgent) + sinon.stub(agents, 'HttpsAgent').returns(agents.httpsAgent) }) afterEach(() => { (axios.create as sinon.SinonStub).restore() + + // @ts-expect-error - this is an unusual usage of stubbing + ;(agents.HttpsAgent as sinon.SinonStub).restore() + + // @ts-expect-error - this is an unusual usage of stubbing + ;(agents.HttpAgent as sinon.SinonStub).restore() }) const getCreatedConfig = (): CreateAxiosDefaults => { @@ -30,6 +40,7 @@ describe('CloudRequest', () => { _create() const cfg = getCreatedConfig() + expect(agents.HttpsAgent).to.have.been.calledWith({ rejectUnauthorized: true }) expect(cfg.httpAgent).to.eq(httpAgent) expect(cfg.httpsAgent).to.eq(httpsAgent) }) From 5c86e1c4af922102a44594b7a2836ad3b5a5045d Mon Sep 17 00:00:00 2001 From: Cacie Prins Date: Wed, 23 Apr 2025 11:39:13 -0400 Subject: [PATCH 2/3] refactor: migrate preflight logic to axios middleware - Move preflight logic from api/index.ts to dedicated middleware class - Replace request-promise with axios interceptors - Implement two-phase retry strategy: - Initial attempt with api-proxy - Retry with asyncRetry for fallback - Store preflight state in axios instance defaults - Add proper type checking for preflight state - Update tests to verify middleware behavior - Remove module-level state in favor of instance state --- .../cloud/api/axios_middleware/preflight.ts | 119 ++++++ .../server/lib/cloud/api/cloud_request.ts | 6 +- .../api/{ => endpoints}/create_instance.ts | 6 +- .../lib/cloud/api/endpoints/post_preflight.ts | 64 ++++ packages/server/lib/cloud/api/index.ts | 12 +- packages/server/lib/cloud/environment.ts | 13 +- packages/server/lib/cloud/routes.ts | 4 + .../server/test/unit/cloud/api/api_spec.js | 77 ++-- .../api/axios_middleware/preflight_spec.ts | 348 ++++++++++++++++++ .../transform_error_spec.ts | 2 +- .../unit/cloud/api/create_instance_spec.ts | 4 +- .../api/endpoints/post_preflight_spec.ts | 251 +++++++++++++ 12 files changed, 848 insertions(+), 58 deletions(-) create mode 100644 packages/server/lib/cloud/api/axios_middleware/preflight.ts rename packages/server/lib/cloud/api/{ => endpoints}/create_instance.ts (89%) create mode 100644 packages/server/lib/cloud/api/endpoints/post_preflight.ts create mode 100644 packages/server/test/unit/cloud/api/axios_middleware/preflight_spec.ts rename packages/server/test/unit/cloud/api/{ => axios_middleware}/transform_error_spec.ts (96%) create mode 100644 packages/server/test/unit/cloud/api/endpoints/post_preflight_spec.ts diff --git a/packages/server/lib/cloud/api/axios_middleware/preflight.ts b/packages/server/lib/cloud/api/axios_middleware/preflight.ts new file mode 100644 index 000000000000..684719f1f61d --- /dev/null +++ b/packages/server/lib/cloud/api/axios_middleware/preflight.ts @@ -0,0 +1,119 @@ +import { InternalAxiosRequestConfig, AxiosInstance, AxiosResponse, isAxiosError } from 'axios' +import { getApiUrl } from '../../routes' +import { postPreflight, PreflightRequestBody, PreflightOptions } from '../endpoints/post_preflight' +import { isArray, isObject } from 'lodash' +import { asyncRetry, exponentialBackoff } from '../../../util/async_retry' +import Debug from 'debug' + +const debug = Debug('cypress:server:cloud:api:axios_middleware:preflight') + +interface PreflightWarning { + message: string +} + +interface PreflightState { + encrypt: boolean + apiUrl: string + warnings?: PreflightWarning[] +} + +declare module 'axios' { + interface AxiosRequestConfig { + requirePreflight?: boolean + preflightState?: PreflightState + appendPreflightWarnings?: boolean + } +} + +export class PreflightMiddleware { + private projectAttributes: PreflightRequestBody | undefined + constructor (private axios: AxiosInstance) { + + } + + setProjectAttributes (attributes: PreflightRequestBody) { + this.projectAttributes = attributes + } + + async requestInterceptor (cfg: InternalAxiosRequestConfig): Promise { + if (!cfg.requirePreflight) { + return cfg + } + + if (cfg.requirePreflight && cfg.preflightState) { + cfg.baseURL = cfg.preflightState.apiUrl + + return cfg + } + + if (!this.projectAttributes) { + debug('Preflight middleware skipping request because no project attributes are set') + + return cfg + } + + const projectAttributes: PreflightRequestBody = this.projectAttributes + + try { + const options: PreflightOptions = { + apiUrl: getApiUrl().replace('api', 'api-proxy'), + attempt: 1, + } + + const preflightState = await postPreflight(projectAttributes, options) + + cfg.preflightState = preflightState + this.axios.defaults.preflightState = preflightState + } catch (e) { + if (isAxiosError(e) && e.status === 412) { + throw e + } + + let attempt = 0 + const retryPreflight = asyncRetry( + async () => { + attempt++ + + return postPreflight(projectAttributes, { + apiUrl: getApiUrl(), + httpAgent: this.axios.defaults.httpAgent, + httpsAgent: this.axios.defaults.httpsAgent, + attempt, + }) + }, + { + maxAttempts: 3, // Will make 3 total attempts (initial + 2 retries) + retryDelay: exponentialBackoff({ factor: 1000, fuzz: 0.1 }), // Start with 1 second, double each time + shouldRetry: (err) => { + if (isAxiosError(err) && err.status === 412) { + return false // Don't retry on 412 errors + } + + return true + }, + }, + ) + + const preflightState = await retryPreflight() + + cfg.preflightState = preflightState + this.axios.defaults.preflightState = preflightState + } + + return cfg + } + + responseInterceptor (res: AxiosResponse): AxiosResponse { + if (res.config.appendPreflightWarnings && res.config.preflightState?.warnings) { + if (isArray(res.data.warnings)) { + res.data.warnings = (res.data.warnings as PreflightWarning[]).concat(res.config.preflightState?.warnings) + } else if (isObject(res.data.warnings)) { + res.data.warnings = [res.data.warnings as PreflightWarning, ...(res.config.preflightState.warnings as PreflightWarning[])] + } else { + res.data.warnings = res.config.preflightState?.warnings + } + } + + return res + } +} diff --git a/packages/server/lib/cloud/api/cloud_request.ts b/packages/server/lib/cloud/api/cloud_request.ts index 867eff1df32d..eddc28f501df 100644 --- a/packages/server/lib/cloud/api/cloud_request.ts +++ b/packages/server/lib/cloud/api/cloud_request.ts @@ -13,13 +13,13 @@ import { installErrorTransform } from './axios_middleware/transform_error' import { installLogging } from './axios_middleware/logging' // initialized with an export for testing purposes -export const _create = (): AxiosInstance => { +export const _create = (withAgents: boolean = true): AxiosInstance => { const cfgKey = process.env.CYPRESS_CONFIG_ENV || process.env.CYPRESS_INTERNAL_ENV || 'development' const instance = axios.create({ baseURL: app_config[cfgKey].api_url, - httpAgent: new HttpAgent(), - httpsAgent: new HttpsAgent({ rejectUnauthorized: true }), + httpAgent: withAgents ? new HttpAgent() : undefined, + httpsAgent: withAgents ? new HttpsAgent({ rejectUnauthorized: true }) : undefined, headers: { 'x-cypress-version': pkg.version, 'User-Agent': `cypress/${pkg.version}`, diff --git a/packages/server/lib/cloud/api/create_instance.ts b/packages/server/lib/cloud/api/endpoints/create_instance.ts similarity index 89% rename from packages/server/lib/cloud/api/create_instance.ts rename to packages/server/lib/cloud/api/endpoints/create_instance.ts index 4ef74f73d3eb..c294d745d3fd 100644 --- a/packages/server/lib/cloud/api/create_instance.ts +++ b/packages/server/lib/cloud/api/endpoints/create_instance.ts @@ -1,6 +1,6 @@ -import { CloudRequest, isRetryableCloudError } from './cloud_request' -import { asyncRetry, exponentialBackoff } from '../../util/async_retry' -import * as errors from '../../errors' +import { CloudRequest, isRetryableCloudError } from '../cloud_request' +import { asyncRetry, exponentialBackoff } from '../../../util/async_retry' +import * as errors from '../../../errors' import { isAxiosError } from 'axios' const MAX_RETRIES = 3 diff --git a/packages/server/lib/cloud/api/endpoints/post_preflight.ts b/packages/server/lib/cloud/api/endpoints/post_preflight.ts new file mode 100644 index 000000000000..05c5a0e24820 --- /dev/null +++ b/packages/server/lib/cloud/api/endpoints/post_preflight.ts @@ -0,0 +1,64 @@ +import axios from 'axios' +import { isArray, isBoolean, isString, isObject } from 'lodash' +import type { HttpAgent, HttpsAgent } from '@packages/network/lib/agent' + +export interface PreflightWarning { + message: string +} + +export interface PreflightState { + encrypt: boolean + apiUrl: string + warnings?: PreflightWarning[] +} + +export interface PreflightRequestBody { + projectId: string + projectRoot: string + ciBuildId: string + browser: Record + testingType: 'e2e' | 'component' + parallel: boolean +} + +export interface PreflightOptions { + apiUrl: string + attempt?: number + httpAgent?: HttpAgent + httpsAgent?: HttpsAgent + timeout?: number +} + +function isValidPreflightState (state: unknown): state is PreflightState { + if (!isObject(state)) return false + + const s = state as Record + + return isBoolean(s.encrypt) && + isString(s.apiUrl) && + (!s.warnings || (isArray(s.warnings) && s.warnings.every((warning) => { + return isObject(warning) && isString((warning as Record).message) + }))) +} + +export async function postPreflight (body: PreflightRequestBody, options: PreflightOptions): Promise { + const instance = axios.create({ + baseURL: options.apiUrl, + httpAgent: options.httpAgent, + httpsAgent: options.httpsAgent, + }) + + const response = await instance.post('/preflight', body, { + headers: { + 'x-route-version': '1', + 'x-cypress-request-attempt': options.attempt?.toString() ?? '1', + }, + timeout: options.timeout, + }) + + if (!isValidPreflightState(response.data)) { + throw new TypeError('Invalid preflight state received from server') + } + + return response.data +} diff --git a/packages/server/lib/cloud/api/index.ts b/packages/server/lib/cloud/api/index.ts index c44d8da431b3..59a505426d53 100644 --- a/packages/server/lib/cloud/api/index.ts +++ b/packages/server/lib/cloud/api/index.ts @@ -15,9 +15,8 @@ const errors = require('../../errors') import Bluebird from 'bluebird' import type { AfterSpecDurations } from '@packages/types' -import { agent } from '@packages/network' +import { default as agent } from '@packages/network/lib/agent' import type { CombinedAgent } from '@packages/network/lib/agent' -import { CloudRequest } from './cloud_request' import { apiUrl, apiRoutes, makeRoutes } from '../routes' import { getText } from '../../util/status_code' import * as enc from '../encryption' @@ -33,7 +32,7 @@ import { PUBLIC_KEY_VERSION } from '../constants' // axios implementation disabled until proxy issues can be diagnosed/fixed // TODO: https://github.com/cypress-io/cypress/issues/31490 //import { createInstance } from './create_instance' -import type { CreateInstanceRequestBody, CreateInstanceResponse } from './create_instance' +import type { CreateInstanceRequestBody, CreateInstanceResponse } from './endpoints/create_instance' import { transformError } from './axios_middleware/transform_error' @@ -73,7 +72,7 @@ export interface CypressRequestOptions extends OptionsWithUrl { } // TODO: migrate to fetch from @cypress/request -const rp = request.defaults((params: CypressRequestOptions, callback) => { +export const rp = request.defaults((params: CypressRequestOptions, callback) => { let resp if (params.cacheable && (resp = getCachedResponse(params))) { @@ -364,9 +363,8 @@ export default { }, async ping () { - const { data } = await CloudRequest.get(apiRoutes.ping()) - - return Bluebird.resolve(data) + return rp.get(apiRoutes.ping()) + .catch(tagError) }, getAuthUrls () { diff --git a/packages/server/lib/cloud/environment.ts b/packages/server/lib/cloud/environment.ts index f86a2609b0d8..26ad8584159f 100644 --- a/packages/server/lib/cloud/environment.ts +++ b/packages/server/lib/cloud/environment.ts @@ -79,7 +79,18 @@ interface DependencyInformation { } // See https://whimsical.com/encryption-logic-BtJJkN7TxacK8kaHDgH1zM for more information on what this is doing -const getEnvInformationForProjectRoot = async (projectRoot: string, pid: string) => { +interface EnvInformation { + envUrl: string | undefined + errors: { + dependency?: string | undefined + name: string + message: string + stack: string + }[] + dependencies: {} +} + +const getEnvInformationForProjectRoot = async (projectRoot: string, pid: string): Promise => { let dependencies = {} let errors: { dependency?: string, name: string, message: string, stack: string }[] = [] let envDependencies = process.env.CYPRESS_ENV_DEPENDENCIES diff --git a/packages/server/lib/cloud/routes.ts b/packages/server/lib/cloud/routes.ts index 5d0d96a99b72..1a0b6104b54f 100644 --- a/packages/server/lib/cloud/routes.ts +++ b/packages/server/lib/cloud/routes.ts @@ -5,6 +5,10 @@ const app_config = require('../../config/app.json') export const apiUrl = app_config[process.env.CYPRESS_CONFIG_ENV || process.env.CYPRESS_INTERNAL_ENV || 'development'].api_url +export function getApiUrl () { + return apiUrl +} + const CLOUD_ENDPOINTS = { api: '', auth: 'auth', diff --git a/packages/server/test/unit/cloud/api/api_spec.js b/packages/server/test/unit/cloud/api/api_spec.js index 884ea19ab244..d60456ac8cb2 100644 --- a/packages/server/test/unit/cloud/api/api_spec.js +++ b/packages/server/test/unit/cloud/api/api_spec.js @@ -25,7 +25,7 @@ const AUTH_URLS = { 'dashboardLogoutUrl': 'http://localhost:3000/logout', } -const { httpAgent, default: agent } = require('@packages/network/lib/agent') +const { default: agent } = require('@packages/network/lib/agent') const { PROTOCOL_STUB_VALID, @@ -140,44 +140,40 @@ describe('lib/cloud/api', () => { context('.rp', () => { beforeEach(() => { sinon.spy(agent, 'addRequest') - sinon.spy(httpAgent, 'addRequest') return nock.enableNetConnect() }) // nock will prevent requests from reaching the agent - it('makes calls using the correct agent', async () => { + it('makes calls using the correct agent', () => { nock.cleanAll() - try { - await api.ping() - } catch (e) { - // noop - } finally { - expect(httpAgent.addRequest).to.be.calledOnce - - expect(httpAgent.addRequest).to.be.calledWithMatch(sinon.match.any, { - host: 'localhost', - port: '1234', - path: '/ping', + return api.ping() + .then(() => { + throw new Error('') + }) + .catch(() => { + expect(agent.addRequest).to.be.calledOnce + + expect(agent.addRequest).to.be.calledWithMatch(sinon.match.any, { + href: 'http://localhost:1234/ping', }) - } + }) }) - // .ping() uses axios, which sets rejectUnauthorized on the agents, rather than the - // request. - // eslint-disable-next-line @cypress/dev/skip-comment - it.skip('sets rejectUnauthorized on the request', async () => { + it('sets rejectUnauthorized on the request', () => { nock.cleanAll() - try { - await api.ping() - } catch (e) { - // noop - } finally { - expect(httpAgent.addRequest).to.be.calledOnce + return api.ping() + .then(() => { + throw new Error('') + }) + .catch(() => { + expect(agent.addRequest).to.be.calledOnce - expect(httpAgent.addRequest.firstCall.firstArg.rejectUnauthorized).to.be.true - } + expect(agent.addRequest).to.be.calledWithMatch(sinon.match.any, { + rejectUnauthorized: true, + }) + }) }) context('with a proxy defined', () => { @@ -185,22 +181,21 @@ describe('lib/cloud/api', () => { nock.cleanAll() }) - it('makes calls using the correct agent', async () => { - nock.cleanAll() + it('makes calls using the correct agent', () => { + process.env.HTTP_PROXY = (process.env.HTTPS_PROXY = 'http://foo.invalid:1234') + process.env.NO_PROXY = '' - try { - await api.ping() - } catch (e) { - // noop - } finally { - expect(httpAgent.addRequest).to.be.calledOnce - - expect(httpAgent.addRequest).to.be.calledWithMatch(sinon.match.any, { - host: 'localhost', - port: '1234', - path: '/ping', + return api.ping() + .then(() => { + throw new Error('') + }) + .catch(() => { + expect(agent.addRequest).to.be.calledOnce + + expect(agent.addRequest).to.be.calledWithMatch(sinon.match.any, { + href: 'http://localhost:1234/ping', }) - } + }) }) }) }) diff --git a/packages/server/test/unit/cloud/api/axios_middleware/preflight_spec.ts b/packages/server/test/unit/cloud/api/axios_middleware/preflight_spec.ts new file mode 100644 index 000000000000..bcc6ba152ae4 --- /dev/null +++ b/packages/server/test/unit/cloud/api/axios_middleware/preflight_spec.ts @@ -0,0 +1,348 @@ +const { sinon } = require('../../../../spec_helper') + +import type { SinonStubbedInstance, SinonStub } from 'sinon' +import { expect } from 'chai' + +import { PreflightMiddleware } from '../../../../../lib/cloud/api/axios_middleware/preflight' +import axios, { Axios, AxiosInstance, InternalAxiosRequestConfig, AxiosError, AxiosDefaults, HeadersDefaults, AxiosHeaderValue, AxiosResponse } from 'axios' +import * as preflightEndpoint from '../../../../../lib/cloud/api/endpoints/post_preflight' +import * as routes from '../../../../../lib/cloud/routes' +import { HttpAgent, HttpsAgent } from '@packages/network/lib/agent' +import * as asyncRetryModule from '../../../../../lib/util/async_retry' + +describe('PreflightMiddleware', () => { + const apiUrl = 'https://api.cypress.test' + const apiProxyUrl = 'https://api-proxy.cypress.test' + const preflightResponseApiUrl = 'https://preflight.cypress.test' + + const preflightResponse: Awaited> = { + apiUrl: preflightResponseApiUrl, + encrypt: true, + warnings: [], + } + + const projectAttributes: Parameters[0] = { + projectId: '123', + projectRoot: '/some/path', + ciBuildId: 'abc', + browser: {}, + testingType: 'e2e', + parallel: true, + } + + let axiosInstance: SinonStubbedInstance + let postPreflightStub: SinonStub, ReturnType> + let axiosCreateStub: SinonStub, ReturnType> + let getApiUrlStub: SinonStub, ReturnType> + let requestConfig: Partial + let preflightMiddleware: PreflightMiddleware + + let stubbedHttpAgent: SinonStubbedInstance + let stubbedHttpsAgent: SinonStubbedInstance + + beforeEach(() => { + // the differences between Axios and AxiosInstance are negligible for the purposes of this test + axiosInstance = sinon.createStubInstance(Axios) as SinonStubbedInstance + postPreflightStub = sinon.stub(preflightEndpoint, 'postPreflight') + axiosCreateStub = sinon.stub(axios, 'create').returns(axiosInstance) + getApiUrlStub = sinon.stub(routes, 'getApiUrl').returns(apiUrl) + requestConfig = { + } + + stubbedHttpAgent = sinon.createStubInstance(HttpAgent) + stubbedHttpsAgent = sinon.createStubInstance(HttpsAgent) + + axiosInstance.defaults = { + httpAgent: stubbedHttpAgent, + httpsAgent: stubbedHttpsAgent, + } as Omit, 'headers'> & { headers: HeadersDefaults & { [key: string]: AxiosHeaderValue } } + + preflightMiddleware = new PreflightMiddleware(axiosInstance) + }) + + afterEach(() => { + postPreflightStub.restore() + axiosCreateStub.restore() + getApiUrlStub.restore() + }) + + describe('.requestInterceptor', () => { + let asyncRetryStub: SinonStub, ReturnType> + + beforeEach(() => { + asyncRetryStub = sinon.stub(asyncRetryModule, 'asyncRetry') + asyncRetryStub.callsFake((fn) => fn) + }) + + afterEach(() => { + sinon.restore() + }) + + describe('when requirePreflight=true on the request config', () => { + beforeEach(() => { + requestConfig.requirePreflight = true + }) + + describe('and there is no saved preflight state', () => { + beforeEach(() => { + axiosInstance.defaults.preflightState = undefined + }) + + describe('and there are project attributes configured', () => { + beforeEach(() => { + preflightMiddleware.setProjectAttributes(projectAttributes) + }) + + describe('when api-proxy with no agents succeeds', () => { + beforeEach(() => { + postPreflightStub.withArgs(projectAttributes, { apiUrl: apiProxyUrl, attempt: 1 }).resolves(preflightResponse) + }) + + it('caches the preflight response on the axios instance', async () => { + await preflightMiddleware.requestInterceptor(requestConfig as InternalAxiosRequestConfig) + expect(axiosInstance.defaults).not.to.be.undefined + expect(axiosInstance.defaults.preflightState).not.to.be.undefined + + for (const [k, v] of Object.entries(preflightResponse)) { + expect(axiosInstance.defaults.preflightState[k]).to.eq(v) + } + }) + }) + + describe('when api-proxy with no agents fails', () => { + let err: SinonStubbedInstance + + beforeEach(() => { + err = sinon.createStubInstance(AxiosError) + }) + + describe('with 412', () => { + beforeEach(() => { + err.status = 412 + postPreflightStub.rejects(err) + }) + + it('throws', async () => { + await expect( + preflightMiddleware.requestInterceptor(requestConfig as InternalAxiosRequestConfig), + ).to.eventually.be.rejectedWith(err) + }) + }) + + describe('for any other reason', () => { + beforeEach(() => { + err.status = 404 + postPreflightStub.withArgs(projectAttributes, { + apiUrl: apiProxyUrl, + attempt: 1, + }).rejects(err) + }) + + describe('retries the fallback preflight request', () => { + it('uses asyncRetry with correct options', async () => { + await preflightMiddleware.requestInterceptor(requestConfig as InternalAxiosRequestConfig) + + const [, opts] = asyncRetryStub.firstCall.args + + expect(opts.maxAttempts).to.equal(3) + expect(opts.retryDelay).to.be.a('function') + expect(opts.shouldRetry).to.be.a('function') + }) + }) + + describe('when api & agents request succeeds', () => { + beforeEach(() => { + postPreflightStub.withArgs(projectAttributes, { + apiUrl, + httpAgent: stubbedHttpAgent, + httpsAgent: stubbedHttpsAgent, + attempt: 1, + }).resolves(preflightResponse) + }) + + it('sets the preflight state to the response', async () => { + const cfg = await preflightMiddleware.requestInterceptor(requestConfig as InternalAxiosRequestConfig) + + if (!cfg.preflightState) { + throw new Error('preflight state should be defined') + } + + for (const [k, v] of Object.entries(preflightResponse)) { + expect(axiosInstance.defaults.preflightState[k]).to.eq(v) + + expect(cfg.preflightState[k]).to.eq(v) + } + }) + }) + + describe('when api & agents request fails', () => { + let err500: sinon.SinonStubbedInstance + + beforeEach(() => { + err500 = sinon.createStubInstance(AxiosError) + err500.status = 500 + + postPreflightStub.rejects(err500) + }) + + it('throws', async () => { + await expect( + preflightMiddleware.requestInterceptor(requestConfig as InternalAxiosRequestConfig), + ).to.eventually.be.rejectedWith(err500) + }) + }) + }) + }) + + describe('when api-proxy with no agents succeeds', () => { + beforeEach(() => { + postPreflightStub.withArgs(projectAttributes, { + apiUrl: apiUrl.replace('api', 'api-proxy'), + attempt: 1, + }).resolves(preflightResponse) + }) + + it('sets the preflight state to the response', async () => { + const cfg = await preflightMiddleware.requestInterceptor(requestConfig as InternalAxiosRequestConfig) + + for (const [k, v] of Object.entries(preflightResponse)) { + expect(axiosInstance.defaults.preflightState[k]).to.eq(v) + if (!cfg.preflightState) { + throw new Error('preflight state should be defined') + } + + expect(cfg.preflightState[k]).to.eq(v) + } + }) + }) + }) + }) + + describe('and there is a saved preflight state on the internal axios request config', () => { + beforeEach(() => { + axiosInstance.defaults.preflightState = requestConfig.preflightState = preflightResponse + }) + + it('does not send any preflight requests', async () => { + await preflightMiddleware.requestInterceptor(requestConfig as InternalAxiosRequestConfig) + expect(postPreflightStub).not.to.be.called + }) + + it('sets the apiUrl on the outgoing request', async () => { + const cfg = await preflightMiddleware.requestInterceptor(requestConfig as InternalAxiosRequestConfig) + + expect(cfg.baseURL).to.equal(preflightResponseApiUrl) + }) + }) + }) + + describe('when requirePreflight=false on the request config', () => { + beforeEach(() => { + requestConfig.requirePreflight = false + }) + + it('does not send any preflight requests', async () => { + await preflightMiddleware.requestInterceptor(requestConfig as InternalAxiosRequestConfig) + expect(postPreflightStub).not.to.be.called + }) + + it('does not modify the request config', async () => { + const cfg = await preflightMiddleware.requestInterceptor(requestConfig as InternalAxiosRequestConfig) + + expect(cfg).to.deep.equal(requestConfig) + }) + }) + + describe('error handling', () => { + beforeEach(() => { + requestConfig.requirePreflight = true + preflightMiddleware.setProjectAttributes(projectAttributes) + }) + + describe('when postPreflight throws a non-AxiosError', () => { + beforeEach(() => { + postPreflightStub.rejects(new Error('network error')) + }) + + it('propagates the error', async () => { + await expect( + preflightMiddleware.requestInterceptor(requestConfig as InternalAxiosRequestConfig), + ).to.eventually.be.rejectedWith('network error') + }) + }) + }) + }) + + describe('.responseInterceptor', () => { + let response: AxiosResponse + + const preflightWarning = { message: 'preflight warning' } + const mainRequestWarning = { message: 'main request warning' } + + beforeEach(() => { + response = { + data: {}, + status: 200, + statusText: 'OK', + headers: {}, + config: {} as InternalAxiosRequestConfig, + } + }) + + describe('when there were warnings on the preflight response', () => { + beforeEach(() => { + response.config.preflightState = { + ...preflightResponse, + warnings: [preflightWarning], + } + }) + + describe('when axios is configured to append warnings from the preflight response to the response', () => { + beforeEach(() => { + response.config.appendPreflightWarnings = true + }) + + describe('and there are warnings on the incoming response', () => { + beforeEach(() => { + response.data.warnings = [mainRequestWarning] + }) + + it('appends the warnings from the preflight response to the incoming response warnings', () => { + const interceptedResponse = preflightMiddleware.responseInterceptor(response) + + expect(interceptedResponse.data.warnings).to.be.an('array') + expect(interceptedResponse.data.warnings).to.have.length(2) + expect(interceptedResponse.data.warnings).to.contain(preflightWarning) + expect(interceptedResponse.data.warnings).to.contain(mainRequestWarning) + }) + }) + + describe('and the incoming response warnings are undefined', () => { + beforeEach(() => { + response.data.warnings = undefined + }) + + it('adds the preflight warnings to the incoming response', () => { + const interceptedResponse = preflightMiddleware.responseInterceptor(response) + + expect(interceptedResponse.data.warnings).to.be.an('array') + expect(interceptedResponse.data.warnings).to.have.length(1) + expect(interceptedResponse.data.warnings).to.contain(preflightWarning) + }) + }) + }) + + describe('when the request is not configured to append warnings from the preflight response to the response', () => { + beforeEach(() => { + response.config.appendPreflightWarnings = false + }) + + it('does not add the preflight warnings to the incoming response', () => { + const interceptedResponse = preflightMiddleware.responseInterceptor(response) + + expect(interceptedResponse.data.warnings).to.deep.equal(response.data.warnings) + }) + }) + }) + }) +}) diff --git a/packages/server/test/unit/cloud/api/transform_error_spec.ts b/packages/server/test/unit/cloud/api/axios_middleware/transform_error_spec.ts similarity index 96% rename from packages/server/test/unit/cloud/api/transform_error_spec.ts rename to packages/server/test/unit/cloud/api/axios_middleware/transform_error_spec.ts index 9a290ceb3a42..287b1c648596 100644 --- a/packages/server/test/unit/cloud/api/transform_error_spec.ts +++ b/packages/server/test/unit/cloud/api/axios_middleware/transform_error_spec.ts @@ -1,5 +1,5 @@ import { expect } from 'chai' -import { installErrorTransform } from '../../../../lib/cloud/api/axios_middleware/transform_error' +import { installErrorTransform } from '../../../../../lib/cloud/api/axios_middleware/transform_error' import { AxiosError, AxiosResponse, AxiosInstance } from 'axios' import sinon, { SinonSpy } from 'sinon' diff --git a/packages/server/test/unit/cloud/api/create_instance_spec.ts b/packages/server/test/unit/cloud/api/create_instance_spec.ts index eb63e819280e..2cbb208ac7d2 100644 --- a/packages/server/test/unit/cloud/api/create_instance_spec.ts +++ b/packages/server/test/unit/cloud/api/create_instance_spec.ts @@ -3,7 +3,7 @@ import nock from 'nock' import sinonChai from 'sinon-chai' import pkg from '@packages/root' -import { createInstance as axiosCreateInstance, CreateInstanceRequestBody, CreateInstanceResponse } from '../../../../lib/cloud/api/create_instance' +import { createInstance, CreateInstanceRequestBody, CreateInstanceResponse } from '../../../../lib/cloud/api/endpoints/create_instance' import api from '../../../../lib/cloud/api' chai.use(sinonChai) @@ -54,7 +54,7 @@ context('API createInstance', () => { ;[ { label: AXIOS_LABEL, - fn: axiosCreateInstance, + fn: createInstance, }, { label: REQUEST_LABEL, diff --git a/packages/server/test/unit/cloud/api/endpoints/post_preflight_spec.ts b/packages/server/test/unit/cloud/api/endpoints/post_preflight_spec.ts new file mode 100644 index 000000000000..9ebe04119d54 --- /dev/null +++ b/packages/server/test/unit/cloud/api/endpoints/post_preflight_spec.ts @@ -0,0 +1,251 @@ +import { expect } from 'chai' +const { sinon, nock } = require('../../../../spec_helper') +import axios from 'axios' +import { postPreflight } from '../../../../../lib/cloud/api/endpoints/post_preflight' +import { HttpAgent, HttpsAgent } from '@packages/network/lib/agent' + +describe('postPreflight', () => { + let axiosCreateStub: sinon.SinonStub + let postStub: sinon.SinonStub + let httpAgent: sinon.SinonStubbedInstance + let httpsAgent: sinon.SinonStubbedInstance + + const apiUrl = 'https://api.cypress.test' + const body = { + projectId: '123', + projectRoot: '/some/path', + ciBuildId: 'abc', + browser: {}, + testingType: 'e2e' as const, + parallel: true, + } + + beforeEach(() => { + httpAgent = sinon.createStubInstance(HttpAgent) + httpsAgent = sinon.createStubInstance(HttpsAgent) + + const axiosInstance = { + post: sinon.stub(), + } + + postStub = axiosInstance.post + axiosCreateStub = sinon.stub(axios, 'create').returns(axiosInstance) + }) + + afterEach(() => { + axiosCreateStub.restore() + }) + + it('creates axios instance with correct configuration', async () => { + const validResponse = { + encrypt: true, + apiUrl: 'https://api.cypress.test', + warnings: [], + } + + postStub.resolves({ data: validResponse }) + + await postPreflight(body, { + apiUrl, + attempt: 1, + httpAgent, + httpsAgent, + }) + + expect(axiosCreateStub).to.have.been.calledWith({ + baseURL: apiUrl, + httpAgent, + httpsAgent, + }) + }) + + it('makes POST request with correct headers', async () => { + const validResponse = { + encrypt: true, + apiUrl: 'https://api.cypress.test', + warnings: [], + } + + postStub.resolves({ data: validResponse }) + + await postPreflight(body, { + apiUrl, + attempt: 2, + httpAgent, + httpsAgent, + }) + + expect(postStub).to.have.been.calledWith('/preflight', body, { + headers: { + 'x-route-version': '1', + 'x-cypress-request-attempt': '2', + }, + timeout: undefined, + }) + }) + + it('uses default attempt number when not provided', async () => { + const validResponse = { + encrypt: true, + apiUrl: 'https://api.cypress.test', + warnings: [], + } + + postStub.resolves({ data: validResponse }) + + await postPreflight(body, { + apiUrl, + httpAgent, + httpsAgent, + }) + + expect(postStub).to.have.been.calledWith('/preflight', body, { + headers: { + 'x-route-version': '1', + 'x-cypress-request-attempt': '1', + }, + timeout: undefined, + }) + }) + + it('returns valid preflight state', async () => { + const validResponse = { + encrypt: true, + apiUrl: 'https://api.cypress.test', + warnings: [], + } + + postStub.resolves({ data: validResponse }) + + const result = await postPreflight(body, { + apiUrl, + attempt: 1, + timeout: undefined, + }) + + expect(result).to.deep.equal(validResponse) + }) + + it('throws TypeError for invalid preflight state', async () => { + const invalidResponse = { + encrypt: true, + // missing apiUrl + } + + postStub.resolves({ data: invalidResponse }) + + await expect(postPreflight(body, { + apiUrl, + attempt: 1, + })).to.be.rejectedWith(TypeError, 'Invalid preflight state received from server') + }) + + it('throws TypeError for invalid warnings', async () => { + const invalidResponse = { + encrypt: true, + apiUrl: 'https://api.cypress.test', + warnings: 'not an array', // invalid warnings type + } + + postStub.resolves({ data: invalidResponse }) + + await expect(postPreflight(body, { + apiUrl, + attempt: 1, + })).to.be.rejectedWith(TypeError, 'Invalid preflight state received from server') + }) + + it('propagates axios errors', async () => { + const error = new Error('network error') + + postStub.rejects(error) + + await expect(postPreflight(body, { + apiUrl, + attempt: 1, + })).to.be.rejectedWith(error) + }) +}) + +describe('postPreflight integration', () => { + const apiUrl = 'https://api.cypress.test' + const body = { + projectId: '123', + projectRoot: '/some/path', + ciBuildId: 'abc', + browser: {}, + testingType: 'e2e' as const, + parallel: true, + } + + beforeEach(() => { + nock.cleanAll() + }) + + it('makes actual HTTP request with correct headers', async () => { + const validResponse = { + encrypt: true, + apiUrl: 'https://api.cypress.test', + warnings: [], + } + + const scope = nock(apiUrl) + .post('/preflight', body) + .matchHeader('x-route-version', '1') + .matchHeader('x-cypress-request-attempt', '2') + .reply(200, validResponse) + + const result = await postPreflight(body, { + apiUrl, + attempt: 2, + }) + + expect(result).to.deep.equal(validResponse) + expect(scope.isDone()).to.be.true + }) + + it('handles server errors', async () => { + const scope = nock(apiUrl) + .post('/preflight', body) + .reply(500, { error: 'Internal Server Error' }) + + await expect(postPreflight(body, { + apiUrl, + attempt: 1, + })).to.be.rejectedWith('Request failed with status code 500') + + expect(scope.isDone()).to.be.true + }) + + it('handles network errors', async () => { + const scope = nock(apiUrl) + .post('/preflight', body) + .replyWithError('ECONNREFUSED') + + await expect(postPreflight(body, { + apiUrl, + attempt: 1, + })).to.be.rejectedWith('ECONNREFUSED') + + expect(scope.isDone()).to.be.true + }) + + it('handles timeout errors', async () => { + const scope = nock(apiUrl) + .post('/preflight', body) + .delayConnection(1000) // Simulate timeout + .reply(200, { + encrypt: true, + apiUrl: 'https://api.cypress.test', + warnings: [], + }) + + await expect(postPreflight(body, { + apiUrl, + attempt: 1, + timeout: 1000, + })).to.be.rejectedWith('timeout') + + expect(scope.isDone()).to.be.true + }) +}) From 6935e5de6424254f4ce4c3d1ea9b78f8fa698a6c Mon Sep 17 00:00:00 2001 From: Cacie Prins Date: Fri, 25 Apr 2025 10:46:11 -0400 Subject: [PATCH 3/3] ensure timeout feature parity --- .../cloud/api/axios_middleware/preflight.ts | 2 + packages/server/lib/cloud/api/index.ts | 12 +--- .../server/lib/cloud/api/preflight_timeout.ts | 11 +++ .../api/axios_middleware/preflight_spec.ts | 67 ++++++++++++++++++- .../test/unit/cloud/preflight_timeout_spec.ts | 67 +++++++++++++++++++ 5 files changed, 146 insertions(+), 13 deletions(-) create mode 100644 packages/server/lib/cloud/api/preflight_timeout.ts create mode 100644 packages/server/test/unit/cloud/preflight_timeout_spec.ts diff --git a/packages/server/lib/cloud/api/axios_middleware/preflight.ts b/packages/server/lib/cloud/api/axios_middleware/preflight.ts index 684719f1f61d..451b3ab87324 100644 --- a/packages/server/lib/cloud/api/axios_middleware/preflight.ts +++ b/packages/server/lib/cloud/api/axios_middleware/preflight.ts @@ -3,6 +3,7 @@ import { getApiUrl } from '../../routes' import { postPreflight, PreflightRequestBody, PreflightOptions } from '../endpoints/post_preflight' import { isArray, isObject } from 'lodash' import { asyncRetry, exponentialBackoff } from '../../../util/async_retry' +import { noProxyPreflightTimeout } from '../preflight_timeout' import Debug from 'debug' const debug = Debug('cypress:server:cloud:api:axios_middleware:preflight') @@ -58,6 +59,7 @@ export class PreflightMiddleware { const options: PreflightOptions = { apiUrl: getApiUrl().replace('api', 'api-proxy'), attempt: 1, + timeout: noProxyPreflightTimeout(), } const preflightState = await postPreflight(projectAttributes, options) diff --git a/packages/server/lib/cloud/api/index.ts b/packages/server/lib/cloud/api/index.ts index 59a505426d53..f9da486123c0 100644 --- a/packages/server/lib/cloud/api/index.ts +++ b/packages/server/lib/cloud/api/index.ts @@ -35,7 +35,7 @@ import { PUBLIC_KEY_VERSION } from '../constants' import type { CreateInstanceRequestBody, CreateInstanceResponse } from './endpoints/create_instance' import { transformError } from './axios_middleware/transform_error' - +import { noProxyPreflightTimeout } from './preflight_timeout' const THIRTY_SECONDS = humanInterval('30 seconds') const SIXTY_SECONDS = humanInterval('60 seconds') const TWO_MINUTES = humanInterval('2 minutes') @@ -239,16 +239,6 @@ const isRetriableError = (err) => { (err.statusCode == null) } -function noProxyPreflightTimeout (): number { - try { - const timeoutFromEnv = Number(process.env.CYPRESS_INITIAL_PREFLIGHT_TIMEOUT) - - return isNaN(timeoutFromEnv) ? 5000 : timeoutFromEnv - } catch (e: unknown) { - return 5000 - } -} - export type CreateRunOptions = { projectRoot: string ci: { diff --git a/packages/server/lib/cloud/api/preflight_timeout.ts b/packages/server/lib/cloud/api/preflight_timeout.ts new file mode 100644 index 000000000000..12fbf34c8b42 --- /dev/null +++ b/packages/server/lib/cloud/api/preflight_timeout.ts @@ -0,0 +1,11 @@ +const DEFAULT_PREFLIGHT_TIMEOUT = 5000 + +export function noProxyPreflightTimeout (): number { + try { + const timeoutFromEnv = Number(process.env.CYPRESS_INITIAL_PREFLIGHT_TIMEOUT) + + return (isNaN(timeoutFromEnv) || process.env.CYPRESS_INITIAL_PREFLIGHT_TIMEOUT === '') ? DEFAULT_PREFLIGHT_TIMEOUT : timeoutFromEnv + } catch (e: unknown) { + return DEFAULT_PREFLIGHT_TIMEOUT + } +} diff --git a/packages/server/test/unit/cloud/api/axios_middleware/preflight_spec.ts b/packages/server/test/unit/cloud/api/axios_middleware/preflight_spec.ts index bcc6ba152ae4..c561d3ac57bc 100644 --- a/packages/server/test/unit/cloud/api/axios_middleware/preflight_spec.ts +++ b/packages/server/test/unit/cloud/api/axios_middleware/preflight_spec.ts @@ -7,6 +7,7 @@ import { PreflightMiddleware } from '../../../../../lib/cloud/api/axios_middlewa import axios, { Axios, AxiosInstance, InternalAxiosRequestConfig, AxiosError, AxiosDefaults, HeadersDefaults, AxiosHeaderValue, AxiosResponse } from 'axios' import * as preflightEndpoint from '../../../../../lib/cloud/api/endpoints/post_preflight' import * as routes from '../../../../../lib/cloud/routes' +import * as preflightTimeout from '../../../../../lib/cloud/api/preflight_timeout' import { HttpAgent, HttpsAgent } from '@packages/network/lib/agent' import * as asyncRetryModule from '../../../../../lib/util/async_retry' @@ -34,6 +35,7 @@ describe('PreflightMiddleware', () => { let postPreflightStub: SinonStub, ReturnType> let axiosCreateStub: SinonStub, ReturnType> let getApiUrlStub: SinonStub, ReturnType> + let noProxyPreflightTimeoutStub: SinonStub let requestConfig: Partial let preflightMiddleware: PreflightMiddleware @@ -46,6 +48,7 @@ describe('PreflightMiddleware', () => { postPreflightStub = sinon.stub(preflightEndpoint, 'postPreflight') axiosCreateStub = sinon.stub(axios, 'create').returns(axiosInstance) getApiUrlStub = sinon.stub(routes, 'getApiUrl').returns(apiUrl) + noProxyPreflightTimeoutStub = sinon.stub(preflightTimeout, 'noProxyPreflightTimeout').returns(5000) requestConfig = { } @@ -64,6 +67,7 @@ describe('PreflightMiddleware', () => { postPreflightStub.restore() axiosCreateStub.restore() getApiUrlStub.restore() + noProxyPreflightTimeoutStub.restore() }) describe('.requestInterceptor', () => { @@ -88,6 +92,20 @@ describe('PreflightMiddleware', () => { axiosInstance.defaults.preflightState = undefined }) + describe('and there are no project attributes configured', () => { + beforeEach(() => { + // @ts-ignore - intentionally setting to undefined for test + preflightMiddleware.setProjectAttributes(undefined) + }) + + it('skips preflight request and returns config unchanged', async () => { + const cfg = await preflightMiddleware.requestInterceptor(requestConfig as InternalAxiosRequestConfig) + + expect(postPreflightStub).not.to.be.called + expect(cfg).to.deep.equal(requestConfig) + }) + }) + describe('and there are project attributes configured', () => { beforeEach(() => { preflightMiddleware.setProjectAttributes(projectAttributes) @@ -95,7 +113,11 @@ describe('PreflightMiddleware', () => { describe('when api-proxy with no agents succeeds', () => { beforeEach(() => { - postPreflightStub.withArgs(projectAttributes, { apiUrl: apiProxyUrl, attempt: 1 }).resolves(preflightResponse) + postPreflightStub.withArgs(projectAttributes, { + apiUrl: apiProxyUrl, + attempt: 1, + timeout: 5000, // Expected timeout from stub + }).resolves(preflightResponse) }) it('caches the preflight response on the axios instance', async () => { @@ -103,10 +125,25 @@ describe('PreflightMiddleware', () => { expect(axiosInstance.defaults).not.to.be.undefined expect(axiosInstance.defaults.preflightState).not.to.be.undefined + // Type guard to satisfy TypeScript + if (!axiosInstance.defaults.preflightState) { + throw new Error('preflightState should be defined') + } + for (const [k, v] of Object.entries(preflightResponse)) { expect(axiosInstance.defaults.preflightState[k]).to.eq(v) } }) + + it('passes the timeout from noProxyPreflightTimeout', async () => { + const expectedTimeout = 5000 // Stubbed value + + await preflightMiddleware.requestInterceptor(requestConfig as InternalAxiosRequestConfig) + + expect(postPreflightStub).to.be.calledOnce + expect(postPreflightStub.firstCall.args[1].timeout).to.equal(expectedTimeout) + expect(noProxyPreflightTimeoutStub).to.have.been.called + }) }) describe('when api-proxy with no agents fails', () => { @@ -135,6 +172,7 @@ describe('PreflightMiddleware', () => { postPreflightStub.withArgs(projectAttributes, { apiUrl: apiProxyUrl, attempt: 1, + timeout: 5000, }).rejects(err) }) @@ -167,9 +205,13 @@ describe('PreflightMiddleware', () => { throw new Error('preflight state should be defined') } + // Type guard to satisfy TypeScript + if (!axiosInstance.defaults.preflightState) { + throw new Error('preflightState should be defined') + } + for (const [k, v] of Object.entries(preflightResponse)) { expect(axiosInstance.defaults.preflightState[k]).to.eq(v) - expect(cfg.preflightState[k]).to.eq(v) } }) @@ -199,12 +241,18 @@ describe('PreflightMiddleware', () => { postPreflightStub.withArgs(projectAttributes, { apiUrl: apiUrl.replace('api', 'api-proxy'), attempt: 1, + timeout: 5000, }).resolves(preflightResponse) }) it('sets the preflight state to the response', async () => { const cfg = await preflightMiddleware.requestInterceptor(requestConfig as InternalAxiosRequestConfig) + // Type guard to satisfy TypeScript + if (!axiosInstance.defaults.preflightState) { + throw new Error('preflightState should be defined') + } + for (const [k, v] of Object.entries(preflightResponse)) { expect(axiosInstance.defaults.preflightState[k]).to.eq(v) if (!cfg.preflightState) { @@ -317,6 +365,21 @@ describe('PreflightMiddleware', () => { }) }) + describe('and warnings in the incoming response is an object, not an array', () => { + beforeEach(() => { + response.data.warnings = mainRequestWarning + }) + + it('converts warnings to an array containing both object and preflight warnings', () => { + const interceptedResponse = preflightMiddleware.responseInterceptor(response) + + expect(interceptedResponse.data.warnings).to.be.an('array') + expect(interceptedResponse.data.warnings).to.have.length(2) + expect(interceptedResponse.data.warnings[0]).to.deep.equal(mainRequestWarning) + expect(interceptedResponse.data.warnings[1]).to.deep.equal(preflightWarning) + }) + }) + describe('and the incoming response warnings are undefined', () => { beforeEach(() => { response.data.warnings = undefined diff --git a/packages/server/test/unit/cloud/preflight_timeout_spec.ts b/packages/server/test/unit/cloud/preflight_timeout_spec.ts new file mode 100644 index 000000000000..bd3e253c29a8 --- /dev/null +++ b/packages/server/test/unit/cloud/preflight_timeout_spec.ts @@ -0,0 +1,67 @@ +import { expect } from 'chai' +import { noProxyPreflightTimeout } from '../../../lib/cloud/api/preflight_timeout' + +describe('noProxyPreflightTimeout', () => { + let originalEnv: NodeJS.ProcessEnv + + beforeEach(() => { + originalEnv = process.env + process.env = { ...originalEnv } + }) + + afterEach(() => { + process.env = originalEnv + }) + + it('returns default timeout when no environment variable is set', () => { + delete process.env.CYPRESS_INITIAL_PREFLIGHT_TIMEOUT + expect(noProxyPreflightTimeout()).to.equal(5000) // Default is 5000ms + }) + + it('returns value from environment variable when set', () => { + process.env.CYPRESS_INITIAL_PREFLIGHT_TIMEOUT = '10000' + expect(noProxyPreflightTimeout()).to.equal(10000) + }) + + it('returns default timeout when environment variable is not a number', () => { + process.env.CYPRESS_INITIAL_PREFLIGHT_TIMEOUT = 'not-a-number' + expect(noProxyPreflightTimeout()).to.equal(5000) + }) + + it('returns default timeout when error occurs parsing environment variable', () => { + // Mock Number to throw an error when called + const originalNumber = global.Number + + // @ts-ignore - intentionally mocking to throw for test + global.Number = function () { + throw new Error('test error') + } + + try { + expect(noProxyPreflightTimeout()).to.equal(5000) + } finally { + // Restore original Number + global.Number = originalNumber + } + }) + + it('handles empty string in environment variable', () => { + process.env.CYPRESS_INITIAL_PREFLIGHT_TIMEOUT = '' + expect(noProxyPreflightTimeout()).to.equal(5000) + }) + + it('handles negative values in environment variable', () => { + process.env.CYPRESS_INITIAL_PREFLIGHT_TIMEOUT = '-1000' + expect(noProxyPreflightTimeout()).to.equal(-1000) + }) + + it('handles zero value in environment variable', () => { + process.env.CYPRESS_INITIAL_PREFLIGHT_TIMEOUT = '0' + expect(noProxyPreflightTimeout()).to.equal(0) + }) + + it('handles very large timeout values', () => { + process.env.CYPRESS_INITIAL_PREFLIGHT_TIMEOUT = '3600000' // 1 hour + expect(noProxyPreflightTimeout()).to.equal(3600000) + }) +})