fetch-http.spec.ts 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628
  1. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  2. import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
  3. import { AddressInfo } from 'node:net'
  4. import { Context } from '@deepseek-ai/cordis'
  5. import WebRuntime from '@deepseek-ai/dsh-web'
  6. import { HttpFetchProvider, LOCAL_FETCH_PROVIDER_ID } from '@deepseek-ai/dsh-web-fetch-http'
  7. import type { HttpFetchLimits, HttpFetchResolver } from '@deepseek-ai/dsh-web-fetch-http'
  8. import * as fetchPlugin from '@deepseek-ai/dsh-web-fetch-http'
  9. import { createPinnedLookup, isPublicIpAddress, publicHttpNetwork, requestPinned, resolvePublicAddresses } from '../src/network.ts'
  10. import {
  11. classifyContentType,
  12. decoderForCharset,
  13. isSameOrigin,
  14. parseCharset,
  15. parseFetchUrl,
  16. validateFetchUrl,
  17. WEB_FETCH_MAX_URL_LENGTH,
  18. } from '../src/policy.ts'
  19. const limits: HttpFetchLimits = {
  20. maxResponseBytes: 5_000_000,
  21. maxBodyChars: 100_000,
  22. timeoutMs: 5_000,
  23. maxRedirects: 5,
  24. userAgent: 'test-agent/1.0',
  25. }
  26. type Handler = (req: IncomingMessage, res: ServerResponse) => void
  27. let server: Server
  28. let base: string
  29. let handler: Handler
  30. let restoreResolution: () => void
  31. beforeEach(async () => {
  32. handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('default') }
  33. server = createServer((req, res) => { handler(req, res) })
  34. await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
  35. const { port } = server.address() as AddressInfo
  36. base = `http://127.0.0.1:${port}`
  37. const spy = vi.spyOn(publicHttpNetwork, 'resolve').mockResolvedValue([{ address: '127.0.0.1', family: 4 }])
  38. restoreResolution = () => { spy.mockRestore() }
  39. })
  40. afterEach(async () => {
  41. vi.unstubAllGlobals()
  42. vi.restoreAllMocks()
  43. await new Promise<void>(resolve => server.close(() => { resolve() }))
  44. })
  45. function provider(overrides: Partial<HttpFetchLimits> = {}): HttpFetchProvider {
  46. return new HttpFetchProvider({ ...limits, ...overrides })
  47. }
  48. describe('policy helpers', () => {
  49. it('validates scheme, credentials, and length', () => {
  50. expect(parseFetchUrl('https://example.com/preflight').pathname).toBe('/preflight')
  51. expect(validateFetchUrl('https://example.com/x').hostname).toBe('example.com')
  52. expect(() => validateFetchUrl('ftp://example.com')).toThrow(expect.objectContaining({ code: 'WEB_INVALID_URL' }))
  53. expect(() => validateFetchUrl('not a url')).toThrow(expect.objectContaining({ code: 'WEB_INVALID_URL' }))
  54. expect(() => validateFetchUrl('https://user:pass@example.com')).toThrow(expect.objectContaining({ code: 'WEB_BLOCKED_URL' }))
  55. const prefix = 'https://example.com/'
  56. const exact = `${prefix}${'a'.repeat(WEB_FETCH_MAX_URL_LENGTH - prefix.length)}`
  57. expect(validateFetchUrl(exact).href).toBe(exact)
  58. expect(() => validateFetchUrl(`${exact}a`)).toThrow(expect.objectContaining({ code: 'WEB_INVALID_URL' }))
  59. })
  60. it('classifies content types', () => {
  61. expect(classifyContentType('text/html; charset=utf-8')).toBe('html')
  62. expect(classifyContentType('application/xhtml+xml')).toBe('html')
  63. expect(classifyContentType('text/plain')).toBe('text')
  64. expect(classifyContentType('application/json')).toBe('text')
  65. expect(classifyContentType('image/png')).toBeUndefined()
  66. expect(classifyContentType(null)).toBeUndefined()
  67. })
  68. it('compares origins', () => {
  69. expect(isSameOrigin(new URL('https://a.com/x'), new URL('https://a.com/y'))).toBe(true)
  70. expect(isSameOrigin(new URL('https://a.com'), new URL('https://b.com'))).toBe(false)
  71. expect(isSameOrigin(new URL('http://a.com'), new URL('https://a.com'))).toBe(false)
  72. })
  73. it('parses the charset parameter', () => {
  74. expect(parseCharset('text/html; charset=UTF-8')).toBe('utf-8')
  75. expect(parseCharset('text/plain; charset="iso-8859-1"')).toBe('iso-8859-1')
  76. expect(parseCharset('text/plain')).toBeUndefined()
  77. expect(parseCharset(null)).toBeUndefined()
  78. })
  79. it('builds a decoder for a charset and defaults to UTF-8', () => {
  80. expect(decoderForCharset(undefined).encoding).toBe('utf-8')
  81. expect(decoderForCharset('iso-8859-1').encoding).toBe('windows-1252')
  82. expect(() => decoderForCharset('not-a-charset')).toThrow(expect.objectContaining({ code: 'WEB_UNSUPPORTED_CONTENT_TYPE' }))
  83. })
  84. })
  85. describe('public-network policy', () => {
  86. it('accepts only globally reachable unicast addresses', () => {
  87. for (const address of ['8.8.8.8', '2001:4860:4860::8888', '::ffff:8.8.8.8']) {
  88. expect(isPublicIpAddress(address), address).toBe(true)
  89. }
  90. for (const address of [
  91. '0.0.0.0',
  92. '10.0.0.1',
  93. '100.64.0.1',
  94. '127.0.0.1',
  95. '169.254.169.254',
  96. '192.0.2.1',
  97. '224.0.0.1',
  98. '255.255.255.255',
  99. '::',
  100. '::1',
  101. 'fe80::1',
  102. 'fc00::1',
  103. 'ff02::1',
  104. '::ffff:127.0.0.1',
  105. '64:ff9b::808:808',
  106. 'not-an-ip',
  107. ]) {
  108. expect(isPublicIpAddress(address), address).toBe(false)
  109. }
  110. })
  111. it('retains one fully public DNS answer set', async () => {
  112. const resolver = vi.fn(async () => [
  113. { address: '8.8.4.4', family: 4 },
  114. { address: '2001:4860:4860::8888', family: 6 },
  115. ])
  116. await expect(resolvePublicAddresses('example.test', new AbortController().signal, resolver))
  117. .resolves.toEqual([
  118. { address: '8.8.4.4', family: 4 },
  119. { address: '2001:4860:4860::8888', family: 6 },
  120. ])
  121. })
  122. it('rejects the whole DNS answer set when one address is not public', async () => {
  123. const resolver = vi.fn(async () => [
  124. { address: '8.8.8.8', family: 4 },
  125. { address: '127.0.0.1', family: 4 },
  126. ])
  127. await expect(resolvePublicAddresses('rebinding.test', new AbortController().signal, resolver))
  128. .rejects.toThrow(expect.objectContaining({ code: 'WEB_BLOCKED_URL' }))
  129. })
  130. it('rejects empty and invalid resolver results', async () => {
  131. await expect(resolvePublicAddresses('empty.test', new AbortController().signal, async () => []))
  132. .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' }))
  133. await expect(resolvePublicAddresses('family.test', new AbortController().signal, async () => [{ address: '8.8.8.8', family: 0 }]))
  134. .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' }))
  135. await expect(resolvePublicAddresses('mismatch.test', new AbortController().signal, async () => [{ address: '::1', family: 4 }]))
  136. .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' }))
  137. })
  138. it('validates bracketed IPv6 literals after checking for an active DNS64 prefix', async () => {
  139. const resolver = vi.fn(async () => [{ address: '192.0.0.170', family: 4 }])
  140. await expect(resolvePublicAddresses('[2001:4860:4860::8888]', new AbortController().signal, resolver))
  141. .resolves.toEqual([{ address: '2001:4860:4860::8888', family: 6 }])
  142. expect(resolver).toHaveBeenCalledWith('ipv4only.arpa', { all: true, order: 'verbatim' })
  143. })
  144. it('rejects a network-specific NAT64 address that translates to private IPv4', async () => {
  145. const resolver = vi.fn(async (hostname: string) => hostname === 'ipv4only.arpa'
  146. ? [{ address: '2001:4860:64:64::c000:aa', family: 6 }]
  147. : [{ address: '2001:4860:64:64::7f00:1', family: 6 }])
  148. await expect(resolvePublicAddresses('nat64.test', new AbortController().signal, resolver))
  149. .rejects.toThrow(expect.objectContaining({ code: 'WEB_BLOCKED_URL' }))
  150. })
  151. it('accepts a network-specific NAT64 address that translates to public IPv4', async () => {
  152. const resolver = vi.fn(async (hostname: string) => hostname === 'ipv4only.arpa'
  153. ? [{ address: '2001:4860:64:64::c000:aa', family: 6 }]
  154. : [{ address: '2001:4860:64:64::808:808', family: 6 }])
  155. await expect(resolvePublicAddresses('nat64.test', new AbortController().signal, resolver))
  156. .resolves.toEqual([{ address: '2001:4860:64:64::808:808', family: 6 }])
  157. })
  158. it('deduplicates discovered prefixes and ignores addresses outside their translation layout', async () => {
  159. const resolver = vi.fn(async (hostname: string) => hostname === 'ipv4only.arpa'
  160. ? [
  161. { address: '2001:4860:64:64::c000:aa', family: 6 },
  162. { address: '2001:4860:64:64::c000:ab', family: 6 },
  163. { address: '2001:4860:64:64:c0:0:aa00:0', family: 6 },
  164. ]
  165. : [
  166. { address: '2001:4860:65:64::808:808', family: 6 },
  167. { address: '2001:4860:64:64:100::1', family: 6 },
  168. ])
  169. await expect(resolvePublicAddresses('native-v6.test', new AbortController().signal, resolver))
  170. .resolves.toEqual([
  171. { address: '2001:4860:65:64::808:808', family: 6 },
  172. { address: '2001:4860:64:64:100::1', family: 6 },
  173. ])
  174. })
  175. it('stops waiting for DNS when the request is aborted', async () => {
  176. let finish!: (value: never[]) => void
  177. const resolver = vi.fn(() => new Promise<never[]>((resolve) => { finish = resolve }))
  178. const controller = new AbortController()
  179. const pending = resolvePublicAddresses('slow.test', controller.signal, resolver)
  180. controller.abort(new Error('stop'))
  181. await expect(pending).rejects.toThrow('web fetch aborted during hostname resolution')
  182. finish([])
  183. const alreadyAborted = new AbortController()
  184. alreadyAborted.abort(new Error('already stopped'))
  185. await expect(resolvePublicAddresses('slow.test', alreadyAborted.signal, resolver))
  186. .rejects.toThrow('web fetch aborted during hostname resolution')
  187. })
  188. it('propagates resolver failures', async () => {
  189. await expect(resolvePublicAddresses('broken.test', new AbortController().signal, async () => { throw new Error('dns failed') }))
  190. .rejects.toThrow('dns failed')
  191. })
  192. it('serves only the retained addresses through the connector lookup', async () => {
  193. const lookup = createPinnedLookup([
  194. { address: '8.8.8.8', family: 4 },
  195. { address: '2001:4860:4860::8888', family: 6 },
  196. ])
  197. const call = (options: Parameters<typeof lookup>[1]) => new Promise<{
  198. error: NodeJS.ErrnoException | null
  199. address: string | import('node:dns').LookupAddress[]
  200. family: number | undefined
  201. }>((resolve) => {
  202. lookup('fixed.test', options, (error, address, family) => { resolve({ error, address, family }) })
  203. })
  204. await expect(call({ all: true })).resolves.toMatchObject({
  205. error: null,
  206. address: [{ address: '8.8.8.8', family: 4 }, { address: '2001:4860:4860::8888', family: 6 }],
  207. })
  208. await expect(call({ family: 4 })).resolves.toMatchObject({ error: null, address: '8.8.8.8', family: 4 })
  209. await expect(call({ family: 'IPv6' })).resolves.toMatchObject({ error: null, address: '2001:4860:4860::8888', family: 6 })
  210. await expect(call({ family: 'IPv4' })).resolves.toMatchObject({ error: null, address: '8.8.8.8', family: 4 })
  211. await expect(call({ family: 7 })).resolves.toMatchObject({ error: { code: 'ENOTFOUND' }, address: '', family: 7 })
  212. await expect(call({ family: 7, all: true })).resolves.toMatchObject({ error: { code: 'ENOTFOUND' }, address: [], family: 7 })
  213. })
  214. it('pins the connection to the validated address without resolving the URL hostname again', async () => {
  215. handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('pinned') }
  216. const { port } = server.address() as AddressInfo
  217. const request = await requestPinned(
  218. new URL(`http://does-not-resolve.invalid:${port}/`),
  219. [{ address: '127.0.0.1', family: 4 }],
  220. {},
  221. new AbortController().signal,
  222. )
  223. try {
  224. await expect(request.response.text()).resolves.toBe('pinned')
  225. } finally {
  226. await request.close()
  227. }
  228. })
  229. })
  230. describe('HttpFetchProvider success', () => {
  231. it('fetches a text body', async () => {
  232. handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('hello world') }
  233. const result = await provider().fetch({ url: base })
  234. expect(provider().available()).toBe(true)
  235. expect(result.statusCode).toBe(200)
  236. expect(result.body).toEqual({ kind: 'text', content: 'hello world' })
  237. expect(result.truncated).toBe(false)
  238. })
  239. it('fetches an html body and classifies it as html', async () => {
  240. handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/html' }); res.end('<h1>hi</h1>') }
  241. const result = await provider().fetch({ url: base })
  242. expect(result.body).toEqual({ kind: 'html', content: '<h1>hi</h1>' })
  243. })
  244. it('uses an explicitly injected validated-address resolver', async () => {
  245. const resolveAddresses = vi.fn<HttpFetchResolver>(async () => [{ address: '127.0.0.1', family: 4 }])
  246. const result = await new HttpFetchProvider(limits, resolveAddresses).fetch({ url: base })
  247. expect(result.statusCode).toBe(200)
  248. expect(resolveAddresses).toHaveBeenCalledWith('127.0.0.1', expect.any(AbortSignal))
  249. expect(publicHttpNetwork.resolve).not.toHaveBeenCalled()
  250. })
  251. it('sends the configured user agent', async () => {
  252. let seen: string | undefined
  253. handler = (req, res) => { seen = req.headers['user-agent']; res.writeHead(200, { 'content-type': 'text/plain' }); res.end('ok') }
  254. await provider().fetch({ url: base })
  255. expect(seen).toBe('test-agent/1.0')
  256. })
  257. it('returns a non-2xx response as a result, not an error', async () => {
  258. handler = (_req, res) => { res.writeHead(404, { 'content-type': 'text/plain' }); res.end('nope') }
  259. const result = await provider().fetch({ url: base })
  260. expect(result.statusCode).toBe(404)
  261. expect(result.body).toEqual({ kind: 'text', content: 'nope' })
  262. })
  263. })
  264. describe('HttpFetchProvider caps', () => {
  265. it('rejects an over-cap Content-Length with WEB_FETCH_TOO_LARGE', async () => {
  266. handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain', 'content-length': '999999' }); res.end('x'.repeat(999999)) }
  267. await expect(provider({ maxResponseBytes: 10 }).fetch({ url: base }))
  268. .rejects.toThrow(expect.objectContaining({ code: 'WEB_FETCH_TOO_LARGE' }))
  269. })
  270. it('truncates a stream that grows past the byte cap', async () => {
  271. handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('abcdefghij') }
  272. const result = await provider({ maxResponseBytes: 4 }).fetch({ url: base })
  273. expect(result.body.content).toBe('abcd')
  274. expect(result.truncated).toBe(true)
  275. })
  276. it('does not flag a body that exactly fills the byte cap as truncated', async () => {
  277. handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('abcd') }
  278. const result = await provider({ maxResponseBytes: 4 }).fetch({ url: base })
  279. expect(result.body.content).toBe('abcd')
  280. expect(result.truncated).toBe(false)
  281. })
  282. it('truncates a decoded body past the character cap', async () => {
  283. handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('abcdefghij') }
  284. const result = await provider({ maxBodyChars: 3 }).fetch({ url: base })
  285. expect(result.body.content).toBe('abc')
  286. expect(result.truncated).toBe(true)
  287. })
  288. it('rejects an unsupported content type', async () => {
  289. handler = (_req, res) => { res.writeHead(200, { 'content-type': 'image/png' }); res.end('binary') }
  290. await expect(provider().fetch({ url: base }))
  291. .rejects.toThrow(expect.objectContaining({ code: 'WEB_UNSUPPORTED_CONTENT_TYPE' }))
  292. })
  293. it('rejects a response with no content type at all', async () => {
  294. handler = (_req, res) => { res.writeHead(200); res.end('no type') }
  295. await expect(provider().fetch({ url: base }))
  296. .rejects.toThrow(expect.objectContaining({ code: 'WEB_UNSUPPORTED_CONTENT_TYPE' }))
  297. })
  298. it('accepts a declared content-length within the cap', async () => {
  299. handler = (_req, res) => { const body = 'sized'; res.writeHead(200, { 'content-type': 'text/plain', 'content-length': String(body.length) }); res.end(body) }
  300. const result = await provider().fetch({ url: base })
  301. expect(result.body.content).toBe('sized')
  302. })
  303. it('decodes a non-UTF-8 declared charset', async () => {
  304. // 0xE9 is "é" in ISO-8859-1; decoded as UTF-8 it would be a replacement char.
  305. handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain; charset=iso-8859-1' }); res.end(Buffer.from([0x63, 0x61, 0x66, 0xE9])) }
  306. const result = await provider().fetch({ url: base })
  307. expect(result.body.content).toBe('café')
  308. })
  309. it('rejects an unsupported declared charset', async () => {
  310. handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain; charset=not-a-charset' }); res.end('x') }
  311. await expect(provider().fetch({ url: base }))
  312. .rejects.toThrow(expect.objectContaining({ code: 'WEB_UNSUPPORTED_CONTENT_TYPE' }))
  313. })
  314. })
  315. describe('HttpFetchProvider redirects', () => {
  316. it('follows a same-origin redirect and reports the final URL', async () => {
  317. handler = (req, res) => {
  318. if (req.url === '/start') { res.writeHead(302, { location: '/end' }); res.end() }
  319. else { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('arrived') }
  320. }
  321. const result = await provider().fetch({ url: `${base}/start` })
  322. expect(result.body.content).toBe('arrived')
  323. expect(result.url).toBe(`${base}/end`)
  324. })
  325. it('blocks a cross-origin redirect with WEB_REDIRECT_BLOCKED', async () => {
  326. handler = (_req, res) => { res.writeHead(302, { location: 'https://example.com/' }); res.end() }
  327. await expect(provider().fetch({ url: base }))
  328. .rejects.toThrow(expect.objectContaining({ code: 'WEB_REDIRECT_BLOCKED' }))
  329. })
  330. it('re-validates a redirect target, rejecting same-origin credentials in the Location', async () => {
  331. const { port } = server.address() as AddressInfo
  332. handler = (_req, res) => { res.writeHead(302, { location: `http://user:pass@127.0.0.1:${port}/` }); res.end() }
  333. await expect(provider().fetch({ url: base }))
  334. .rejects.toThrow(expect.objectContaining({ code: 'WEB_BLOCKED_URL' }))
  335. })
  336. it('rejects exceeding the redirect hop cap', async () => {
  337. handler = (req, res) => {
  338. const n = Number(new URL(req.url ?? '/', base).searchParams.get('n') ?? '0')
  339. res.writeHead(302, { location: `/?n=${n + 1}` })
  340. res.end()
  341. }
  342. await expect(provider({ maxRedirects: 2 }).fetch({ url: `${base}/?n=0` }))
  343. .rejects.toThrow(expect.objectContaining({ code: 'WEB_REDIRECT_BLOCKED' }))
  344. })
  345. it('follows exactly maxRedirects hops: a chain landing on the Nth redirect succeeds', async () => {
  346. // maxRedirects: 2 → /?n=0 → /?n=1 → /?n=2(200). Exactly 2 redirects + 1
  347. // final = 3 requests; the cap is inclusive of the landing request.
  348. let requests = 0
  349. handler = (req, res) => {
  350. requests++
  351. const n = Number(new URL(req.url ?? '/', base).searchParams.get('n') ?? '0')
  352. if (n >= 2) { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('landed') }
  353. else { res.writeHead(302, { location: `/?n=${n + 1}` }); res.end() }
  354. }
  355. const result = await provider({ maxRedirects: 2 }).fetch({ url: `${base}/?n=0` })
  356. expect(result.body.content).toBe('landed')
  357. expect(requests).toBe(3)
  358. })
  359. it('makes exactly maxRedirects+1 requests before blocking an over-long chain', async () => {
  360. // maxRedirects: 2 on an infinite chain: requests at n=0,1,2 (the 3rd is the
  361. // over-limit redirect, refused before its Location is followed) = 3 total.
  362. let requests = 0
  363. handler = (req, res) => {
  364. requests++
  365. const n = Number(new URL(req.url ?? '/', base).searchParams.get('n') ?? '0')
  366. res.writeHead(302, { location: `/?n=${n + 1}` })
  367. res.end()
  368. }
  369. await expect(provider({ maxRedirects: 2 }).fetch({ url: `${base}/?n=0` }))
  370. .rejects.toThrow(expect.objectContaining({ code: 'WEB_REDIRECT_BLOCKED', message: 'exceeded the maximum of 2 redirects' }))
  371. expect(requests).toBe(3)
  372. })
  373. it('reports an over-limit redirect as "exceeded", not cross-origin, even when the over-limit hop points cross-origin', async () => {
  374. // The redirect budget is checked BEFORE the over-limit hop's target is
  375. // origin-validated, so the diagnosis is "exceeded", not "cross-origin".
  376. handler = (req, res) => {
  377. const n = Number(new URL(req.url ?? '/', base).searchParams.get('n') ?? '0')
  378. const location = n === 0 ? '/?n=1' : 'https://example.com/'
  379. res.writeHead(302, { location })
  380. res.end()
  381. }
  382. await expect(provider({ maxRedirects: 1 }).fetch({ url: `${base}/?n=0` }))
  383. .rejects.toThrow(expect.objectContaining({ code: 'WEB_REDIRECT_BLOCKED', message: 'exceeded the maximum of 1 redirects' }))
  384. })
  385. it('maxRedirects: 0 follows no redirect but still fetches a direct 200', async () => {
  386. handler = (req, res) => {
  387. if (req.url === '/r') { res.writeHead(302, { location: '/done' }); res.end() }
  388. else { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('direct') }
  389. }
  390. await expect(provider({ maxRedirects: 0 }).fetch({ url: `${base}/r` }))
  391. .rejects.toThrow(expect.objectContaining({ code: 'WEB_REDIRECT_BLOCKED' }))
  392. const direct = await provider({ maxRedirects: 0 }).fetch({ url: `${base}/done` })
  393. expect(direct.body.content).toBe('direct')
  394. })
  395. it('treats a redirect without a Location header as a provider error', async () => {
  396. handler = (_req, res) => { res.writeHead(302); res.end() }
  397. await expect(provider().fetch({ url: base }))
  398. .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' }))
  399. })
  400. it('follows a relative same-origin redirect', async () => {
  401. handler = (req, res) => {
  402. if (req.url === '/a') { res.writeHead(301, { location: 'b' }); res.end() }
  403. else { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('landed') }
  404. }
  405. const result = await provider().fetch({ url: `${base}/a` })
  406. expect(result.body.content).toBe('landed')
  407. })
  408. })
  409. describe('HttpFetchProvider invalid URLs and abort', () => {
  410. it('blocks a loopback destination before opening a connection', async () => {
  411. restoreResolution()
  412. await expect(provider().fetch({ url: base }))
  413. .rejects.toThrow(expect.objectContaining({ code: 'WEB_BLOCKED_URL' }))
  414. })
  415. it('rejects a non-http scheme before any network access', async () => {
  416. await expect(provider().fetch({ url: 'ftp://example.com' }))
  417. .rejects.toThrow(expect.objectContaining({ code: 'WEB_INVALID_URL' }))
  418. })
  419. it('rejects credentials in the URL', async () => {
  420. await expect(provider().fetch({ url: 'http://user:pass@127.0.0.1/' }))
  421. .rejects.toThrow(expect.objectContaining({ code: 'WEB_BLOCKED_URL' }))
  422. })
  423. it('honors a pre-aborted signal', async () => {
  424. const controller = new AbortController()
  425. controller.abort()
  426. await expect(provider().fetch({ url: base }, controller.signal))
  427. .rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' }))
  428. })
  429. it('aborts an in-flight fetch via the signal', async () => {
  430. handler = (_req, _res) => { /* never responds */ }
  431. const controller = new AbortController()
  432. const promise = provider().fetch({ url: base }, controller.signal)
  433. controller.abort()
  434. await expect(promise).rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' }))
  435. })
  436. it('times out a slow response with WEB_FETCH_TIMEOUT', async () => {
  437. handler = (_req, _res) => { /* never responds */ }
  438. await expect(provider({ timeoutMs: 50 }).fetch({ url: base }))
  439. .rejects.toThrow(expect.objectContaining({ code: 'WEB_FETCH_TIMEOUT' }))
  440. })
  441. it('classifies a timeout DURING the body read as WEB_FETCH_TIMEOUT, not WEB_ABORTED', async () => {
  442. // Promise body that resolves headers (so fetch() returns) but a content-length
  443. // that outlasts the bytes sent, so readCapped()'s reader awaits more and the
  444. // timeout fires mid-read — the reader then surfaces a generic AbortError that
  445. // must still be recovered as the timeout reason via signal.reason.
  446. handler = (_req, res) => {
  447. res.writeHead(200, { 'content-type': 'text/plain', 'content-length': '100' })
  448. res.write('partial')
  449. // never send the remaining bytes nor end the response
  450. }
  451. await expect(provider({ timeoutMs: 80 }).fetch({ url: base }))
  452. .rejects.toThrow(expect.objectContaining({ code: 'WEB_FETCH_TIMEOUT' }))
  453. })
  454. it('maps a connection failure to WEB_PROVIDER_ERROR', async () => {
  455. // Port 1 on loopback is not listening: a real connection failure (not abort).
  456. await expect(provider().fetch({ url: 'http://127.0.0.1:1/' }))
  457. .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' }))
  458. })
  459. })
  460. describe('HttpFetchProvider body cancellation on error paths', () => {
  461. /** A fake Response whose body.cancel is observable. */
  462. type FakeInit = { status: number; headers: Record<string, string>; location?: string }
  463. function fakeResponse(init: FakeInit): { response: Response; cancelled: () => boolean } {
  464. let cancelled = false
  465. const headers = new Headers(init.headers)
  466. if (init.location !== undefined) headers.set('location', init.location)
  467. const response = {
  468. status: init.status,
  469. headers,
  470. body: { cancel: () => { cancelled = true; return Promise.resolve() } },
  471. } as unknown as Response
  472. return { response, cancelled: () => cancelled }
  473. }
  474. function stubRequest(response: Response): void {
  475. vi.spyOn(publicHttpNetwork, 'request').mockResolvedValue({
  476. response: response as never,
  477. close: async () => {},
  478. })
  479. }
  480. it('cancels the body when a cross-origin redirect is blocked', async () => {
  481. const { response, cancelled } = fakeResponse({ status: 302, headers: {}, location: 'https://elsewhere.test/' })
  482. stubRequest(response)
  483. await expect(provider().fetch({ url: 'http://127.0.0.1:9/' }))
  484. .rejects.toThrow(expect.objectContaining({ code: 'WEB_REDIRECT_BLOCKED' }))
  485. expect(cancelled()).toBe(true)
  486. })
  487. it('cancels the body when an unsupported charset is rejected', async () => {
  488. const { response, cancelled } = fakeResponse({ status: 200, headers: { 'content-type': 'text/plain; charset=not-a-charset' } })
  489. stubRequest(response)
  490. await expect(provider().fetch({ url: 'http://127.0.0.1:9/' }))
  491. .rejects.toThrow(expect.objectContaining({ code: 'WEB_UNSUPPORTED_CONTENT_TYPE' }))
  492. expect(cancelled()).toBe(true)
  493. })
  494. it('cancels the body when a redirect has no Location header', async () => {
  495. const { response, cancelled } = fakeResponse({ status: 302, headers: {} })
  496. stubRequest(response)
  497. await expect(provider().fetch({ url: 'http://127.0.0.1:9/' }))
  498. .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' }))
  499. expect(cancelled()).toBe(true)
  500. })
  501. })
  502. describe('web-fetch-http plugin registration', () => {
  503. it('registers the provider into ctx.web (HMR-safe)', async () => {
  504. const ctx = new Context()
  505. await ctx.plugin(WebRuntime, { fetchProvider: LOCAL_FETCH_PROVIDER_ID })
  506. const fiber = await ctx.plugin(fetchPlugin, {})
  507. await expect(ctx.web.fetch({ url: `${base}/` }))
  508. .resolves.toMatchObject({ statusCode: 200 })
  509. await fiber.dispose()
  510. await expect(ctx.web.fetch({ url: `${base}/` }))
  511. .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' }))
  512. })
  513. it('has no default export (namespace plugin export shape)', () => {
  514. expect('default' in fetchPlugin).toBe(false)
  515. })
  516. it('rejects a non-positive resource limit at construction', async () => {
  517. const ctx = new Context()
  518. await ctx.plugin(WebRuntime, { fetchProvider: LOCAL_FETCH_PROVIDER_ID })
  519. await expect(ctx.plugin(fetchPlugin, { maxResponseBytes: -1 }))
  520. .rejects.toThrow(/maxResponseBytes must be a positive finite number/)
  521. })
  522. it('rejects a zero timeout at construction', async () => {
  523. const ctx = new Context()
  524. await ctx.plugin(WebRuntime, { fetchProvider: LOCAL_FETCH_PROVIDER_ID })
  525. await expect(ctx.plugin(fetchPlugin, { timeoutMs: 0 }))
  526. .rejects.toThrow(/timeoutMs must be a positive finite number/)
  527. })
  528. it('rejects a timeout beyond Node timer range at construction', async () => {
  529. const ctx = new Context()
  530. await ctx.plugin(WebRuntime, { fetchProvider: LOCAL_FETCH_PROVIDER_ID })
  531. await expect(ctx.plugin(fetchPlugin, { timeoutMs: 2_147_483_648 }))
  532. .rejects.toThrow(/timeoutMs must be no greater than 2147483647/)
  533. })
  534. it('rejects a fractional redirect cap at construction', async () => {
  535. const ctx = new Context()
  536. await ctx.plugin(WebRuntime, { fetchProvider: LOCAL_FETCH_PROVIDER_ID })
  537. await expect(ctx.plugin(fetchPlugin, { maxRedirects: 1.5 }))
  538. .rejects.toThrow(/maxRedirects must be a non-negative integer/)
  539. })
  540. it('rejects a negative redirect cap at construction', async () => {
  541. const ctx = new Context()
  542. await ctx.plugin(WebRuntime, { fetchProvider: LOCAL_FETCH_PROVIDER_ID })
  543. await expect(ctx.plugin(fetchPlugin, { maxRedirects: -1 }))
  544. .rejects.toThrow(/maxRedirects must be a non-negative integer/)
  545. })
  546. it('accepts maxRedirects: 0 (follow no redirects) as valid config', async () => {
  547. const ctx = new Context()
  548. await ctx.plugin(WebRuntime, { fetchProvider: LOCAL_FETCH_PROVIDER_ID })
  549. const fiber = await ctx.plugin(fetchPlugin, { maxRedirects: 0 })
  550. await expect(ctx.web.fetch({ url: `${base}/` }))
  551. .resolves.toMatchObject({ statusCode: 200 })
  552. await fiber.dispose()
  553. })
  554. })