FetchUtilsTests.js 12 KB

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