FetchUtilsTests.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381
  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. fetchJson(this.url('/hang'), { signal: AbortSignal.timeout(10) })
  93. ).to.be.rejectedWith(AbortError)
  94. await expectRequestAborted(this.server.lastReq)
  95. })
  96. it('supports basic auth', async function () {
  97. const json = await fetchJson(this.url('/json/basic-auth'), {
  98. basicAuth: { user: 'user', password: 'pass' },
  99. })
  100. expect(json).to.deep.equal({ key: 'verysecret' })
  101. })
  102. it("destroys the request body if it doesn't get consumed", async function () {
  103. const stream = Readable.from(infiniteIterator())
  104. await fetchJson(this.url('/json/ignore-request'), {
  105. method: 'POST',
  106. body: stream,
  107. })
  108. expect(stream.destroyed).to.be.true
  109. })
  110. })
  111. describe('fetchStream', function () {
  112. it('returns a stream', async function () {
  113. const stream = await fetchStream(this.url('/large'))
  114. const text = await streamToString(stream)
  115. expect(text).to.equal(this.server.largePayload)
  116. })
  117. it('aborts the request when the stream is destroyed', async function () {
  118. const stream = await fetchStream(this.url('/large'))
  119. stream.destroy()
  120. await expectRequestAborted(this.server.lastReq)
  121. })
  122. it('aborts the request when the request body is destroyed before transfer', async function () {
  123. const stream = Readable.from(infiniteIterator())
  124. const promise = fetchStream(this.url('/hang'), {
  125. method: 'POST',
  126. body: stream,
  127. })
  128. stream.destroy()
  129. await expect(promise).to.be.rejectedWith(AbortError)
  130. await wait(80)
  131. expect(this.server.lastReq).to.be.undefined
  132. })
  133. it('aborts the request when the request body is destroyed during transfer', async function () {
  134. const stream = Readable.from(infiniteIterator())
  135. // Note: this test won't work on `/hang`
  136. const promise = fetchStream(this.url('/sink'), {
  137. method: 'POST',
  138. body: stream,
  139. })
  140. await once(this.server.events, 'request-received')
  141. stream.destroy()
  142. await expect(promise).to.be.rejectedWith(AbortError)
  143. await expectRequestAborted(this.server.lastReq)
  144. })
  145. it('handles errors', async function () {
  146. await expect(fetchStream(this.url('/500'))).to.be.rejectedWith(
  147. RequestFailedError
  148. )
  149. await expectRequestAborted(this.server.lastReq)
  150. })
  151. it('supports abort signals', async function () {
  152. await expect(
  153. fetchStream(this.url('/hang'), { signal: AbortSignal.timeout(10) })
  154. ).to.be.rejectedWith(AbortError)
  155. await expectRequestAborted(this.server.lastReq)
  156. })
  157. it('destroys the request body when an error occurs', async function () {
  158. const stream = Readable.from(infiniteIterator())
  159. await expect(
  160. fetchStream(this.url('/hang'), {
  161. method: 'POST',
  162. body: stream,
  163. signal: AbortSignal.timeout(10),
  164. })
  165. ).to.be.rejectedWith(AbortError)
  166. expect(stream.destroyed).to.be.true
  167. })
  168. })
  169. describe('fetchNothing', function () {
  170. it('closes the connection', async function () {
  171. await fetchNothing(this.url('/large'))
  172. await expectRequestAborted(this.server.lastReq)
  173. })
  174. it('aborts the request when the request body is destroyed before transfer', async function () {
  175. const stream = Readable.from(infiniteIterator())
  176. const promise = fetchNothing(this.url('/hang'), {
  177. method: 'POST',
  178. body: stream,
  179. })
  180. stream.destroy()
  181. await expect(promise).to.be.rejectedWith(AbortError)
  182. expect(this.server.lastReq).to.be.undefined
  183. })
  184. it('aborts the request when the request body is destroyed during transfer', async function () {
  185. const stream = Readable.from(infiniteIterator())
  186. // Note: this test won't work on `/hang`
  187. const promise = fetchNothing(this.url('/sink'), {
  188. method: 'POST',
  189. body: stream,
  190. })
  191. await once(this.server.events, 'request-received')
  192. stream.destroy()
  193. await expect(promise).to.be.rejectedWith(AbortError)
  194. await wait(80)
  195. await expectRequestAborted(this.server.lastReq)
  196. })
  197. it("doesn't abort the request if the request body ends normally", async function () {
  198. const stream = Readable.from('hello there')
  199. await fetchNothing(this.url('/sink'), { method: 'POST', body: stream })
  200. })
  201. it('handles errors', async function () {
  202. await expect(fetchNothing(this.url('/500'))).to.be.rejectedWith(
  203. RequestFailedError
  204. )
  205. await expectRequestAborted(this.server.lastReq)
  206. })
  207. it('supports abort signals', async function () {
  208. await expect(
  209. fetchNothing(this.url('/hang'), { signal: AbortSignal.timeout(10) })
  210. ).to.be.rejectedWith(AbortError)
  211. await expectRequestAborted(this.server.lastReq)
  212. })
  213. it('destroys the request body when an error occurs', async function () {
  214. const stream = Readable.from(infiniteIterator())
  215. await expect(
  216. fetchNothing(this.url('/hang'), {
  217. method: 'POST',
  218. body: stream,
  219. signal: AbortSignal.timeout(10),
  220. })
  221. ).to.be.rejectedWith(AbortError)
  222. expect(stream.destroyed).to.be.true
  223. })
  224. })
  225. describe('fetchString', function () {
  226. it('returns a string', async function () {
  227. const body = await fetchString(this.url('/hello'))
  228. expect(body).to.equal('hello')
  229. })
  230. it('handles errors', async function () {
  231. await expect(fetchString(this.url('/500'))).to.be.rejectedWith(
  232. RequestFailedError
  233. )
  234. await expectRequestAborted(this.server.lastReq)
  235. })
  236. })
  237. describe('fetchRedirect', function () {
  238. it('returns the immediate redirect', async function () {
  239. const body = await fetchRedirect(this.url('/redirect/1'))
  240. expect(body).to.equal(this.url('/redirect/2'))
  241. })
  242. it('rejects status 200', async function () {
  243. await expect(fetchRedirect(this.url('/hello'))).to.be.rejectedWith(
  244. RequestFailedError
  245. )
  246. await expectRequestAborted(this.server.lastReq)
  247. })
  248. it('rejects empty redirect', async function () {
  249. await expect(fetchRedirect(this.url('/redirect/empty-location')))
  250. .to.be.rejectedWith(RequestFailedError)
  251. .and.eventually.have.property('cause')
  252. .and.to.have.property('message')
  253. .to.equal('missing Location response header on 3xx response')
  254. await expectRequestAborted(this.server.lastReq)
  255. })
  256. it('handles errors', async function () {
  257. await expect(fetchRedirect(this.url('/500'))).to.be.rejectedWith(
  258. RequestFailedError
  259. )
  260. await expectRequestAborted(this.server.lastReq)
  261. })
  262. })
  263. describe('CustomHttpAgent', function () {
  264. it('makes an http request successfully', async function () {
  265. const agent = new CustomHttpAgent({ connectTimeout: 100 })
  266. const body = await fetchString(this.url('/hello'), { agent })
  267. expect(body).to.equal('hello')
  268. })
  269. it('times out when accessing a non-routable address', async function () {
  270. const agent = new CustomHttpAgent({ connectTimeout: 10 })
  271. await expect(fetchString('http://10.255.255.255/', { agent }))
  272. .to.be.rejectedWith(FetchError)
  273. .and.eventually.have.property('message')
  274. .and.to.equal(
  275. 'request to http://10.255.255.255/ failed, reason: connect timeout'
  276. )
  277. })
  278. })
  279. describe('CustomHttpsAgent', function () {
  280. it('makes an https request successfully', async function () {
  281. const agent = new CustomHttpsAgent({
  282. connectTimeout: 100,
  283. ca: PUBLIC_CERT,
  284. })
  285. const body = await fetchString(this.httpsUrl('/hello'), { agent })
  286. expect(body).to.equal('hello')
  287. })
  288. it('rejects an untrusted server', async function () {
  289. const agent = new CustomHttpsAgent({
  290. connectTimeout: 100,
  291. })
  292. await expect(fetchString(this.httpsUrl('/hello'), { agent }))
  293. .to.be.rejectedWith(FetchError)
  294. .and.eventually.have.property('code')
  295. .and.to.equal('DEPTH_ZERO_SELF_SIGNED_CERT')
  296. })
  297. it('times out when accessing a non-routable address', async function () {
  298. const agent = new CustomHttpsAgent({ connectTimeout: 10 })
  299. await expect(fetchString('https://10.255.255.255/', { agent }))
  300. .to.be.rejectedWith(FetchError)
  301. .and.eventually.have.property('message')
  302. .and.to.equal(
  303. 'request to https://10.255.255.255/ failed, reason: connect timeout'
  304. )
  305. })
  306. })
  307. })
  308. async function streamToString(stream) {
  309. let s = ''
  310. for await (const chunk of stream) {
  311. s += chunk
  312. }
  313. return s
  314. }
  315. async function* infiniteIterator() {
  316. let i = 1
  317. while (true) {
  318. yield `chunk ${i++}\n`
  319. }
  320. }
  321. async function expectRequestAborted(req) {
  322. if (!req.destroyed) {
  323. try {
  324. await once(req, 'close')
  325. } catch (err) {
  326. // `once` throws if req emits an 'error' event.
  327. // We ignore `Error: aborted` when the request is aborted.
  328. if (err.message !== 'aborted') {
  329. throw err
  330. }
  331. }
  332. expect(req.destroyed).to.be.true
  333. }
  334. }
  335. const wait = ms => new Promise(resolve => setTimeout(resolve, ms))