FetchUtilsTests.js 14 KB

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