From 4b3e5919592d3bafdd1c77bef7b57a43af155dad Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Sat, 29 Apr 2023 04:10:55 -0700 Subject: [PATCH] Add NordVPN E2E tests (#144) Runs a simple end to end test against NordVPN servers, for `https-proxy-agent` and `socks-proxy-agent`. --- .github/workflows/test.yml | 22 ++++- package.json | 1 + packages/agent-base/src/helpers.ts | 5 +- packages/https-proxy-agent/package.json | 5 +- packages/https-proxy-agent/test/e2e.test.ts | 84 +++++++++++++++++++ packages/https-proxy-agent/test/tsconfig.json | 2 +- packages/socks-proxy-agent/jest.config.js | 5 ++ packages/socks-proxy-agent/package.json | 9 +- packages/socks-proxy-agent/test/e2e.test.ts | 82 ++++++++++++++++++ packages/socks-proxy-agent/test/tsconfig.json | 4 + pnpm-lock.yaml | 45 ++++++++++ turbo.json | 11 ++- 12 files changed, 268 insertions(+), 7 deletions(-) create mode 100644 packages/https-proxy-agent/test/e2e.test.ts create mode 100644 packages/socks-proxy-agent/jest.config.js create mode 100644 packages/socks-proxy-agent/test/e2e.test.ts create mode 100644 packages/socks-proxy-agent/test/tsconfig.json diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e23696f8..c9d7c9f1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,7 +26,6 @@ jobs: node-version: ${{ matrix.node-version }} cache: 'pnpm' - run: pnpm install --frozen-lockfile - - run: pnpm build - run: pnpm test lint: @@ -46,3 +45,24 @@ jobs: cache: 'pnpm' - run: pnpm install --frozen-lockfile - run: pnpm lint + + e2e: + name: E2E + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - uses: pnpm/action-setup@v2 + with: + version: 7.32.2 + - name: Use Node.js 20.x + uses: actions/setup-node@v3 + with: + node-version: 20.x + cache: 'pnpm' + - run: pnpm install --frozen-lockfile + - run: pnpm test-e2e + env: + NORDVPN_USERNAME: ${{ secrets.NORDVPN_USERNAME }} + NORDVPN_PASSWORD: ${{ secrets.NORDVPN_PASSWORD }} diff --git a/package.json b/package.json index 757dfca5..647e6c44 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "build": "turbo run build", "lint": "turbo run lint", "test": "turbo run test", + "test-e2e": "turbo run test-e2e", "format": "prettier --write \"**/*.{ts,tsx,md}\"" }, "devDependencies": { diff --git a/packages/agent-base/src/helpers.ts b/packages/agent-base/src/helpers.ts index 897371d4..5088b665 100644 --- a/packages/agent-base/src/helpers.ts +++ b/packages/agent-base/src/helpers.ts @@ -12,14 +12,15 @@ export async function toBuffer(stream: Readable): Promise { return Buffer.concat(chunks, length); } -export async function json(stream: Readable): Promise> { +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export async function json(stream: Readable): Promise { const buf = await toBuffer(stream); return JSON.parse(buf.toString('utf8')); } export function req( url: string | URL, - opts: https.RequestOptions + opts: https.RequestOptions = {} ): Promise { return new Promise((resolve, reject) => { const href = typeof url === 'string' ? url : url.href; diff --git a/packages/https-proxy-agent/package.json b/packages/https-proxy-agent/package.json index 87364597..0f6730d4 100644 --- a/packages/https-proxy-agent/package.json +++ b/packages/https-proxy-agent/package.json @@ -9,7 +9,8 @@ ], "scripts": { "build": "tsc", - "test": "jest --env node --verbose --bail", + "test": "jest --env node --verbose --bail test/test.ts", + "test-e2e": "jest --env node --verbose --bail test/e2e.test.ts", "lint": "eslint src --ext .js,.ts", "prepublishOnly": "npm run build" }, @@ -33,10 +34,12 @@ "debug": "4" }, "devDependencies": { + "@types/async-retry": "^1.4.5", "@types/debug": "4", "@types/jest": "^29.5.1", "@types/node": "^14.18.43", "async-listen": "^2.1.0", + "async-retry": "^1.3.3", "jest": "^29.5.0", "proxy": "workspace:*", "ts-jest": "^29.1.0", diff --git a/packages/https-proxy-agent/test/e2e.test.ts b/packages/https-proxy-agent/test/e2e.test.ts new file mode 100644 index 00000000..29d3fcc3 --- /dev/null +++ b/packages/https-proxy-agent/test/e2e.test.ts @@ -0,0 +1,84 @@ +import retry from 'async-retry'; +import { req, json } from 'agent-base'; +import { HttpsProxyAgent } from '../src'; + +interface NordVPNServer { + name: string; + domain: string; + flag: string; + features: { [key: string]: boolean }; +} + +jest.setTimeout(30000); + +const findNordVpnServer = () => + retry( + async (): Promise => { + const res = await req('https://nordvpn.com/api/server'); + if (res.statusCode !== 200) { + res.socket.destroy(); + throw new Error(`Status code: ${res.statusCode}`); + } + const body = await json(res); + const servers = (body as NordVPNServer[]).filter( + (s) => s.features.proxy_ssl + ); + if (servers.length === 0) { + throw new Error( + 'Could not find `https` proxy server from NordVPN' + ); + } + const server = servers[Math.floor(Math.random() * servers.length)]; + return server; + }, + { + retries: 5, + onRetry(err, attempt) { + console.log( + `Failed to get NordVPN servers. Retrying… (attempt #${attempt}, ${err.message})` + ); + }, + } + ); + +async function getRealIP(): Promise { + const res = await req('https://dump.n8.io'); + const body = await json(res); + return body.request.headers['x-real-ip']; +} + +describe('HttpsProxyAgent', () => { + it('should work over NordVPN proxy', async () => { + const { NORDVPN_USERNAME, NORDVPN_PASSWORD } = process.env; + if (!NORDVPN_USERNAME) { + throw new Error('`NORDVPN_USERNAME` env var is not defined'); + } + if (!NORDVPN_PASSWORD) { + throw new Error('`NORDVPN_PASSWORD` env var is not defined'); + } + + const [realIp, server] = await Promise.all([ + getRealIP(), + findNordVpnServer(), + ]); + console.log( + `Using NordVPN HTTPS proxy server: ${server.name} (${server.domain})` + ); + + const username = encodeURIComponent(NORDVPN_USERNAME); + const password = encodeURIComponent(NORDVPN_PASSWORD); + + // NordVPN runs their HTTPS proxy servers on port 89 + // https://www.reddit.com/r/nordvpn/comments/hvz48h/nordvpn_https_proxy/ + const agent = new HttpsProxyAgent( + `https://${username}:${password}@${server.domain}:89` + ); + + const res = await req('https://dump.n8.io', { agent }); + const body = await json(res); + expect(body.request.headers['x-real-ip']).not.toEqual(realIp); + expect(body.request.headers['x-vercel-ip-country']).toEqual( + server.flag + ); + }); +}); diff --git a/packages/https-proxy-agent/test/tsconfig.json b/packages/https-proxy-agent/test/tsconfig.json index a79e2e63..e6f83d5e 100644 --- a/packages/https-proxy-agent/test/tsconfig.json +++ b/packages/https-proxy-agent/test/tsconfig.json @@ -1,4 +1,4 @@ { "extends": "../tsconfig.json", - "include": ["test.ts"] + "include": ["*.ts"] } diff --git a/packages/socks-proxy-agent/jest.config.js b/packages/socks-proxy-agent/jest.config.js new file mode 100644 index 00000000..ee66e76e --- /dev/null +++ b/packages/socks-proxy-agent/jest.config.js @@ -0,0 +1,5 @@ +/** @type {import('@ts-jest/dist/types').InitialOptionsTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', +}; diff --git a/packages/socks-proxy-agent/package.json b/packages/socks-proxy-agent/package.json index 098b1207..a22e087d 100644 --- a/packages/socks-proxy-agent/package.json +++ b/packages/socks-proxy-agent/package.json @@ -114,12 +114,18 @@ "socks": "^2.7.1" }, "devDependencies": { + "@types/async-retry": "^1.4.5", "@types/debug": "^4.1.7", + "@types/jest": "^29.5.1", "@types/node": "^14.18.43", + "async-retry": "^1.3.3", "cacheable-lookup": "^6.1.0", "dns2": "^2.1.0", + "jest": "^29.5.0", "mocha": "^9.2.2", + "proxy": "workspace:*", "socksv5": "github:TooTallNate/socksv5#fix/dstSock-close-event", + "ts-jest": "^29.1.0", "tsconfig": "workspace:*", "typescript": "^5.0.4" }, @@ -128,7 +134,8 @@ }, "scripts": { "build": "tsc", - "test": "mocha --reporter spec", + "test": "mocha --reporter spec test/test.js", + "test-e2e": "jest --env node --verbose --bail test/e2e.test.ts", "lint": "eslint . --ext .ts", "prepublishOnly": "npm run build" }, diff --git a/packages/socks-proxy-agent/test/e2e.test.ts b/packages/socks-proxy-agent/test/e2e.test.ts new file mode 100644 index 00000000..dd4a695e --- /dev/null +++ b/packages/socks-proxy-agent/test/e2e.test.ts @@ -0,0 +1,82 @@ +import retry from 'async-retry'; +import { req, json } from 'agent-base'; +import { SocksProxyAgent } from '../src'; + +interface NordVPNServer { + name: string; + domain: string; + flag: string; + features: { [key: string]: boolean }; +} + +jest.setTimeout(30000); + +const findNordVpnServer = () => + retry( + async (): Promise => { + const res = await req('https://nordvpn.com/api/server'); + if (res.statusCode !== 200) { + res.socket.destroy(); + throw new Error(`Status code: ${res.statusCode}`); + } + const body = await json(res); + const servers = (body as NordVPNServer[]).filter( + (s) => s.features.socks + ); + if (servers.length === 0) { + throw new Error( + 'Could not find `socks` proxy server from NordVPN' + ); + } + const server = servers[Math.floor(Math.random() * servers.length)]; + return server; + }, + { + retries: 5, + onRetry(err, attempt) { + console.log( + `Failed to get NordVPN servers. Retrying… (attempt #${attempt}, ${err.message})` + ); + }, + } + ); + +async function getRealIP(): Promise { + const res = await req('https://dump.n8.io'); + const body = await json(res); + return body.request.headers['x-real-ip']; +} + +describe('SocksProxyAgent', () => { + it('should work over NordVPN proxy', async () => { + const { NORDVPN_USERNAME, NORDVPN_PASSWORD } = process.env; + if (!NORDVPN_USERNAME) { + throw new Error('`NORDVPN_USERNAME` env var is not defined'); + } + if (!NORDVPN_PASSWORD) { + throw new Error('`NORDVPN_PASSWORD` env var is not defined'); + } + + const [realIp, server] = await Promise.all([ + getRealIP(), + findNordVpnServer(), + ]); + console.log( + `Using NordVPN SOCKS proxy server: ${server.name} (${server.domain})` + ); + + const username = encodeURIComponent(NORDVPN_USERNAME); + const password = encodeURIComponent(NORDVPN_PASSWORD); + + const agent = new SocksProxyAgent( + `socks://${username}:${password}@${server.domain}` + ); + + const res = await req('https://dump.n8.io', { agent }); + const body = await json(res); + expect(body.request.headers['x-real-ip']).not.toEqual(realIp); + expect(body.request.headers['x-vercel-ip-country']).toEqual( + server.flag + ); + }); +}); diff --git a/packages/socks-proxy-agent/test/tsconfig.json b/packages/socks-proxy-agent/test/tsconfig.json new file mode 100644 index 00000000..e6f83d5e --- /dev/null +++ b/packages/socks-proxy-agent/test/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.json", + "include": ["*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 27120aba..dc6758e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -222,6 +222,9 @@ importers: specifier: '4' version: 4.3.4 devDependencies: + '@types/async-retry': + specifier: ^1.4.5 + version: 1.4.5 '@types/debug': specifier: '4' version: 4.1.0 @@ -234,6 +237,9 @@ importers: async-listen: specifier: ^2.1.0 version: 2.1.0 + async-retry: + specifier: ^1.3.3 + version: 1.3.3 jest: specifier: ^29.5.0 version: 29.5.0(@types/node@14.18.43) @@ -443,24 +449,42 @@ importers: specifier: ^2.7.1 version: 2.7.1 devDependencies: + '@types/async-retry': + specifier: ^1.4.5 + version: 1.4.5 '@types/debug': specifier: ^4.1.7 version: 4.1.7 + '@types/jest': + specifier: ^29.5.1 + version: 29.5.1 '@types/node': specifier: ^14.18.43 version: 14.18.43 + async-retry: + specifier: ^1.3.3 + version: 1.3.3 cacheable-lookup: specifier: ^6.1.0 version: 6.1.0 dns2: specifier: ^2.1.0 version: 2.1.0 + jest: + specifier: ^29.5.0 + version: 29.5.0(@types/node@14.18.43) mocha: specifier: ^9.2.2 version: 9.2.2 + proxy: + specifier: workspace:* + version: link:../proxy socksv5: specifier: github:TooTallNate/socksv5#fix/dstSock-close-event version: github.com/TooTallNate/socksv5/d937368b28e929396166d77a06d387a4a902bd51 + ts-jest: + specifier: ^29.1.0 + version: 29.1.0(@babel/core@7.21.4)(jest@29.5.0)(typescript@5.0.4) tsconfig: specifier: workspace:* version: link:../tsconfig @@ -1189,6 +1213,12 @@ packages: resolution: {integrity: sha512-3fNb8ja/wQWFrHf5SQC5S3n0iBXdnT3PTPEJni2tBQRuv0BnAsz5u12U5gPRBSR7xdY6fI6QjWoTK/8ysuTt0w==} dev: true + /@types/async-retry@1.4.5: + resolution: {integrity: sha512-YrdjSD+yQv7h6d5Ip+PMxh3H6ZxKyQk0Ts+PvaNRInxneG9PFVZjFg77ILAN+N6qYf7g4giSJ1l+ZjQ1zeegvA==} + dependencies: + '@types/retry': 0.12.2 + dev: true + /@types/babel__core@7.20.0: resolution: {integrity: sha512-+n8dL/9GWblDO0iU6eZAwEIJVr5DWigtle+Q6HLOrh/pdbXOhOtqzq8VPPE2zvNJzSKY4vH/z3iT3tn0A3ypiQ==} dependencies: @@ -1334,6 +1364,10 @@ packages: '@types/node': 14.18.43 dev: true + /@types/retry@0.12.2: + resolution: {integrity: sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==} + dev: true + /@types/semver@7.3.13: resolution: {integrity: sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw==} dev: true @@ -1669,6 +1703,12 @@ packages: engines: {node: '>= 14'} dev: true + /async-retry@1.3.3: + resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==} + dependencies: + retry: 0.13.1 + dev: true + /async@0.2.10: resolution: {integrity: sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ==} dev: true @@ -4308,6 +4348,11 @@ packages: supports-preserve-symlinks-flag: 1.0.0 dev: true + /retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + dev: true + /reusify@1.0.4: resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} diff --git a/turbo.json b/turbo.json index eeb20769..3dc934e6 100644 --- a/turbo.json +++ b/turbo.json @@ -1,6 +1,12 @@ { "$schema": "https://turbo.build/schema.json", - "globalEnv": ["HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY"], + "globalEnv": [ + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + "NORDVPN_USERNAME", + "NORDVPN_PASSWORD" + ], "pipeline": { "build": { "dependsOn": ["^build"], @@ -9,6 +15,9 @@ "test": { "dependsOn": ["build"] }, + "test-e2e": { + "dependsOn": ["build"] + }, "lint": {} } }