FetchUtilsTests.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  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. after(async function () {
  46. await this.server.stop()
  47. })
  48. describe('fetchJson', function () {
  49. it('parses a JSON response', async function () {
  50. const json = await fetchJson(this.url('/json/hello'))
  51. expect(json).to.deep.equal({ msg: 'hello' })
  52. })
  53. it('parses JSON in the request', async function () {
  54. const json = await fetchJson(this.url('/json/add'), {
  55. method: 'POST',
  56. json: { a: 2, b: 3 },
  57. })
  58. expect(json).to.deep.equal({ sum: 5 })
  59. })
  60. it('accepts stringified JSON as body', async function () {
  61. const json = await fetchJson(this.url('/json/add'), {
  62. method: 'POST',
  63. body: JSON.stringify({ a: 2, b: 3 }),
  64. headers: { 'Content-Type': 'application/json' },
  65. })
  66. expect(json).to.deep.equal({ sum: 5 })
  67. })
  68. it('throws a FetchError when the payload is not JSON', async function () {
  69. await expect(fetchJson(this.url('/hello'))).to.be.rejectedWith(FetchError)
  70. })
  71. it('aborts the request if JSON parsing fails', async function () {
  72. await expect(fetchJson(this.url('/large'))).to.be.rejectedWith(FetchError)
  73. await expectRequestAborted(this.server.lastReq)
  74. })
  75. it('handles errors when the payload is JSON', async function () {
  76. await expect(fetchJson(this.url('/json/500'))).to.be.rejectedWith(
  77. RequestFailedError
  78. )
  79. await expectRequestAborted(this.server.lastReq)
  80. })
  81. it('handles errors when the payload is not JSON', async function () {
  82. await expect(fetchJson(this.url('/500'))).to.be.rejectedWith(
  83. RequestFailedError
  84. )
  85. await expectRequestAborted(this.server.lastReq)
  86. })
  87. it('supports abort signals', async function () {
  88. await expect(
  89. fetchJson(this.url('/hang'), { signal: AbortSignal.timeout(10) })
  90. ).to.be.rejectedWith(AbortError)
  91. await expectRequestAborted(this.server.lastReq)
  92. })
  93. it('supports basic auth', async function () {
  94. const json = await fetchJson(this.url('/json/basic-auth'), {
  95. basicAuth: { user: 'user', password: 'pass' },
  96. })
  97. expect(json).to.deep.equal({ key: 'verysecret' })
  98. })
  99. it("destroys the request body if it doesn't get consumed", async function () {
  100. const stream = Readable.from(infiniteIterator())
  101. await fetchJson(this.url('/json/ignore-request'), {
  102. method: 'POST',
  103. body: stream,
  104. })
  105. expect(stream.destroyed).to.be.true
  106. })
  107. })
  108. describe('fetchStream', function () {
  109. it('returns a stream', async function () {
  110. const stream = await fetchStream(this.url('/large'))
  111. const text = await streamToString(stream)
  112. expect(text).to.equal(this.server.largePayload)
  113. })
  114. it('aborts the request when the stream is destroyed', async function () {
  115. const stream = await fetchStream(this.url('/large'))
  116. stream.destroy()
  117. await expectRequestAborted(this.server.lastReq)
  118. })
  119. it('aborts the request when the request body is destroyed', async function () {
  120. const stream = Readable.from(infiniteIterator())
  121. const promise = fetchStream(this.url('/hang'), {
  122. method: 'POST',
  123. body: stream,
  124. })
  125. stream.destroy()
  126. await expect(promise).to.be.rejectedWith(AbortError)
  127. await expectRequestAborted(this.server.lastReq)
  128. })
  129. it('handles errors', async function () {
  130. await expect(fetchStream(this.url('/500'))).to.be.rejectedWith(
  131. RequestFailedError
  132. )
  133. await expectRequestAborted(this.server.lastReq)
  134. })
  135. it('supports abort signals', async function () {
  136. await expect(
  137. fetchStream(this.url('/hang'), { signal: AbortSignal.timeout(10) })
  138. ).to.be.rejectedWith(AbortError)
  139. await expectRequestAborted(this.server.lastReq)
  140. })
  141. it('destroys the request body when an error occurs', async function () {
  142. const stream = Readable.from(infiniteIterator())
  143. await expect(
  144. fetchStream(this.url('/hang'), {
  145. body: stream,
  146. signal: AbortSignal.timeout(10),
  147. })
  148. ).to.be.rejectedWith(AbortError)
  149. expect(stream.destroyed).to.be.true
  150. })
  151. })
  152. describe('fetchNothing', function () {
  153. it('closes the connection', async function () {
  154. await fetchNothing(this.url('/large'))
  155. await expectRequestAborted(this.server.lastReq)
  156. })
  157. it('aborts the request when the request body is destroyed', async function () {
  158. const stream = Readable.from(infiniteIterator())
  159. const promise = fetchNothing(this.url('/hang'), {
  160. method: 'POST',
  161. body: stream,
  162. })
  163. stream.destroy()
  164. await expect(promise).to.be.rejectedWith(AbortError)
  165. await expectRequestAborted(this.server.lastReq)
  166. })
  167. it("doesn't abort the request if the request body ends normally", async function () {
  168. const stream = Readable.from('hello there')
  169. await fetchNothing(this.url('/sink'), { method: 'POST', body: stream })
  170. })
  171. it('handles errors', async function () {
  172. await expect(fetchNothing(this.url('/500'))).to.be.rejectedWith(
  173. RequestFailedError
  174. )
  175. await expectRequestAborted(this.server.lastReq)
  176. })
  177. it('supports abort signals', async function () {
  178. await expect(
  179. fetchNothing(this.url('/hang'), { signal: AbortSignal.timeout(10) })
  180. ).to.be.rejectedWith(AbortError)
  181. await expectRequestAborted(this.server.lastReq)
  182. })
  183. it('destroys the request body when an error occurs', async function () {
  184. const stream = Readable.from(infiniteIterator())
  185. await expect(
  186. fetchNothing(this.url('/hang'), {
  187. body: stream,
  188. signal: AbortSignal.timeout(10),
  189. })
  190. ).to.be.rejectedWith(AbortError)
  191. expect(stream.destroyed).to.be.true
  192. })
  193. })
  194. describe('fetchString', function () {
  195. it('returns a string', async function () {
  196. const body = await fetchString(this.url('/hello'))
  197. expect(body).to.equal('hello')
  198. })
  199. it('handles errors', async function () {
  200. await expect(fetchString(this.url('/500'))).to.be.rejectedWith(
  201. RequestFailedError
  202. )
  203. await expectRequestAborted(this.server.lastReq)
  204. })
  205. })
  206. describe('fetchRedirect', function () {
  207. it('returns the immediate redirect', async function () {
  208. const body = await fetchRedirect(this.url('/redirect/1'))
  209. expect(body).to.equal(this.url('/redirect/2'))
  210. })
  211. it('rejects status 200', async function () {
  212. await expect(fetchRedirect(this.url('/hello'))).to.be.rejectedWith(
  213. RequestFailedError
  214. )
  215. await expectRequestAborted(this.server.lastReq)
  216. })
  217. it('rejects empty redirect', async function () {
  218. await expect(fetchRedirect(this.url('/redirect/empty-location')))
  219. .to.be.rejectedWith(RequestFailedError)
  220. .and.eventually.have.property('cause')
  221. .and.to.have.property('message')
  222. .to.equal('missing Location response header on 3xx response')
  223. await expectRequestAborted(this.server.lastReq)
  224. })
  225. it('handles errors', async function () {
  226. await expect(fetchRedirect(this.url('/500'))).to.be.rejectedWith(
  227. RequestFailedError
  228. )
  229. await expectRequestAborted(this.server.lastReq)
  230. })
  231. })
  232. describe('CustomHttpAgent', function () {
  233. it('makes an http request successfully', async function () {
  234. const agent = new CustomHttpAgent({ connectTimeout: 100 })
  235. const body = await fetchString(this.url('/hello'), { agent })
  236. expect(body).to.equal('hello')
  237. })
  238. it('times out when accessing a non-routable address', async function () {
  239. const agent = new CustomHttpAgent({ connectTimeout: 10 })
  240. await expect(fetchString('http://10.255.255.255/', { agent }))
  241. .to.be.rejectedWith(FetchError)
  242. .and.eventually.have.property('message')
  243. .and.to.equal(
  244. 'request to http://10.255.255.255/ failed, reason: connect timeout'
  245. )
  246. })
  247. })
  248. describe('CustomHttpsAgent', function () {
  249. it('makes an https request successfully', async function () {
  250. const agent = new CustomHttpsAgent({
  251. connectTimeout: 100,
  252. ca: PUBLIC_CERT,
  253. })
  254. const body = await fetchString(this.httpsUrl('/hello'), { agent })
  255. expect(body).to.equal('hello')
  256. })
  257. it('rejects an untrusted server', async function () {
  258. const agent = new CustomHttpsAgent({
  259. connectTimeout: 100,
  260. })
  261. await expect(fetchString(this.httpsUrl('/hello'), { agent }))
  262. .to.be.rejectedWith(FetchError)
  263. .and.eventually.have.property('code')
  264. .and.to.equal('DEPTH_ZERO_SELF_SIGNED_CERT')
  265. })
  266. it('times out when accessing a non-routable address', async function () {
  267. const agent = new CustomHttpsAgent({ connectTimeout: 10 })
  268. await expect(fetchString('https://10.255.255.255/', { agent }))
  269. .to.be.rejectedWith(FetchError)
  270. .and.eventually.have.property('message')
  271. .and.to.equal(
  272. 'request to https://10.255.255.255/ failed, reason: connect timeout'
  273. )
  274. })
  275. })
  276. })
  277. async function streamToString(stream) {
  278. let s = ''
  279. for await (const chunk of stream) {
  280. s += chunk
  281. }
  282. return s
  283. }
  284. async function* infiniteIterator() {
  285. let i = 1
  286. while (true) {
  287. yield `chunk ${i++}\n`
  288. }
  289. }
  290. async function expectRequestAborted(req) {
  291. if (!req.destroyed) {
  292. await once(req, 'close')
  293. expect(req.destroyed).to.be.true
  294. }
  295. }