Skip to content

Commit 5271f13

Browse files
committed
test(http-request): add 25 tests for hooks, maxResponseSize, rawResponse, enrichErrorMessage
- hooks: onRequest/onResponse fire per-attempt, per-redirect, with POST method, custom headers - maxResponseSize: rejects oversized responses, works with httpJson/httpText, fires error hooks - rawResponse: exposes IncomingMessage on success and non-2xx responses - enrichErrorMessage: tests all 7 error codes + unknown + no-code cases - Export enrichErrorMessage for testability
1 parent 13a976e commit 5271f13

2 files changed

Lines changed: 277 additions & 2 deletions

File tree

src/http-request.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -945,9 +945,8 @@ async function httpDownloadAttempt(
945945
/**
946946
* Build an enriched error message based on the error code.
947947
* Generic guidance (no product-specific branding).
948-
* @private
949948
*/
950-
function enrichErrorMessage(
949+
export function enrichErrorMessage(
951950
url: string,
952951
method: string,
953952
error: NodeJS.ErrnoException,

test/unit/http-request.test.mts

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,18 @@ import path from 'node:path'
1818
import { Writable } from 'node:stream'
1919

2020
import {
21+
enrichErrorMessage,
2122
fetchChecksums,
2223
httpDownload,
2324
httpJson,
2425
httpRequest,
2526
httpText,
2627
parseChecksums,
2728
} from '@socketsecurity/lib/http-request'
29+
import type {
30+
HttpHookRequestInfo,
31+
HttpHookResponseInfo,
32+
} from '@socketsecurity/lib/http-request'
2833
import { Logger } from '@socketsecurity/lib/logger'
2934
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
3035
import { runWithTempDir } from './utils/temp-file-helper'
@@ -163,6 +168,13 @@ beforeAll(async () => {
163168
// Empty checksums file (only comments).
164169
res.writeHead(200, { 'Content-Type': 'text/plain' })
165170
res.end('# This file has no checksums\n\n')
171+
} else if (url === '/large-body') {
172+
const content = 'X'.repeat(10_000)
173+
res.writeHead(200, {
174+
'Content-Length': String(content.length),
175+
'Content-Type': 'text/plain',
176+
})
177+
res.end(content)
166178
} else if (url === '/post-success') {
167179
if (req.method === 'POST') {
168180
res.writeHead(201, { 'Content-Type': 'application/json' })
@@ -1804,4 +1816,268 @@ abc123def456789012345678901234567890123456789012345678901234abcd
18041816
expect(response.text()).toBe('Plain text response')
18051817
})
18061818
})
1819+
1820+
describe('hooks', () => {
1821+
it('should call onRequest before the request', async () => {
1822+
const requestInfos: HttpHookRequestInfo[] = []
1823+
await httpRequest(`${httpBaseUrl}/json`, {
1824+
hooks: {
1825+
onRequest: info => requestInfos.push(info),
1826+
},
1827+
})
1828+
expect(requestInfos).toHaveLength(1)
1829+
expect(requestInfos[0]!.method).toBe('GET')
1830+
expect(requestInfos[0]!.url).toBe(`${httpBaseUrl}/json`)
1831+
expect(requestInfos[0]!.timeout).toBe(30_000)
1832+
expect(requestInfos[0]!.headers).toBeDefined()
1833+
expect(requestInfos[0]!.headers['User-Agent']).toBe('socket-registry/1.0')
1834+
})
1835+
1836+
it('should call onResponse after a successful request', async () => {
1837+
const responseInfos: HttpHookResponseInfo[] = []
1838+
await httpRequest(`${httpBaseUrl}/json`, {
1839+
hooks: {
1840+
onResponse: info => responseInfos.push(info),
1841+
},
1842+
})
1843+
expect(responseInfos).toHaveLength(1)
1844+
expect(responseInfos[0]!.method).toBe('GET')
1845+
expect(responseInfos[0]!.url).toBe(`${httpBaseUrl}/json`)
1846+
expect(responseInfos[0]!.status).toBe(200)
1847+
expect(responseInfos[0]!.statusText).toBe('OK')
1848+
expect(responseInfos[0]!.duration).toBeGreaterThanOrEqual(0)
1849+
expect(responseInfos[0]!.error).toBeUndefined()
1850+
})
1851+
1852+
it('should call onResponse with error on failure', async () => {
1853+
const responseInfos: HttpHookResponseInfo[] = []
1854+
await httpRequest(`${httpBaseUrl}/timeout`, {
1855+
timeout: 50,
1856+
hooks: {
1857+
onResponse: info => responseInfos.push(info),
1858+
},
1859+
}).catch(() => {})
1860+
expect(responseInfos).toHaveLength(1)
1861+
expect(responseInfos[0]!.error).toBeDefined()
1862+
})
1863+
1864+
it('should fire hooks per-attempt on retries', async () => {
1865+
const requestInfos: HttpHookRequestInfo[] = []
1866+
const responseInfos: HttpHookResponseInfo[] = []
1867+
1868+
let attemptCount = 0
1869+
const testServer = http.createServer((_req, _res) => {
1870+
attemptCount++
1871+
_res.socket?.destroy()
1872+
})
1873+
1874+
await new Promise<void>(resolve => {
1875+
testServer.listen(0, () => resolve())
1876+
})
1877+
const address = testServer.address()
1878+
const testPort = address && typeof address === 'object' ? address.port : 0
1879+
1880+
try {
1881+
await httpRequest(`http://localhost:${testPort}/`, {
1882+
retries: 1,
1883+
retryDelay: 10,
1884+
hooks: {
1885+
onRequest: info => requestInfos.push(info),
1886+
onResponse: info => responseInfos.push(info),
1887+
},
1888+
}).catch(() => {})
1889+
1890+
expect(attemptCount).toBe(2)
1891+
expect(requestInfos).toHaveLength(2)
1892+
expect(responseInfos).toHaveLength(2)
1893+
for (const info of responseInfos) {
1894+
expect(info.error).toBeDefined()
1895+
}
1896+
} finally {
1897+
await new Promise<void>(resolve => { testServer.close(() => resolve()) })
1898+
}
1899+
})
1900+
1901+
it('should fire hooks on redirect hops', async () => {
1902+
const requestInfos: HttpHookRequestInfo[] = []
1903+
const responseInfos: HttpHookResponseInfo[] = []
1904+
1905+
await httpRequest(`${httpBaseUrl}/redirect`, {
1906+
hooks: {
1907+
onRequest: info => requestInfos.push(info),
1908+
onResponse: info => responseInfos.push(info),
1909+
},
1910+
})
1911+
1912+
// redirect hop + final request = 2 onRequest, 2 onResponse
1913+
expect(requestInfos).toHaveLength(2)
1914+
expect(responseInfos).toHaveLength(2)
1915+
expect(responseInfos[0]!.status).toBe(302)
1916+
expect(responseInfos[1]!.status).toBe(200)
1917+
})
1918+
1919+
it('should pass custom headers through hooks', async () => {
1920+
const requestInfos: HttpHookRequestInfo[] = []
1921+
await httpRequest(`${httpBaseUrl}/json`, {
1922+
headers: { 'X-Custom': 'test-value' },
1923+
hooks: {
1924+
onRequest: info => requestInfos.push(info),
1925+
},
1926+
})
1927+
expect(requestInfos[0]!.headers['X-Custom']).toBe('test-value')
1928+
})
1929+
1930+
it('should include method in hook info for POST', async () => {
1931+
const requestInfos: HttpHookRequestInfo[] = []
1932+
await httpRequest(`${httpBaseUrl}/echo-body`, {
1933+
method: 'POST',
1934+
body: 'test',
1935+
hooks: {
1936+
onRequest: info => requestInfos.push(info),
1937+
},
1938+
})
1939+
expect(requestInfos[0]!.method).toBe('POST')
1940+
})
1941+
})
1942+
1943+
describe('maxResponseSize', () => {
1944+
it('should reject responses exceeding maxResponseSize', async () => {
1945+
await expect(
1946+
httpRequest(`${httpBaseUrl}/large-body`, {
1947+
maxResponseSize: 100,
1948+
}),
1949+
).rejects.toThrow(/exceeds maximum size limit/)
1950+
})
1951+
1952+
it('should allow responses within maxResponseSize', async () => {
1953+
const response = await httpRequest(`${httpBaseUrl}/json`, {
1954+
maxResponseSize: 1_000_000,
1955+
})
1956+
expect(response.ok).toBe(true)
1957+
})
1958+
1959+
it('should include size info in the error message', async () => {
1960+
try {
1961+
await httpRequest(`${httpBaseUrl}/large-body`, {
1962+
maxResponseSize: 50,
1963+
})
1964+
expect.unreachable('should have thrown')
1965+
} catch (e) {
1966+
expect((e as Error).message).toMatch(/MB.*>.*MB/)
1967+
}
1968+
})
1969+
1970+
it('should work with httpJson', async () => {
1971+
await expect(
1972+
httpJson(`${httpBaseUrl}/json`, {
1973+
maxResponseSize: 5,
1974+
}),
1975+
).rejects.toThrow(/exceeds maximum size limit/)
1976+
})
1977+
1978+
it('should work with httpText', async () => {
1979+
await expect(
1980+
httpText(`${httpBaseUrl}/text`, {
1981+
maxResponseSize: 5,
1982+
}),
1983+
).rejects.toThrow(/exceeds maximum size limit/)
1984+
})
1985+
1986+
it('should fire onResponse hook with error on size limit', async () => {
1987+
const responseInfos: HttpHookResponseInfo[] = []
1988+
await httpRequest(`${httpBaseUrl}/large-body`, {
1989+
maxResponseSize: 50,
1990+
hooks: {
1991+
onResponse: info => responseInfos.push(info),
1992+
},
1993+
}).catch(() => {})
1994+
1995+
// At least one hook call should contain the size limit error
1996+
expect(responseInfos.length).toBeGreaterThanOrEqual(1)
1997+
const sizeError = responseInfos.find(
1998+
info => info.error?.message?.includes('exceeds maximum size limit'),
1999+
)
2000+
expect(sizeError).toBeDefined()
2001+
})
2002+
})
2003+
2004+
describe('rawResponse', () => {
2005+
it('should expose rawResponse on HttpResponse', async () => {
2006+
const response = await httpRequest(`${httpBaseUrl}/json`)
2007+
expect(response.rawResponse).toBeDefined()
2008+
expect(response.rawResponse!.statusCode).toBe(200)
2009+
})
2010+
2011+
it('should have headers on rawResponse', async () => {
2012+
const response = await httpRequest(`${httpBaseUrl}/json`)
2013+
expect(response.rawResponse!.headers['content-type']).toContain('application/json')
2014+
})
2015+
2016+
it('should be available on non-2xx responses', async () => {
2017+
const response = await httpRequest(`${httpBaseUrl}/not-found`)
2018+
expect(response.rawResponse).toBeDefined()
2019+
expect(response.rawResponse!.statusCode).toBe(404)
2020+
})
2021+
})
2022+
2023+
describe('enrichErrorMessage', () => {
2024+
it('should enrich ECONNREFUSED', () => {
2025+
const err = Object.assign(new Error('connect failed'), { code: 'ECONNREFUSED' }) as NodeJS.ErrnoException
2026+
const msg = enrichErrorMessage('http://localhost:1', 'GET', err)
2027+
expect(msg).toContain('Connection refused')
2028+
expect(msg).toContain('GET request failed')
2029+
})
2030+
2031+
it('should enrich ENOTFOUND', () => {
2032+
const err = Object.assign(new Error('not found'), { code: 'ENOTFOUND' }) as NodeJS.ErrnoException
2033+
const msg = enrichErrorMessage('http://no-such-host.invalid', 'POST', err)
2034+
expect(msg).toContain('DNS lookup failed')
2035+
expect(msg).toContain('POST request failed')
2036+
})
2037+
2038+
it('should enrich ETIMEDOUT', () => {
2039+
const err = Object.assign(new Error('timed out'), { code: 'ETIMEDOUT' }) as NodeJS.ErrnoException
2040+
const msg = enrichErrorMessage('http://example.com', 'GET', err)
2041+
expect(msg).toContain('Connection timed out')
2042+
})
2043+
2044+
it('should enrich ECONNRESET', () => {
2045+
const err = Object.assign(new Error('reset'), { code: 'ECONNRESET' }) as NodeJS.ErrnoException
2046+
const msg = enrichErrorMessage('http://example.com', 'GET', err)
2047+
expect(msg).toContain('Connection reset')
2048+
})
2049+
2050+
it('should enrich EPIPE', () => {
2051+
const err = Object.assign(new Error('broken pipe'), { code: 'EPIPE' }) as NodeJS.ErrnoException
2052+
const msg = enrichErrorMessage('http://example.com', 'PUT', err)
2053+
expect(msg).toContain('Broken pipe')
2054+
expect(msg).toContain('PUT request failed')
2055+
})
2056+
2057+
it('should enrich CERT_HAS_EXPIRED', () => {
2058+
const err = Object.assign(new Error('cert expired'), { code: 'CERT_HAS_EXPIRED' }) as NodeJS.ErrnoException
2059+
const msg = enrichErrorMessage('https://expired.example.com', 'GET', err)
2060+
expect(msg).toContain('SSL/TLS certificate error')
2061+
})
2062+
2063+
it('should enrich UNABLE_TO_VERIFY_LEAF_SIGNATURE', () => {
2064+
const err = Object.assign(new Error('leaf sig'), { code: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' }) as NodeJS.ErrnoException
2065+
const msg = enrichErrorMessage('https://badcert.example.com', 'GET', err)
2066+
expect(msg).toContain('SSL/TLS certificate error')
2067+
})
2068+
2069+
it('should include error code for unknown codes', () => {
2070+
const err = Object.assign(new Error('something'), { code: 'ESOMETHING' }) as NodeJS.ErrnoException
2071+
const msg = enrichErrorMessage('http://example.com', 'DELETE', err)
2072+
expect(msg).toContain('Error code: ESOMETHING')
2073+
expect(msg).toContain('DELETE request failed')
2074+
})
2075+
2076+
it('should handle errors without a code', () => {
2077+
const err = new Error('generic error') as NodeJS.ErrnoException
2078+
const msg = enrichErrorMessage('http://example.com', 'GET', err)
2079+
expect(msg).toContain('GET request failed')
2080+
expect(msg).not.toContain('Error code:')
2081+
})
2082+
})
18072083
})

0 commit comments

Comments
 (0)