FetchUtilsTests.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450
  1. const { expect } = require('chai')
  2. const fs = require('node:fs')
  3. const events = require('node:events')
  4. const { FetchError, AbortError } = require('node-fetch')
  5. const { Readable } = require('node:stream')
  6. const { pipeline } = require('node:stream/promises')
  7. const { once } = require('node:events')
  8. const { TestServer } = require('./helpers/TestServer')
  9. const selfsigned = require('selfsigned')
  10. const {
  11. fetchJson,
  12. fetchStream,
  13. fetchNothing,
  14. fetchRedirect,
  15. fetchString,
  16. RequestFailedError,
  17. CustomHttpAgent,
  18. CustomHttpsAgent,
  19. } = require('../..')
  20. const HTTP_PORT = 30001
  21. const HTTPS_PORT = 30002
  22. const dns = require('node:dns')
  23. const _originalLookup = dns.lookup
  24. // Custom DNS resolver function
  25. dns.lookup = (hostname, options, callback) => {
  26. if (hostname === 'example.com') {
  27. // If the hostname is our test case, return the ip address for the test server
  28. if (options?.all) {
  29. callback(null, [{ address: '127.0.0.1', family: 4 }])
  30. } else {
  31. callback(null, '127.0.0.1', 4)
  32. }
  33. } else {
  34. // Otherwise, use the default lookup
  35. _originalLookup(hostname, options, callback)
  36. }
  37. }
  38. describe('fetch-utils', function () {
  39. let PUBLIC_CERT
  40. before(async function () {
  41. this.server = new TestServer()
  42. const attrs = [{ name: 'commonName', value: 'example.com' }]
  43. const pems = await selfsigned.generate(attrs, { days: 365, keySize: 2048 })
  44. const PRIVATE_KEY = pems.private
  45. PUBLIC_CERT = pems.cert
  46. await this.server.start(HTTP_PORT, HTTPS_PORT, {
  47. key: PRIVATE_KEY,
  48. cert: PUBLIC_CERT,
  49. })
  50. this.url = path => `http://example.com:${HTTP_PORT}${path}`
  51. this.httpsUrl = path => `https://example.com:${HTTPS_PORT}${path}`
  52. })
  53. beforeEach(function () {
  54. this.server.lastReq = undefined
  55. })
  56. after(async function () {
  57. await this.server.stop()
  58. })
  59. describe('fetchJson', function () {
  60. it('parses a JSON response', async function () {
  61. const json = await fetchJson(this.url('/json/hello'))
  62. expect(json).to.deep.equal({ msg: 'hello' })
  63. })
  64. it('parses JSON in the request', async function () {
  65. const json = await fetchJson(this.url('/json/add'), {
  66. method: 'POST',
  67. json: { a: 2, b: 3 },
  68. })
  69. expect(json).to.deep.equal({ sum: 5 })
  70. })
  71. it('accepts stringified JSON as body', async function () {
  72. const json = await fetchJson(this.url('/json/add'), {
  73. method: 'POST',
  74. body: JSON.stringify({ a: 2, b: 3 }),
  75. headers: { 'Content-Type': 'application/json' },
  76. })
  77. expect(json).to.deep.equal({ sum: 5 })
  78. })
  79. it('throws a FetchError when the payload is not JSON', async function () {
  80. await expect(fetchJson(this.url('/hello'))).to.be.rejectedWith(FetchError)
  81. })
  82. it('aborts the request if JSON parsing fails', async function () {
  83. await expect(fetchJson(this.url('/large'))).to.be.rejectedWith(FetchError)
  84. await expectRequestAborted(this.server.lastReq)
  85. })
  86. it('handles errors when the payload is JSON', async function () {
  87. await expect(fetchJson(this.url('/json/500'))).to.be.rejectedWith(
  88. RequestFailedError
  89. )
  90. await expectRequestAborted(this.server.lastReq)
  91. })
  92. it('handles errors when the payload is not JSON', async function () {
  93. await expect(fetchJson(this.url('/500'))).to.be.rejectedWith(
  94. RequestFailedError
  95. )
  96. await expectRequestAborted(this.server.lastReq)
  97. })
  98. it('supports abort signals', async function () {
  99. await expect(
  100. abortOnceReceived(
  101. signal => fetchJson(this.url('/hang'), { signal }),
  102. this.server
  103. )
  104. ).to.be.rejectedWith(AbortError)
  105. await expectRequestAborted(this.server.lastReq)
  106. })
  107. it('supports basic auth', async function () {
  108. const json = await fetchJson(this.url('/json/basic-auth'), {
  109. basicAuth: { user: 'user', password: 'pass' },
  110. })
  111. expect(json).to.deep.equal({ key: 'verysecret' })
  112. })
  113. it("destroys the request body if it doesn't get consumed", async function () {
  114. const stream = Readable.from(infiniteIterator())
  115. await fetchJson(this.url('/json/ignore-request'), {
  116. method: 'POST',
  117. body: stream,
  118. })
  119. expect(stream.destroyed).to.be.true
  120. })
  121. })
  122. describe('fetchStream', function () {
  123. it('returns a stream', async function () {
  124. const stream = await fetchStream(this.url('/large'))
  125. const text = await streamToString(stream)
  126. expect(text).to.equal(this.server.largePayload)
  127. })
  128. it('aborts the request when the stream is destroyed', async function () {
  129. const stream = await fetchStream(this.url('/large'))
  130. stream.destroy()
  131. await expectRequestAborted(this.server.lastReq)
  132. })
  133. it('aborts the request when the request body is destroyed before transfer', async function () {
  134. const stream = Readable.from(infiniteIterator())
  135. const promise = fetchStream(this.url('/hang'), {
  136. method: 'POST',
  137. body: stream,
  138. })
  139. stream.destroy()
  140. await expect(promise).to.be.rejectedWith(AbortError)
  141. await wait(80)
  142. expect(this.server.lastReq).to.be.undefined
  143. })
  144. it('aborts the request when the request body is destroyed during transfer', async function () {
  145. const stream = Readable.from(infiniteIterator())
  146. // Note: this test won't work on `/hang`
  147. const promise = fetchStream(this.url('/sink'), {
  148. method: 'POST',
  149. body: stream,
  150. })
  151. await once(this.server.events, 'request-received')
  152. stream.destroy()
  153. await expect(promise).to.be.rejectedWith(AbortError)
  154. await expectRequestAborted(this.server.lastReq)
  155. })
  156. it('handles errors', async function () {
  157. await expect(fetchStream(this.url('/500'))).to.be.rejectedWith(
  158. RequestFailedError
  159. )
  160. await expectRequestAborted(this.server.lastReq)
  161. })
  162. it('supports abort signals', async function () {
  163. await expect(
  164. abortOnceReceived(
  165. signal => fetchStream(this.url('/hang'), { signal }),
  166. this.server
  167. )
  168. ).to.be.rejectedWith(AbortError)
  169. await expectRequestAborted(this.server.lastReq)
  170. })
  171. it('destroys the request body when an error occurs', async function () {
  172. const stream = Readable.from(infiniteIterator())
  173. await expect(
  174. abortOnceReceived(
  175. signal =>
  176. fetchStream(this.url('/hang'), {
  177. method: 'POST',
  178. body: stream,
  179. signal,
  180. }),
  181. this.server
  182. )
  183. ).to.be.rejectedWith(AbortError)
  184. expect(stream.destroyed).to.be.true
  185. })
  186. it('detaches from signal on success', async function () {
  187. const signal = AbortSignal.timeout(10_000)
  188. for (let i = 0; i < 20; i++) {
  189. const s = await fetchStream(this.url('/hello'), { signal })
  190. expect(events.getEventListeners(signal, 'abort')).to.have.length(1)
  191. await pipeline(s, fs.createWriteStream('/dev/null'))
  192. expect(events.getEventListeners(signal, 'abort')).to.have.length(0)
  193. }
  194. })
  195. it('detaches from signal on error', async function () {
  196. const signal = AbortSignal.timeout(10_000)
  197. for (let i = 0; i < 20; i++) {
  198. try {
  199. await fetchStream(this.url('/500'), { signal })
  200. } catch (err) {
  201. if (err instanceof RequestFailedError && err.response.status === 500)
  202. continue
  203. throw err
  204. } finally {
  205. expect(events.getEventListeners(signal, 'abort')).to.have.length(0)
  206. }
  207. }
  208. })
  209. })
  210. describe('fetchNothing', function () {
  211. it('closes the connection', async function () {
  212. await fetchNothing(this.url('/large'))
  213. await expectRequestAborted(this.server.lastReq)
  214. })
  215. it('aborts the request when the request body is destroyed before transfer', async function () {
  216. const stream = Readable.from(infiniteIterator())
  217. const promise = fetchNothing(this.url('/hang'), {
  218. method: 'POST',
  219. body: stream,
  220. })
  221. stream.destroy()
  222. await expect(promise).to.be.rejectedWith(AbortError)
  223. expect(this.server.lastReq).to.be.undefined
  224. })
  225. it('aborts the request when the request body is destroyed during transfer', async function () {
  226. const stream = Readable.from(infiniteIterator())
  227. // Note: this test won't work on `/hang`
  228. const promise = fetchNothing(this.url('/sink'), {
  229. method: 'POST',
  230. body: stream,
  231. })
  232. await once(this.server.events, 'request-received')
  233. stream.destroy()
  234. await expect(promise).to.be.rejectedWith(AbortError)
  235. await wait(80)
  236. await expectRequestAborted(this.server.lastReq)
  237. })
  238. it("doesn't abort the request if the request body ends normally", async function () {
  239. const stream = Readable.from('hello there')
  240. await fetchNothing(this.url('/sink'), { method: 'POST', body: stream })
  241. })
  242. it('handles errors', async function () {
  243. await expect(fetchNothing(this.url('/500'))).to.be.rejectedWith(
  244. RequestFailedError
  245. )
  246. await expectRequestAborted(this.server.lastReq)
  247. })
  248. it('supports abort signals', async function () {
  249. await expect(
  250. abortOnceReceived(
  251. signal => fetchNothing(this.url('/hang'), { signal }),
  252. this.server
  253. )
  254. ).to.be.rejectedWith(AbortError)
  255. await expectRequestAborted(this.server.lastReq)
  256. })
  257. it('destroys the request body when an error occurs', async function () {
  258. const stream = Readable.from(infiniteIterator())
  259. await expect(
  260. abortOnceReceived(
  261. signal =>
  262. fetchNothing(this.url('/hang'), {
  263. method: 'POST',
  264. body: stream,
  265. signal,
  266. }),
  267. this.server
  268. )
  269. ).to.be.rejectedWith(AbortError)
  270. expect(stream.destroyed).to.be.true
  271. })
  272. })
  273. describe('fetchString', function () {
  274. it('returns a string', async function () {
  275. const body = await fetchString(this.url('/hello'))
  276. expect(body).to.equal('hello')
  277. })
  278. it('handles errors', async function () {
  279. await expect(fetchString(this.url('/500'))).to.be.rejectedWith(
  280. RequestFailedError
  281. )
  282. await expectRequestAborted(this.server.lastReq)
  283. })
  284. })
  285. describe('fetchRedirect', function () {
  286. it('returns the immediate redirect', async function () {
  287. const body = await fetchRedirect(this.url('/redirect/1'))
  288. expect(body).to.equal(this.url('/redirect/2'))
  289. })
  290. it('rejects status 200', async function () {
  291. await expect(fetchRedirect(this.url('/hello'))).to.be.rejectedWith(
  292. RequestFailedError
  293. )
  294. await expectRequestAborted(this.server.lastReq)
  295. })
  296. it('rejects empty redirect', async function () {
  297. await expect(fetchRedirect(this.url('/redirect/empty-location')))
  298. .to.be.rejectedWith(RequestFailedError)
  299. .and.eventually.have.property('cause')
  300. .and.to.have.property('message')
  301. .to.equal('missing Location response header on 3xx response')
  302. await expectRequestAborted(this.server.lastReq)
  303. })
  304. it('handles errors', async function () {
  305. await expect(fetchRedirect(this.url('/500'))).to.be.rejectedWith(
  306. RequestFailedError
  307. )
  308. await expectRequestAborted(this.server.lastReq)
  309. })
  310. })
  311. describe('CustomHttpAgent', function () {
  312. it('makes an http request successfully', async function () {
  313. const agent = new CustomHttpAgent({ connectTimeout: 100 })
  314. const body = await fetchString(this.url('/hello'), { agent })
  315. expect(body).to.equal('hello')
  316. })
  317. it('times out when accessing a non-routable address', async function () {
  318. const agent = new CustomHttpAgent({ connectTimeout: 10 })
  319. await expect(fetchString('http://10.255.255.255/', { agent }))
  320. .to.be.rejectedWith(FetchError)
  321. .and.eventually.have.property('message')
  322. .and.to.equal(
  323. 'request to http://10.255.255.255/ failed, reason: connect timeout'
  324. )
  325. })
  326. })
  327. describe('CustomHttpsAgent', function () {
  328. it('makes an https request successfully', async function () {
  329. const agent = new CustomHttpsAgent({
  330. connectTimeout: 100,
  331. ca: PUBLIC_CERT,
  332. })
  333. const body = await fetchString(this.httpsUrl('/hello'), { agent })
  334. expect(body).to.equal('hello')
  335. })
  336. it('rejects an untrusted server', async function () {
  337. const agent = new CustomHttpsAgent({
  338. connectTimeout: 100,
  339. })
  340. await expect(fetchString(this.httpsUrl('/hello'), { agent }))
  341. .to.be.rejectedWith(FetchError)
  342. .and.eventually.have.property('code')
  343. .and.to.equal('DEPTH_ZERO_SELF_SIGNED_CERT')
  344. })
  345. it('times out when accessing a non-routable address', async function () {
  346. const agent = new CustomHttpsAgent({ connectTimeout: 10 })
  347. await expect(fetchString('https://10.255.255.255/', { agent }))
  348. .to.be.rejectedWith(FetchError)
  349. .and.eventually.have.property('message')
  350. .and.to.equal(
  351. 'request to https://10.255.255.255/ failed, reason: connect timeout'
  352. )
  353. })
  354. })
  355. })
  356. async function streamToString(stream) {
  357. let s = ''
  358. for await (const chunk of stream) {
  359. s += chunk
  360. }
  361. return s
  362. }
  363. async function* infiniteIterator() {
  364. let i = 1
  365. while (true) {
  366. yield `chunk ${i++}\n`
  367. }
  368. }
  369. /**
  370. * @param {(signal: AbortSignal) => Promise<any>} func
  371. * @param {TestServer} server
  372. */
  373. async function abortOnceReceived(func, server) {
  374. const controller = new AbortController()
  375. const promise = func(controller.signal)
  376. expect(events.getEventListeners(controller.signal, 'abort')).to.have.length(1)
  377. await once(server.events, 'request-received')
  378. controller.abort()
  379. try {
  380. return await promise
  381. } finally {
  382. expect(events.getEventListeners(controller.signal, 'abort')).to.have.length(
  383. 0
  384. )
  385. }
  386. }
  387. async function expectRequestAborted(req) {
  388. if (!req.destroyed) {
  389. try {
  390. await once(req, 'close')
  391. } catch (err) {
  392. // `once` throws if req emits an 'error' event.
  393. // We ignore `Error: aborted` when the request is aborted.
  394. if (err.message !== 'aborted') {
  395. throw err
  396. }
  397. }
  398. }
  399. expect(req.destroyed).to.be.true
  400. }
  401. const wait = ms => new Promise(resolve => setTimeout(resolve, ms))