@@ -18,13 +18,18 @@ import path from 'node:path'
1818import { Writable } from 'node:stream'
1919
2020import {
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'
2833import { Logger } from '@socketsecurity/lib/logger'
2934import { afterAll , beforeAll , describe , expect , it } from 'vitest'
3035import { 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 ( / e x c e e d s m a x i m u m s i z e l i m i t / )
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 ( / M B .* > .* M B / )
1967+ }
1968+ } )
1969+
1970+ it ( 'should work with httpJson' , async ( ) => {
1971+ await expect (
1972+ httpJson ( `${ httpBaseUrl } /json` , {
1973+ maxResponseSize : 5 ,
1974+ } ) ,
1975+ ) . rejects . toThrow ( / e x c e e d s m a x i m u m s i z e l i m i t / )
1976+ } )
1977+
1978+ it ( 'should work with httpText' , async ( ) => {
1979+ await expect (
1980+ httpText ( `${ httpBaseUrl } /text` , {
1981+ maxResponseSize : 5 ,
1982+ } ) ,
1983+ ) . rejects . toThrow ( / e x c e e d s m a x i m u m s i z e l i m i t / )
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