FetchUtilsTests.js 13 KB

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