FetchUtilsTests.js 13 KB

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