matcher-parity.spec.ts 3.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475
  1. import { createServer, type Server } from 'node:http'
  2. import type { AddressInfo } from 'node:net'
  3. import { afterAll, beforeAll, describe, expect, it } from 'vitest'
  4. import { installProxyFromEnvironment } from '../src/index.ts'
  5. import { proxyForUrl, resolveProxyPolicy } from '../src/policy.ts'
  6. /**
  7. * `proxyForUrl` answers where a URL goes; these cases check that answer against where a real `fetch`
  8. * actually went, for every form in the documented bypass vocabulary. The installed dispatcher routes
  9. * by this same predicate, so the two cannot drift apart by parsing the list twice — what a case can
  10. * still catch is `bypassesProxy` reading a form differently from how the vocabulary documents it,
  11. * and any future dispatcher that reintroduces a second matcher.
  12. *
  13. * The remaining second matcher is Node's, in a spawned child: it reads the published `NO_PROXY` and
  14. * applies its own rules, which differ in separators and IPv4-range support. That seam is documented
  15. * rather than asserted here, because the difference is real.
  16. */
  17. const CASES: readonly { readonly noProxy: string; readonly path: string; readonly bypassed: boolean }[] = [
  18. { noProxy: '', path: '/plain', bypassed: false },
  19. { noProxy: 'probe.invalid', path: '/exact', bypassed: true },
  20. { noProxy: '.probe.invalid', path: '/dot-suffix', bypassed: true },
  21. { noProxy: '*.probe.invalid', path: '/star-suffix', bypassed: true },
  22. { noProxy: 'other.invalid', path: '/miss', bypassed: false },
  23. { noProxy: '*', path: '/all', bypassed: true },
  24. { noProxy: 'probe.invalid:80', path: '/with-default-port', bypassed: true },
  25. { noProxy: 'probe.invalid:8443', path: '/wrong-port', bypassed: false },
  26. { noProxy: 'a.invalid, probe.invalid', path: '/comma-list', bypassed: true },
  27. ]
  28. let seen: string[] = []
  29. let proxy: Server
  30. let proxyUrl: string
  31. beforeAll(async () => {
  32. proxy = createServer((request, response) => {
  33. seen.push(request.url ?? '')
  34. response.writeHead(200, { 'content-type': 'text/plain' })
  35. response.end('VIA-PROXY')
  36. })
  37. const address = await new Promise<AddressInfo>((resolve) => {
  38. proxy.listen(0, '127.0.0.1', () => { resolve(proxy.address() as AddressInfo) })
  39. })
  40. proxyUrl = `http://127.0.0.1:${String(address.port)}`
  41. })
  42. afterAll(async () => {
  43. await new Promise<void>((resolve) => { proxy.close(() => { resolve() }) })
  44. })
  45. /** The launch environment of a user who exported one proxy for both schemes plus one bypass list. */
  46. function proxyEnv(noProxy: string): { get(name: string): { value: string } | undefined } {
  47. const values: Record<string, string> = { HTTP_PROXY: proxyUrl, HTTPS_PROXY: proxyUrl, NO_PROXY: noProxy }
  48. return { get: name => (name in values ? { value: values[name] as string } : undefined) }
  49. }
  50. describe('bypass matcher parity', () => {
  51. it.each(CASES)('agrees on $noProxy for $path', async ({ noProxy, path, bypassed }) => {
  52. seen = []
  53. const url = new URL(`http://probe.invalid${path}`)
  54. const env = proxyEnv(noProxy)
  55. const { policy } = resolveProxyPolicy(env)
  56. const dispose = await installProxyFromEnvironment(env, () => undefined)
  57. try {
  58. // A bypassed target has no route here, so the fetch fails; a proxied one reaches the recorder
  59. // in milliseconds. The deadline bounds the failing path, whose DNS miss is otherwise as slow
  60. // as the machine's resolver decides — and only that path, so it cannot mask a proxied hop.
  61. await fetch(url, { signal: AbortSignal.timeout(1500) }).then(response => response.text()).catch(() => undefined)
  62. const agentProxied = seen.length > 0
  63. expect({ ours: proxyForUrl(policy, url) !== undefined, agent: agentProxied })
  64. .toEqual({ ours: !bypassed, agent: !bypassed })
  65. } finally {
  66. await dispose()
  67. }
  68. })
  69. })