Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 20 additions & 15 deletions src/extensions/http_api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -50,8 +53,9 @@ class HttpApiClient {
public followRedirect: boolean
public response: AxiosResponse | undefined
public responseCookies: Record<string, Cookie>
public proxy: boolean

constructor() {
constructor(proxy = false) {
this.body = undefined
this.bodyType = BODY_TYPE_JSON
this.headers = {}
Expand All @@ -61,6 +65,7 @@ class HttpApiClient {
this.followRedirect = true
this.response = undefined
this.responseCookies = {}
this.proxy = proxy
}

/**
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
}

/**
Expand Down
4 changes: 2 additions & 2 deletions src/extensions/http_api/extend_world.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
}

Expand Down
11 changes: 8 additions & 3 deletions src/extensions/http_api/index.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -16,7 +18,9 @@ import extendWorld from './extend_world.js'
* httpApi.extendWorld(this)
* })
*/
export { extendWorld }
export const extendWorld = (world: Parameters<typeof baseExtendWorld>[0]): void => {
baseExtendWorld(world, proxyConfig)
}

/**
* Installs the extension.
Expand All @@ -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 })
}
271 changes: 271 additions & 0 deletions tests/unit/extensions/http_api/client.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn> }
let mockAxiosCreate: ReturnType<typeof vi.fn>

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<string, unknown>
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)
})
})
})