diff --git a/src/extensions/http_api/client.ts b/src/extensions/http_api/client.ts index 74a0fe8..61e92b0 100644 --- a/src/extensions/http_api/client.ts +++ b/src/extensions/http_api/client.ts @@ -12,22 +12,25 @@ const BODY_TYPE_MULTIPART = 'form-data' const verbsAcceptingBody = ['POST', 'PUT', 'DELETE', 'PATCH'] const validateStatus = (status: number) => status >= 200 && status <= 302 +// Proxy is disabled by default to ensure direct connections in test environments +// Set proxy: false explicitly to prevent axios from reading HTTP_PROXY env vars const axiosInstance = axios.create({ validateStatus, + proxy: false, }) -let cookieInstance: AxiosInstance -const getClient = (cookieJar?: CookieJar): AxiosInstance => { +const getClient = (cookieJar?: CookieJar, proxy = false): AxiosInstance => { if (cookieJar) { - if (!cookieInstance) { - cookieInstance = wrapper( - axios.create({ - jar: cookieJar, - withCredentials: true, - validateStatus, - }) - ) as AxiosInstance - } + const cookieInstance = wrapper( + axios.create({ + jar: cookieJar, + withCredentials: true, + validateStatus, + // When proxy is true, use undefined to allow axios default behavior (reading env vars) + // When proxy is false, explicitly disable to prevent proxy usage + proxy: proxy ? undefined : false, + }) + ) as AxiosInstance return cookieInstance } return axiosInstance @@ -50,8 +53,9 @@ class HttpApiClient { public followRedirect: boolean public response: AxiosResponse | undefined public responseCookies: Record + public proxy: boolean - constructor() { + constructor(proxy = false) { this.body = undefined this.bodyType = BODY_TYPE_JSON this.headers = {} @@ -61,6 +65,7 @@ class HttpApiClient { this.followRedirect = true this.response = undefined this.responseCookies = {} + this.proxy = proxy } /** @@ -271,7 +276,7 @@ class HttpApiClient { } } } - const client = getClient(this.cookieJar) + const client = getClient(this.cookieJar, this.proxy) this.response = await client.request(options) if (this.cookieJar) { @@ -299,8 +304,8 @@ class HttpApiClient { /** * Create a new isolated http api client */ -export default function (...args: HttpApiClientArgs): HttpApiClient { - return new HttpApiClient(...args) +export default function (proxy = false): HttpApiClient { + return new HttpApiClient(proxy) } /** diff --git a/src/extensions/http_api/extend_world.ts b/src/extensions/http_api/extend_world.ts index 034fb48..58b49c3 100644 --- a/src/extensions/http_api/extend_world.ts +++ b/src/extensions/http_api/extend_world.ts @@ -2,14 +2,14 @@ import type { IWorld } from '@cucumber/cucumber' import Registry from '../../core/registry.js' import Client from './client.js' -const extendWorld = (world: IWorld): void => { +const extendWorld = (world: IWorld, proxy = false): void => { if (!Registry.hasExtension(world, 'state')) { throw new Error( `Unable to init "http_api" extension as it requires "state" extension which is not installed` ) } - world.httpApiClient = Client() + world.httpApiClient = Client(proxy) Registry.registerExtension(world, 'http_api') } diff --git a/src/extensions/http_api/index.ts b/src/extensions/http_api/index.ts index d98edaa..8677ce8 100644 --- a/src/extensions/http_api/index.ts +++ b/src/extensions/http_api/index.ts @@ -1,5 +1,7 @@ import * as definitions from './definitions.js' -import extendWorld from './extend_world.js' +import baseExtendWorld from './extend_world.js' + +let proxyConfig = false /** * Extends cucumber world object. @@ -16,7 +18,9 @@ import extendWorld from './extend_world.js' * httpApi.extendWorld(this) * }) */ -export { extendWorld } +export const extendWorld = (world: Parameters[0]): void => { + baseExtendWorld(world, proxyConfig) +} /** * Installs the extension. @@ -35,6 +39,7 @@ export { extendWorld } * state.install() * httpApi.install({ baseUrl: 'http://localhost:3000' }) */ -export const install = ({ baseUrl = '' } = {}): void => { +export const install = ({ baseUrl = '', proxy = false } = {}): void => { + proxyConfig = proxy definitions.install({ baseUrl }) } diff --git a/tests/unit/extensions/http_api/client.test.ts b/tests/unit/extensions/http_api/client.test.ts new file mode 100644 index 0000000..fdd91d1 --- /dev/null +++ b/tests/unit/extensions/http_api/client.test.ts @@ -0,0 +1,271 @@ +import type { AxiosInstance, AxiosResponse } from 'axios' +import axios from 'axios' +import type { Cookie } from 'tough-cookie' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import createClient, { HttpApiClient } from '../../../../src/extensions/http_api/client.js' + +vi.mock('axios') +vi.mock('axios-cookiejar-support', () => ({ + wrapper: (instance: unknown) => instance, +})) + +describe('extensions > http_api > client', () => { + let mockAxiosInstance: { request: ReturnType } + let mockAxiosCreate: ReturnType + + beforeEach(() => { + mockAxiosInstance = { + request: vi.fn(), + } + mockAxiosCreate = vi.fn(() => mockAxiosInstance as unknown as AxiosInstance) + vi.mocked(axios.create).mockImplementation(mockAxiosCreate as typeof axios.create) + vi.mocked(axios.isAxiosError).mockReturnValue(false) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe('createClient factory function', () => { + test('should create client with proxy disabled by default', () => { + const client = createClient() + + expect(client).toBeInstanceOf(HttpApiClient) + expect(client.proxy).toBe(false) + }) + + test('should create client with proxy enabled when specified', () => { + const client = createClient(true) + + expect(client).toBeInstanceOf(HttpApiClient) + expect(client.proxy).toBe(true) + }) + + test('should create client with proxy explicitly disabled', () => { + const client = createClient(false) + + expect(client).toBeInstanceOf(HttpApiClient) + expect(client.proxy).toBe(false) + }) + }) + + describe('HttpApiClient constructor', () => { + test('should initialize with default proxy value (false)', () => { + const client = new HttpApiClient() + + expect(client.proxy).toBe(false) + expect(client.body).toBeUndefined() + expect(client.bodyType).toBe('json') + expect(client.headers).toEqual({}) + expect(client.query).toEqual({}) + expect(client.cookies).toEqual([]) + expect(client.cookieJar).toBeUndefined() + expect(client.followRedirect).toBe(true) + expect(client.response).toBeUndefined() + expect(client.responseCookies).toEqual({}) + }) + + test('should initialize with proxy enabled', () => { + const client = new HttpApiClient(true) + + expect(client.proxy).toBe(true) + }) + + test('should initialize with proxy disabled', () => { + const client = new HttpApiClient(false) + + expect(client.proxy).toBe(false) + }) + }) + + describe('makeRequest with proxy configuration', () => { + test('should maintain proxy setting through client lifecycle', () => { + const clientDisabled = createClient(false) + const clientEnabled = createClient(true) + + // Verify proxy is set correctly + expect(clientDisabled.proxy).toBe(false) + expect(clientEnabled.proxy).toBe(true) + + // Proxy should remain unchanged through various operations + clientDisabled.setJsonBody({ test: 'data' }) + clientEnabled.setJsonBody({ test: 'data' }) + + expect(clientDisabled.proxy).toBe(false) + expect(clientEnabled.proxy).toBe(true) + }) + + test('should pass proxy config to axios when using cookie jar with proxy disabled', async () => { + const client = createClient(false) + client.enableCookies() + + mockAxiosInstance.request.mockResolvedValue({ + data: { success: true }, + headers: {}, + status: 200, + }) + + await client.makeRequest('GET', '/test', 'http://localhost') + + // When cookieJar is used, a new axios instance is created + expect(mockAxiosCreate).toHaveBeenCalledWith( + expect.objectContaining({ + validateStatus: expect.any(Function), + withCredentials: true, + proxy: false, // proxy should be explicitly false + }) + ) + }) + + test('should pass undefined proxy config when proxy is enabled with cookie jar', async () => { + const client = createClient(true) + client.enableCookies() + + mockAxiosInstance.request.mockResolvedValue({ + data: { success: true }, + headers: {}, + status: 200, + }) + + await client.makeRequest('GET', '/test', 'http://localhost') + + // When proxy is true, it should be undefined to allow axios default behavior + expect(mockAxiosCreate).toHaveBeenCalledWith( + expect.objectContaining({ + validateStatus: expect.any(Function), + withCredentials: true, + proxy: undefined, // proxy should be undefined when enabled + }) + ) + }) + + test('should verify proxy configuration is maintained in client', () => { + const clientWithProxyDisabled = createClient(false) + const clientWithProxyEnabled = createClient(true) + + expect(clientWithProxyDisabled.proxy).toBe(false) + expect(clientWithProxyEnabled.proxy).toBe(true) + + // Proxy setting should be used when making requests with cookie jar + clientWithProxyDisabled.enableCookies() + clientWithProxyEnabled.enableCookies() + + expect(clientWithProxyDisabled.proxy).toBe(false) + expect(clientWithProxyEnabled.proxy).toBe(true) + }) + + test('should create new cookie instance for each request with different proxy settings', async () => { + const client1 = createClient(false) + client1.enableCookies() + + const client2 = createClient(true) + client2.enableCookies() + + mockAxiosInstance.request.mockResolvedValue({ + data: {}, + headers: {}, + status: 200, + }) + + await client1.makeRequest('GET', '/test1', 'http://localhost') + await client2.makeRequest('GET', '/test2', 'http://localhost') + + const calls = mockAxiosCreate.mock.calls + // Both should create new instances with different proxy configs + expect(calls.length).toBeGreaterThanOrEqual(2) + + // Find calls with cookiejar configs + const cookieJarCalls = calls.filter((call) => { + const config = call[0] as Record + return config?.['jar'] !== undefined + }) + expect(cookieJarCalls.length).toBeGreaterThanOrEqual(2) + }) + }) + + describe('reset method', () => { + test('should not reset proxy configuration', () => { + const client = createClient(true) + client.setJsonBody({ test: 'data' }) + client.setHeader('Authorization', 'Bearer token') + client.enableCookies() + + expect(client.proxy).toBe(true) + expect(client.body).toBeDefined() + expect(client.headers).not.toEqual({}) + expect(client.cookieJar).toBeDefined() + + client.reset() + + // Proxy should remain unchanged after reset + expect(client.proxy).toBe(true) + // But other properties should be reset + expect(client.body).toBeUndefined() + expect(client.headers).toEqual({}) + expect(client.cookieJar).toBeUndefined() + }) + + test('should reset all properties except proxy', () => { + const client = createClient(false) + client.setJsonBody({ data: 'test' }) + client.setHeaders({ 'X-Custom': 'header' }) + client.setQuery({ param: 'value' }) + client.enableCookies() + client.setFollowRedirect(false) + + // Simulate a response + const mockResponse = { + data: {}, + headers: {}, + status: 200, + statusText: 'OK', + config: {}, + } as AxiosResponse + const mockCookie = {} as Cookie + client.response = mockResponse + client.responseCookies = { testCookie: mockCookie } + + client.reset() + + expect(client.proxy).toBe(false) // Proxy remains + expect(client.body).toBeUndefined() + expect(client.bodyType).toBe('json') + expect(client.headers).toEqual({}) + expect(client.query).toEqual({}) + expect(client.cookies).toEqual([]) + expect(client.cookieJar).toBeUndefined() + expect(client.followRedirect).toBe(true) + expect(client.response).toBeUndefined() + expect(client.responseCookies).toEqual({}) + }) + }) + + describe('proxy property immutability', () => { + test('should maintain proxy setting throughout client lifecycle', () => { + const client = createClient(true) + + expect(client.proxy).toBe(true) + + client.setJsonBody({ test: 'data' }) + expect(client.proxy).toBe(true) + + client.setHeaders({ 'Content-Type': 'application/json' }) + expect(client.proxy).toBe(true) + + client.enableCookies() + expect(client.proxy).toBe(true) + + client.setFollowRedirect(false) + expect(client.proxy).toBe(true) + + client.clearBody() + expect(client.proxy).toBe(true) + + client.clearHeaders() + expect(client.proxy).toBe(true) + + client.disableCookies() + expect(client.proxy).toBe(true) + }) + }) +})