From 7329c760b41ee458ba57f9504c12d750fd4961af Mon Sep 17 00:00:00 2001 From: "reportportal.io" Date: Mon, 20 Oct 2025 16:40:09 +0000 Subject: [PATCH 1/5] 5.4.3 -> 5.4.4-SNAPSHOT --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 6ffbe8b..a47a63b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.4.3 +5.4.4-SNAPSHOT From 8efaca7e3e79be8c719c4f7bba2e7c93f4e9cc5d Mon Sep 17 00:00:00 2001 From: Ilya Date: Mon, 20 Oct 2025 19:02:59 +0200 Subject: [PATCH 2/5] Update README.md --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f783143..c38fa82 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ rpClient.checkConnect().then(() => { When creating a client instance, you need to specify the following options. -### Authentication Options +### Authentication options The client supports two authentication methods: 1. **API Key Authentication** (default) @@ -67,7 +67,7 @@ Either API key or complete OAuth 2.0 configuration is required to connect to Rep | apiKey | Conditional | | User's ReportPortal API key from which you want to send requests. It can be found on the profile page of this user. *Required only if OAuth is not configured. | | oauth | Conditional | | OAuth 2.0 configuration object. When provided, OAuth authentication will be used instead of API key. See OAuth Configuration below. | -#### OAuth Configuration +#### OAuth configuration The `oauth` object supports the following properties: @@ -109,7 +109,7 @@ rpClient.checkConnect().then(() => { }); ``` -### General Options +### General options | Option | Necessity | Default | Description | |-----------------------|------------|----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| From c69a6c70fcfb9d7e77765b41ad6408b253410125 Mon Sep 17 00:00:00 2001 From: Ilya Date: Tue, 28 Oct 2025 13:46:39 +0100 Subject: [PATCH 3/5] EPMRPP-109127 || Manage proxy settings directly (#235) * EPMRPP-109127 || Manage proxy settings directly * EPMRPP-109127 || Fix tests. Update types and readme * EPMRPP-109127 || Update custom agents handling * EPMRPP-109127 || Sanitize credentials while logging * EPMRPP-109127 || Remove unused vars --- README.md | 165 ++++++++++++++++- __tests__/oauth.spec.js | 82 ++++++++- __tests__/proxyHelper.spec.js | 338 ++++++++++++++++++++++++++++++++++ index.d.ts | 88 ++++++++- lib/oauth.js | 20 +- lib/proxyHelper.js | 205 +++++++++++++++++++++ lib/rest.js | 11 +- package-lock.json | 47 ++++- package.json | 3 + 9 files changed, 946 insertions(+), 13 deletions(-) create mode 100644 __tests__/proxyHelper.spec.js create mode 100644 lib/proxyHelper.js diff --git a/README.md b/README.md index c38fa82..e58f891 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ rpClient.checkConnect().then(() => { | headers | Optional | {} | The object with custom headers for internal http client. | | debug | Optional | false | This flag allows seeing the logs of the client. Useful for debugging. | | isLaunchMergeRequired | Optional | false | Allows client to merge launches into one at the end of the run via saving their UUIDs to the temp files at filesystem. At the end of the run launches can be merged using `mergeLaunches` method. Temp file format: `rplaunch-${launch_uuid}.tmp`. | -| restClientConfig | Optional | Not set | `axios` like http client [config](https://github.com/axios/axios#request-config). May contain `agent` property for configure [http(s)](https://nodejs.org/api/https.html#https_https_request_url_options_callback) client, and other client options eg. `timeout`. For debugging and displaying logs you can set `debug: true`. Use the `retry` property (number or [`axios-retry`](https://github.com/softonic/axios-retry#options) config) to customise automatic retries. | +| restClientConfig | Optional | Not set | `axios` like http client [config](https://github.com/axios/axios#request-config). Supports `proxy` and `noProxy` for proxy configuration (see [Proxy configuration](#proxy-configuration)), `agent` property for [http(s)](https://nodejs.org/api/https.html#https_https_request_url_options_callback) client options, `timeout`, `debug: true` for debugging, and `retry` property (number or [`axios-retry`](https://github.com/softonic/axios-retry#options) config) for automatic retries. | | launchUuidPrint | Optional | false | Whether to print the current launch UUID. | | launchUuidPrintOutput | Optional | 'STDOUT' | Launch UUID printing output. Possible values: 'STDOUT', 'STDERR', 'FILE', 'ENVIRONMENT'. Works only if `launchUuidPrint` set to `true`. File format: `rp-launch-uuid-${launch_uuid}.tmp`. Env variable: `RP_LAUNCH_UUID`. | | token | Deprecated | Not set | Use `apiKey` or `oauth` instead. | @@ -166,6 +166,169 @@ const client = new RPClient({ Setting `retry: 0` disables automatic retries. +### Proxy configuration + +The client supports comprehensive proxy configuration for both HTTP and HTTPS requests, including ReportPortal API calls and OAuth token requests. Proxy settings can be configured via `restClientConfig` or environment variables. + +#### Basic proxy configuration + +##### Via configuration object + +```javascript +const RPClient = require('@reportportal/client-javascript'); + +const rpClient = new RPClient({ + apiKey: 'your_api_key', + endpoint: 'http://your-instance.com:8080/api/v1', + launch: 'LAUNCH_NAME', + project: 'PROJECT_NAME', + restClientConfig: { + proxy: { + protocol: 'https', // 'http' or 'https' + host: '127.0.0.1', + port: 8080, + // Optional authentication + auth: { + username: 'proxy-user', + password: 'proxy-password' + } + } + } +}); +``` + +##### Via proxy URL string + +```javascript +const rpClient = new RPClient({ + // ... other options + restClientConfig: { + proxy: 'https://127.0.0.1:8080' + } +}); +``` + +##### Via environment variables + +The client automatically detects and uses proxy environment variables: + +```bash +export HTTPS_PROXY=https://127.0.0.1:8080 +export HTTP_PROXY=http://127.0.0.1:8080 +export NO_PROXY=localhost,127.0.0.1,.local +``` + +#### Bypassing proxy for specific domains (noProxy) + +Use the `noProxy` option to exclude specific domains from being proxied. This is useful when some services are accessible directly while others require a proxy. + +```javascript +const rpClient = new RPClient({ + // ... other options + restClientConfig: { + proxy: { + protocol: 'https', + host: '127.0.0.1', + port: 8080 + }, + // Bypass proxy for these domains + noProxy: 'localhost,127.0.0.1,internal.company.com,.local.domain' + } +}); +``` + +**noProxy format:** +- Exact hostname: `example.com` - matches `example.com` and `sub.example.com` +- Leading dot: `.example.com` - matches only subdomains like `sub.example.com` (not `example.com` itself) +- Wildcard: `*` - bypass proxy for all requests +- Multiple entries: Comma-separated list + +**Priority:** Configuration `noProxy` takes precedence over `NO_PROXY` environment variable. + +#### Proxy with OAuth authentication + +When using OAuth authentication, the proxy configuration is automatically applied to both: +- OAuth token endpoint requests +- ReportPortal API requests + +```javascript +const rpClient = new RPClient({ + endpoint: 'http://your-instance.com:8080/api/v1', + project: 'PROJECT_NAME', + oauth: { + tokenEndpoint: 'https://login.microsoftonline.com/.../oauth2/v2.0/token', + username: 'your-username', + password: 'your-password', + clientId: 'your-client-id' + }, + restClientConfig: { + proxy: { + protocol: 'https', + host: '127.0.0.1', + port: 8080 + }, + // Example: Use proxy for OAuth, bypass for ReportPortal + noProxy: 'your-instance.com' + } +}); +``` + +#### Advanced proxy scenarios + +##### Disable proxy explicitly + +```javascript +restClientConfig: { + proxy: false // Disable proxy even if environment variables are set +} +``` + +##### Debug proxy configuration + +Enable debug mode to see detailed proxy decision logs: + +```javascript +restClientConfig: { + proxy: { /* ... */ }, + noProxy: 'localhost,.local', + debug: true // See proxy-related logs +} +``` + +Debug output example: +``` +[ProxyHelper] getProxyConfig called: + URL: https://login.microsoftonline.com/oauth2/v2.0/token + Hostname: login.microsoftonline.com + noProxy from config: localhost,.local + Should bypass proxy: false +[ProxyHelper] Creating proxy agent: + URL: https://login.microsoftonline.com/oauth2/v2.0/token + Protocol: https: + Proxy URL: https://127.0.0.1:8080 +``` + +#### Proxy configuration options + +| Option | Type | Description | +|---------------------|------------------------------|-------------------------------------------------------------------------------------------------| +| `proxy` | `false \| string \| object` | Proxy configuration. Can be `false` (disable), URL string, or configuration object (see below) | +| `proxy.protocol` | `string` | Proxy protocol: `'http'` or `'https'` | +| `proxy.host` | `string` | Proxy host address | +| `proxy.port` | `number` | Proxy port number | +| `proxy.auth` | `object` | Optional proxy authentication | +| `proxy.auth.username` | `string` | Proxy username | +| `proxy.auth.password` | `string` | Proxy password | +| `noProxy` | `string` | Comma-separated list of domains to bypass proxy | + +#### How proxy handling works + +1. **Per-request proxy decision:** Each request (API or OAuth) determines its proxy configuration based on the target URL +2. **noProxy checking:** URLs matching `noProxy` patterns bypass the proxy and connect directly +3. **Default agents for bypassed URLs:** When a URL bypasses proxy, a default HTTP/HTTPS agent is used to prevent automatic proxy detection +4. **Environment variable support:** `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` are automatically detected and used if no explicit configuration is provided +5. **Priority:** Explicit configuration takes precedence over environment variables + ### checkConnect `checkConnect` - asynchronous method for verifying the correctness of the client connection diff --git a/__tests__/oauth.spec.js b/__tests__/oauth.spec.js index 506e7fa..156b7ff 100644 --- a/__tests__/oauth.spec.js +++ b/__tests__/oauth.spec.js @@ -1,4 +1,5 @@ const axios = require('axios'); +const { HttpsProxyAgent } = require('https-proxy-agent'); const OAuthInterceptor = require('../lib/oauth'); jest.mock('axios', () => ({ @@ -47,9 +48,8 @@ describe('OAuthInterceptor', () => { expect(params.get('client_id')).toBe(baseConfig.clientId); expect(params.get('client_secret')).toBe(baseConfig.clientSecret); expect(params.get('scope')).toBe(baseConfig.scope); - expect(config).toEqual({ - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - }); + expect(config.headers).toEqual({ 'Content-Type': 'application/x-www-form-urlencoded' }); + expect(config.httpsAgent).toBeDefined(); // Default agent added expect(oauthInterceptor.refreshToken).toBe('refresh-123'); expect(oauthInterceptor.tokenExpiresAt).toBe(baseTime + 120000); @@ -152,7 +152,10 @@ describe('OAuthInterceptor', () => { it('logs debug messages only when debug mode is enabled', () => { const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); - const oauthInterceptor = new OAuthInterceptor({ ...baseConfig, debug: true }); + const oauthInterceptor = new OAuthInterceptor({ + ...baseConfig, + restClientConfig: { debug: true }, + }); oauthInterceptor.logDebug('message', { foo: 'bar' }); expect(consoleSpy).toHaveBeenCalledWith('[OAuth] message', { foo: 'bar' }); @@ -309,4 +312,75 @@ describe('OAuthInterceptor', () => { consoleErrorSpy.mockRestore(); consoleWarnSpy.mockRestore(); }); + + it('uses proxy configuration for token requests', async () => { + const baseTime = 1700000700000; + const nowSpy = jest.spyOn(Date, 'now').mockImplementation(() => baseTime); + const configWithProxy = { + ...baseConfig, + restClientConfig: { + proxy: { + protocol: 'https', + host: '127.0.0.1', + port: 9000, + }, + }, + }; + const oauthInterceptor = new OAuthInterceptor(configWithProxy); + axios.post.mockResolvedValue({ + data: { + access_token: 'token-with-proxy', + expires_in: 120, + }, + }); + + const token = await oauthInterceptor.getAccessToken(); + + expect(token).toBe('token-with-proxy'); + expect(axios.post).toHaveBeenCalledTimes(1); + const [url, , config] = axios.post.mock.calls[0]; + + expect(url).toBe(baseConfig.tokenEndpoint); + expect(config.headers).toEqual({ 'Content-Type': 'application/x-www-form-urlencoded' }); + expect(config.httpsAgent).toBeInstanceOf(HttpsProxyAgent); + + nowSpy.mockRestore(); + }); + + it('bypasses proxy for token endpoint when in noProxy list', async () => { + const baseTime = 1700000800000; + const nowSpy = jest.spyOn(Date, 'now').mockImplementation(() => baseTime); + const configWithNoProxy = { + ...baseConfig, + restClientConfig: { + proxy: { + protocol: 'https', + host: '127.0.0.1', + port: 9000, + }, + noProxy: 'auth.example.com', + }, + }; + const oauthInterceptor = new OAuthInterceptor(configWithNoProxy); + axios.post.mockResolvedValue({ + data: { + access_token: 'token-no-proxy', + expires_in: 120, + }, + }); + + const token = await oauthInterceptor.getAccessToken(); + + expect(token).toBe('token-no-proxy'); + expect(axios.post).toHaveBeenCalledTimes(1); + const [url, , config] = axios.post.mock.calls[0]; + + expect(url).toBe(baseConfig.tokenEndpoint); + expect(config.headers).toEqual({ 'Content-Type': 'application/x-www-form-urlencoded' }); + // Should use default agent, not proxy agent + expect(config.httpsAgent).toBeDefined(); + expect(config.httpsAgent.constructor.name).toBe('Agent'); + + nowSpy.mockRestore(); + }); }); diff --git a/__tests__/proxyHelper.spec.js b/__tests__/proxyHelper.spec.js new file mode 100644 index 0000000..cd0b858 --- /dev/null +++ b/__tests__/proxyHelper.spec.js @@ -0,0 +1,338 @@ +const { HttpsProxyAgent } = require('https-proxy-agent'); +const { HttpProxyAgent } = require('http-proxy-agent'); +const { + shouldBypassProxy, + getProxyConfig, + createProxyAgents, + getProxyAgentForUrl, +} = require('../lib/proxyHelper'); + +describe('proxyHelper', () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.resetModules(); + process.env = { ...originalEnv }; + delete process.env.HTTP_PROXY; + delete process.env.HTTPS_PROXY; + delete process.env.NO_PROXY; + delete process.env.http_proxy; + delete process.env.https_proxy; + delete process.env.no_proxy; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + describe('shouldBypassProxy', () => { + it('returns false when noProxy is not provided', () => { + expect(shouldBypassProxy('http://example.com', '')).toBe(false); + expect(shouldBypassProxy('http://example.com', null)).toBe(false); + expect(shouldBypassProxy('http://example.com', undefined)).toBe(false); + }); + + it('returns true for exact hostname match', () => { + expect(shouldBypassProxy('http://example.com', 'example.com')).toBe(true); + expect(shouldBypassProxy('https://example.com:8080', 'example.com')).toBe(true); + }); + + it('returns true for wildcard *', () => { + expect(shouldBypassProxy('http://example.com', '*')).toBe(true); + expect(shouldBypassProxy('https://any.domain.com', '*')).toBe(true); + }); + + it('returns true for subdomain match with leading dot', () => { + expect(shouldBypassProxy('http://sub.example.com', '.example.com')).toBe(true); + expect(shouldBypassProxy('http://deep.sub.example.com', '.example.com')).toBe(true); + expect(shouldBypassProxy('http://example.com', '.example.com')).toBe(false); + }); + + it('returns true for subdomain suffix match', () => { + expect(shouldBypassProxy('http://sub.example.com', 'example.com')).toBe(true); + expect(shouldBypassProxy('http://deep.sub.example.com', 'example.com')).toBe(true); + }); + + it('handles multiple entries separated by commas', () => { + const noProxy = 'localhost,example.com,.test.com'; + expect(shouldBypassProxy('http://localhost', noProxy)).toBe(true); + expect(shouldBypassProxy('http://example.com', noProxy)).toBe(true); + expect(shouldBypassProxy('http://sub.test.com', noProxy)).toBe(true); + expect(shouldBypassProxy('http://other.com', noProxy)).toBe(false); + }); + + it('is case insensitive', () => { + expect(shouldBypassProxy('http://EXAMPLE.COM', 'example.com')).toBe(true); + expect(shouldBypassProxy('http://example.com', 'EXAMPLE.COM')).toBe(true); + }); + + it('handles spaces in noProxy list', () => { + const noProxy = ' localhost , example.com , .test.com '; + expect(shouldBypassProxy('http://localhost', noProxy)).toBe(true); + expect(shouldBypassProxy('http://example.com', noProxy)).toBe(true); + }); + + it('returns false for invalid URLs', () => { + expect(shouldBypassProxy('not-a-url', 'example.com')).toBe(false); + }); + }); + + describe('getProxyConfig', () => { + it('returns null when no proxy is configured', () => { + expect(getProxyConfig('http://example.com', {})).toBeNull(); + }); + + it('returns null when proxy is explicitly disabled', () => { + const config = { proxy: false }; + expect(getProxyConfig('http://example.com', config)).toBeNull(); + }); + + it('returns null when URL matches noProxy from config', () => { + const config = { noProxy: 'example.com,localhost' }; + expect(getProxyConfig('http://example.com', config)).toBeNull(); + expect(getProxyConfig('http://localhost', config)).toBeNull(); + }); + + it('returns null when URL matches NO_PROXY from environment', () => { + process.env.NO_PROXY = 'example.com,localhost'; + expect(getProxyConfig('http://example.com', {})).toBeNull(); + }); + + it('prefers noProxy from config over environment', () => { + process.env.NO_PROXY = 'other.com'; + const config = { noProxy: 'example.com' }; + expect(getProxyConfig('http://example.com', config)).toBeNull(); + }); + + it('returns proxy URL from string config', () => { + const config = { proxy: 'http://proxy.example.com:8080' }; + const result = getProxyConfig('http://target.com', config); + expect(result).toEqual({ + proxyUrl: 'http://proxy.example.com:8080', + }); + }); + + it('returns proxy URL from object config', () => { + const config = { + proxy: { + protocol: 'https', + host: 'proxy.example.com', + port: 8080, + }, + }; + const result = getProxyConfig('http://target.com', config); + expect(result).toEqual({ + proxyUrl: 'https://proxy.example.com:8080', + }); + }); + + it('returns proxy URL with authentication', () => { + const config = { + proxy: { + protocol: 'http', + host: 'proxy.example.com', + port: 8080, + auth: { + username: 'user', + password: 'pass', + }, + }, + }; + const result = getProxyConfig('http://target.com', config); + expect(result).toEqual({ + proxyUrl: 'http://user:pass@proxy.example.com:8080', + }); + }); + + it('uses http as default protocol for object config', () => { + const config = { + proxy: { + host: 'proxy.example.com', + port: 8080, + }, + }; + const result = getProxyConfig('http://target.com', config); + expect(result).toEqual({ + proxyUrl: 'http://proxy.example.com:8080', + }); + }); + + it('returns proxy from HTTPS_PROXY environment variable for https URLs', () => { + process.env.HTTPS_PROXY = 'http://proxy.example.com:8080'; + const result = getProxyConfig('https://target.com', {}); + expect(result).toEqual({ + proxyUrl: 'http://proxy.example.com:8080', + }); + }); + + it('returns proxy from HTTP_PROXY environment variable for http URLs', () => { + process.env.HTTP_PROXY = 'http://proxy.example.com:8080'; + const result = getProxyConfig('http://target.com', {}); + expect(result).toEqual({ + proxyUrl: 'http://proxy.example.com:8080', + }); + }); + + it('respects NO_PROXY when using environment proxy', () => { + process.env.HTTP_PROXY = 'http://proxy.example.com:8080'; + process.env.NO_PROXY = 'target.com'; + const result = getProxyConfig('http://target.com', {}); + expect(result).toBeNull(); + }); + + it('prioritizes explicit config over environment variables', () => { + process.env.HTTPS_PROXY = 'http://env-proxy.com:8080'; + const config = { proxy: 'http://config-proxy.com:9090' }; + const result = getProxyConfig('https://target.com', config); + expect(result).toEqual({ + proxyUrl: 'http://config-proxy.com:9090', + }); + }); + }); + + describe('createProxyAgents', () => { + it('returns default agent when no proxy is configured', () => { + const agents = createProxyAgents('http://example.com', {}); + expect(agents.httpAgent).toBeDefined(); + expect(agents.httpAgent.constructor.name).toBe('Agent'); + }); + + it('creates HttpProxyAgent for HTTP URLs', () => { + const config = { proxy: 'http://proxy.example.com:8080' }; + const agents = createProxyAgents('http://target.com', config); + expect(agents.httpAgent).toBeInstanceOf(HttpProxyAgent); + expect(agents.httpsAgent).toBeUndefined(); + }); + + it('creates HttpsProxyAgent for HTTPS URLs', () => { + const config = { proxy: 'http://proxy.example.com:8080' }; + const agents = createProxyAgents('https://target.com', config); + expect(agents.httpsAgent).toBeInstanceOf(HttpsProxyAgent); + expect(agents.httpAgent).toBeUndefined(); + }); + + it('returns default agent when URL is in noProxy list', () => { + const config = { + proxy: 'http://proxy.example.com:8080', + noProxy: 'target.com', + }; + const agents = createProxyAgents('http://target.com', config); + expect(agents.httpAgent).toBeDefined(); + expect(agents.httpAgent.constructor.name).toBe('Agent'); + }); + + it('returns default HTTPS agent for HTTPS URLs in noProxy list', () => { + const config = { + proxy: 'http://proxy.example.com:8080', + noProxy: 'target.com', + }; + const agents = createProxyAgents('https://target.com', config); + expect(agents.httpsAgent).toBeDefined(); + expect(agents.httpsAgent.constructor.name).toBe('Agent'); + }); + }); + + describe('getProxyAgentForUrl', () => { + it('returns appropriate agent based on URL protocol', () => { + const config = { proxy: 'http://proxy.example.com:8080' }; + + const httpAgents = getProxyAgentForUrl('http://target.com', config); + expect(httpAgents.httpAgent).toBeInstanceOf(HttpProxyAgent); + + const httpsAgents = getProxyAgentForUrl('https://target.com', config); + expect(httpsAgents.httpsAgent).toBeInstanceOf(HttpsProxyAgent); + }); + + it('respects noProxy configuration', () => { + const config = { + proxy: 'http://proxy.example.com:8080', + noProxy: 'localhost,target.com', + }; + const agents = getProxyAgentForUrl('http://target.com', config); + expect(agents.httpAgent).toBeDefined(); + expect(agents.httpAgent.constructor.name).toBe('Agent'); + }); + + it('works with environment variables', () => { + process.env.HTTP_PROXY = 'http://proxy.example.com:8080'; + const agents = getProxyAgentForUrl('http://target.com', {}); + expect(agents.httpAgent).toBeInstanceOf(HttpProxyAgent); + }); + + it('respects NO_PROXY environment variable', () => { + process.env.HTTP_PROXY = 'http://proxy.example.com:8080'; + process.env.NO_PROXY = 'target.com'; + const agents = getProxyAgentForUrl('http://target.com', {}); + expect(agents.httpAgent).toBeDefined(); + expect(agents.httpAgent.constructor.name).toBe('Agent'); + }); + }); + + describe('credential sanitization in debug logs', () => { + let consoleLogSpy; + + beforeEach(() => { + consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); + }); + + afterEach(() => { + consoleLogSpy.mockRestore(); + }); + + it('sanitizes proxy credentials in debug logs when using proxy config object', () => { + const config = { + proxy: { + protocol: 'https', + host: 'proxy.example.com', + port: 8080, + auth: { + username: 'secretuser', + password: 'secretpass', + }, + }, + debug: true, + }; + + getProxyAgentForUrl('http://target.com', config); + + // Check that logs were called + expect(consoleLogSpy).toHaveBeenCalled(); + + // Check that credentials are not exposed in any log + const allLogs = consoleLogSpy.mock.calls.flat().join(' '); + expect(allLogs).not.toContain('secretuser'); + expect(allLogs).not.toContain('secretpass'); + // [REDACTED] gets URL-encoded to %5BREDACTED%5D + expect(allLogs).toMatch(/\[REDACTED\]|%5BREDACTED%5D/); + }); + + it('sanitizes proxy credentials in debug logs when using proxy URL string', () => { + const config = { + proxy: 'https://myuser:mypassword@proxy.example.com:8080', + debug: true, + }; + + getProxyAgentForUrl('http://target.com', config); + + // Check that credentials are not exposed in any log + const allLogs = consoleLogSpy.mock.calls.flat().join(' '); + expect(allLogs).not.toContain('myuser'); + expect(allLogs).not.toContain('mypassword'); + // [REDACTED] gets URL-encoded to %5BREDACTED%5D + expect(allLogs).toMatch(/\[REDACTED\]|%5BREDACTED%5D/); + }); + + it('logs proxy URL normally when no credentials are present', () => { + const config = { + proxy: 'https://proxy.example.com:8080', + debug: true, + }; + + getProxyAgentForUrl('http://target.com', config); + + const allLogs = consoleLogSpy.mock.calls.flat().join(' '); + expect(allLogs).toContain('https://proxy.example.com:8080'); + expect(allLogs).not.toContain('[REDACTED]'); + }); + }); +}); diff --git a/index.d.ts b/index.d.ts index 314059a..5a40901 100644 --- a/index.d.ts +++ b/index.d.ts @@ -29,6 +29,75 @@ declare module '@reportportal/client-javascript' { scope?: string; } + /** + * Proxy configuration object. + */ + export interface ProxyConfig { + /** + * Protocol for the proxy (http or https). + */ + protocol?: string; + /** + * Proxy host. + */ + host: string; + /** + * Proxy port. + */ + port: number; + /** + * Optional authentication for the proxy. + */ + auth?: { + username: string; + password: string; + }; + } + + /** + * REST client configuration options. + */ + export interface RestClientConfig { + /** + * Request timeout in milliseconds. + */ + timeout?: number; + /** + * Proxy configuration. Can be: + * - false: Disable proxy + * - string: Proxy URL (e.g., 'http://proxy.example.com:8080') + * - ProxyConfig object: Detailed proxy configuration + */ + proxy?: false | string | ProxyConfig; + /** + * Comma-separated list of domains to bypass proxy. + * Example: 'localhost,127.0.0.1,.example.com' + * This takes precedence over NO_PROXY environment variable. + */ + noProxy?: string; + /** + * Custom HTTP agent options. + */ + agent?: Record; + /** + * Retry configuration. + */ + retry?: number | { + retries?: number; + retryDelay?: (retryCount: number) => number; + retryCondition?: (error: any) => boolean; + shouldResetTimeout?: boolean; + }; + /** + * Enable debug logging. + */ + debug?: boolean; + /** + * Any other axios configuration options. + */ + [key: string]: unknown; + } + /** * Configuration options for initializing the Report Portal client. * @@ -56,6 +125,23 @@ declare module '@reportportal/client-javascript' { * } * }); * ``` + * + * @example With Proxy Configuration + * ```typescript + * const rp = new ReportPortalClient({ + * endpoint: 'https://your.reportportal.server/api/v1', + * project: 'your_project_name', + * apiKey: 'your_api_key', + * restClientConfig: { + * proxy: { + * protocol: 'https', + * host: '127.0.0.1', + * port: 8080, + * }, + * noProxy: 'localhost,.local.domain', + * } + * }); + * ``` */ export interface ReportPortalConfig { apiKey?: string; @@ -67,7 +153,7 @@ declare module '@reportportal/client-javascript' { isLaunchMergeRequired?: boolean; launchUuidPrint?: boolean; launchUuidPrintOutput?: string; - restClientConfig?: Record; + restClientConfig?: RestClientConfig; token?: string; /** * OAuth 2.0 configuration object. When provided, OAuth authentication will be used instead of API key. diff --git a/lib/oauth.js b/lib/oauth.js index 180d939..f8bc1db 100644 --- a/lib/oauth.js +++ b/lib/oauth.js @@ -1,4 +1,5 @@ const axios = require('axios'); +const { getProxyAgentForUrl } = require('./proxyHelper'); const TOKEN_REFRESH_THRESHOLD_MS = 60000; const DEFAULT_TOKEN_EXPIRATION_MS = 3600000; // 1 hour in milliseconds @@ -17,6 +18,7 @@ class OAuthInterceptor { * @param {string} [config.clientSecret] - OAuth client secret (optional) * @param {string} [config.scope] - OAuth scope (optional) * @param {boolean} [config.debug] - Enable debug logging + * @param {Object} [config.restClientConfig] - REST client configuration for proxy support */ constructor(config) { this.tokenEndpoint = config.tokenEndpoint; @@ -25,7 +27,8 @@ class OAuthInterceptor { this.clientId = config.clientId; this.clientSecret = config.clientSecret; this.scope = config.scope; - this.debug = config.debug || false; + this.restClientConfig = config.restClientConfig || {}; + this.debug = this.restClientConfig.debug || false; this.accessToken = null; this.refreshToken = null; @@ -145,10 +148,25 @@ class OAuthInterceptor { params.append('scope', this.scope); } + // Get proxy agent for token endpoint + // Only apply if custom agents are not explicitly provided + const hasCustomAgents = this.restClientConfig.httpsAgent || this.restClientConfig.httpAgent; + const proxyAgents = hasCustomAgents + ? {} + : getProxyAgentForUrl(this.tokenEndpoint, this.restClientConfig); + + if (this.debug && Object.keys(proxyAgents).length > 0) { + this.logDebug(`Making token request to ${this.tokenEndpoint} with proxy agent`); + } + const response = await axios.post(this.tokenEndpoint, params, { headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, + ...proxyAgents, + // Custom agents from restClientConfig (if provided) take precedence + ...(this.restClientConfig.httpsAgent && { httpsAgent: this.restClientConfig.httpsAgent }), + ...(this.restClientConfig.httpAgent && { httpAgent: this.restClientConfig.httpAgent }), }); const { diff --git a/lib/proxyHelper.js b/lib/proxyHelper.js new file mode 100644 index 0000000..e3ab904 --- /dev/null +++ b/lib/proxyHelper.js @@ -0,0 +1,205 @@ +const { getProxyForUrl } = require('proxy-from-env'); +const { HttpsProxyAgent } = require('https-proxy-agent'); +const { HttpProxyAgent } = require('http-proxy-agent'); +const http = require('http'); +const https = require('https'); + +/** + * Sanitizes a URL by removing credentials (username/password) for safe logging + * @param {string} urlString - The URL to sanitize + * @returns {string} - Sanitized URL with credentials replaced by [REDACTED] + */ +function sanitizeUrlForLogging(urlString) { + try { + const urlObj = new URL(urlString); + if (urlObj.username || urlObj.password) { + // Replace credentials with [REDACTED] + urlObj.username = '[REDACTED]'; + urlObj.password = ''; + return urlObj.toString(); + } + return urlString; + } catch (error) { + // If URL parsing fails, return as-is (likely not a URL) + return urlString; + } +} + +/** + * Checks if a URL should bypass proxy based on NO_PROXY patterns + * @param {string} url - The URL to check + * @param {string} noProxy - Comma-separated list of domains/patterns to bypass + * @returns {boolean} - True if proxy should be bypassed + */ +function shouldBypassProxy(url, noProxy) { + if (!noProxy) return false; + + try { + const urlObj = new URL(url); + const hostname = urlObj.hostname.toLowerCase(); + + // Split NO_PROXY entries and clean them + const patterns = noProxy + .split(',') + .map((entry) => entry.trim().toLowerCase()) + .filter(Boolean); + + return patterns.some((pattern) => { + // Special case: * means bypass all + if (pattern === '*') return true; + + // Pattern with leading dot (.example.com) - only matches subdomains + if (pattern.startsWith('.')) { + const cleanPattern = pattern.slice(1); + // Should match sub.example.com but NOT example.com + return hostname.endsWith(`.${cleanPattern}`); + } + + // Pattern without leading dot (example.com) - matches domain and subdomains + // Exact match + if (hostname === pattern) return true; + + // Suffix match (example.com matches sub.example.com) + if (hostname.endsWith(`.${pattern}`)) return true; + + return false; + }); + } catch (error) { + // If URL parsing fails, don't bypass proxy + return false; + } +} + +/** + * Gets proxy configuration for a given URL + * Checks both environment variables and explicit config + * @param {string} url - The target URL + * @param {object} proxyConfig - Explicit proxy configuration from restClientConfig + * @returns {object|null} - Proxy URL and bypass info, or null if no proxy + */ +function getProxyConfig(url, proxyConfig = {}) { + const urlObj = new URL(url); + + // Check NO_PROXY from config or environment + const noProxyFromConfig = proxyConfig.noProxy; + const noProxyFromEnv = process.env.NO_PROXY || process.env.no_proxy || ''; + const noProxy = noProxyFromConfig || noProxyFromEnv; + + if (proxyConfig.debug) { + console.log('[ProxyHelper] getProxyConfig called:'); + console.log(' URL:', url); + console.log(' Hostname:', urlObj.hostname); + console.log(' noProxy from config:', noProxyFromConfig); + console.log(' noProxy from env:', noProxyFromEnv); + console.log(' Final noProxy:', noProxy); + } + + // Check if URL should bypass proxy + const shouldBypass = shouldBypassProxy(url, noProxy); + if (proxyConfig.debug) { + console.log(' Should bypass proxy:', shouldBypass); + } + + if (shouldBypass) { + return null; + } + + // If proxy is explicitly disabled + if (proxyConfig.proxy === false) { + return null; + } + + // Priority 1: Explicit proxy configuration object + if (proxyConfig.proxy && typeof proxyConfig.proxy === 'object') { + const { protocol: proxyProtocol, host, port, auth } = proxyConfig.proxy; + if (host && port) { + let proxyUrl = `${proxyProtocol || 'http'}://${host}:${port}`; + if (auth) { + const { username, password } = auth; + proxyUrl = `${proxyProtocol || 'http'}://${username}:${password}@${host}:${port}`; + } + return { proxyUrl }; + } + } + + // Priority 2: Explicit proxy URL string + if (typeof proxyConfig.proxy === 'string') { + return { proxyUrl: proxyConfig.proxy }; + } + + // Priority 3: Environment variables (with NO_PROXY support via proxy-from-env) + const proxyUrlFromEnv = getProxyForUrl(url); + if (proxyUrlFromEnv) { + return { proxyUrl: proxyUrlFromEnv }; + } + + return null; +} + +/** + * Creates an HTTP/HTTPS agent with proxy configuration for a specific URL + * @param {string} url - The target URL for the request + * @param {object} restClientConfig - The rest client configuration + * @returns {object} - Object with httpAgent and/or httpsAgent + */ +function createProxyAgents(url, restClientConfig = {}) { + const urlObj = new URL(url); + const isHttps = urlObj.protocol === 'https:'; + const proxyConfig = getProxyConfig(url, restClientConfig); + + if (!proxyConfig) { + if (restClientConfig.debug) { + console.log('[ProxyHelper] No proxy for URL (bypassed or not configured):', url); + console.log(' Using default agent to prevent axios from using env proxy'); + } + // Return a default agent to prevent axios from using HTTP_PROXY/HTTPS_PROXY env vars + // This ensures that URLs in noProxy truly bypass the proxy + if (isHttps) { + return { + httpsAgent: new https.Agent(), + }; + } else { + return { + httpAgent: new http.Agent(), + }; + } + } + + const { proxyUrl } = proxyConfig; + + if (restClientConfig.debug) { + console.log('[ProxyHelper] Creating proxy agent:'); + console.log(' URL:', url); + console.log(' Protocol:', urlObj.protocol); + console.log(' Proxy URL:', sanitizeUrlForLogging(proxyUrl)); + } + + // Create appropriate agent based on target protocol + if (isHttps) { + return { + httpsAgent: new HttpsProxyAgent(proxyUrl), + }; + } else { + return { + httpAgent: new HttpProxyAgent(proxyUrl), + }; + } +} + +/** + * Gets proxy agent for a specific request URL + * This is the main function to be used in axios requests + * @param {string} url - The target URL for the request + * @param {object} restClientConfig - The rest client configuration + * @returns {object} - Object with agent configuration for axios + */ +function getProxyAgentForUrl(url, restClientConfig = {}) { + return createProxyAgents(url, restClientConfig); +} + +module.exports = { + shouldBypassProxy, + getProxyConfig, + createProxyAgents, + getProxyAgentForUrl, +}; diff --git a/lib/rest.js b/lib/rest.js index 5aa4941..8b19c69 100644 --- a/lib/rest.js +++ b/lib/rest.js @@ -4,6 +4,7 @@ const http = require('http'); const https = require('https'); const logger = require('./logger'); const OAuthInterceptor = require('./oauth'); +const { getProxyAgentForUrl } = require('./proxyHelper'); const DEFAULT_MAX_CONNECTION_TIME_MS = 30000; const DEFAULT_RETRY_ATTEMPTS = 6; @@ -16,7 +17,7 @@ const DEFAULT_RETRY_CONFIG = { retryCondition: axiosRetry.isRetryableError, shouldResetTimeout: true, }; -const SKIPPED_REST_CONFIG_KEYS = ['agent', 'retry']; +const SKIPPED_REST_CONFIG_KEYS = ['agent', 'retry', 'proxy', 'noProxy']; class RestClient { constructor(options) { @@ -39,6 +40,7 @@ class RestClient { const oauthInterceptor = new OAuthInterceptor({ ...this.oauthConfig, debug: this.debug, + restClientConfig: this.restClientConfig, }); oauthInterceptor.attach(this.axiosInstance); } catch (error) { @@ -62,12 +64,19 @@ class RestClient { } request(method, url, data, options = {}) { + // Only apply proxy agents if custom agents are not explicitly provided + // Priority: explicit httpsAgent/httpAgent > proxy config > default + const hasCustomAgents = + 'httpsAgent' in this.restClientConfig || 'httpAgent' in this.restClientConfig; + const proxyAgents = hasCustomAgents ? {} : getProxyAgentForUrl(url, this.restClientConfig); + return this.axiosInstance .request({ method, url, data, ...options, + ...proxyAgents, headers: { HOST: new URL(url).host, ...options.headers, diff --git a/package-lock.json b/package-lock.json index a24024f..50214f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,13 +6,16 @@ "packages": { "": { "name": "@reportportal/client-javascript", - "version": "5.4.1", + "version": "5.4.3", "license": "Apache-2.0", "dependencies": { "axios": "^1.12.2", "axios-retry": "^4.5.0", "glob": "^8.1.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", "ini": "^2.0.0", + "proxy-from-env": "^1.1.0", "uniqid": "^5.4.0", "uuid": "^9.0.1" }, @@ -1609,6 +1612,15 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -2307,7 +2319,6 @@ "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, "dependencies": { "ms": "2.1.2" }, @@ -3661,6 +3672,32 @@ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -5038,8 +5075,7 @@ "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/natural-compare": { "version": "1.4.0", @@ -5479,7 +5515,8 @@ "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" }, "node_modules/punycode": { "version": "2.3.0", diff --git a/package.json b/package.json index b2c9ddc..0c366a6 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,10 @@ "axios": "^1.12.2", "axios-retry": "^4.5.0", "glob": "^8.1.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", "ini": "^2.0.0", + "proxy-from-env": "^1.1.0", "uniqid": "^5.4.0", "uuid": "^9.0.1" }, From f15b7fd8744c1e0c921386bfc1eb65801c8a0310 Mon Sep 17 00:00:00 2001 From: Ilya_Hancharyk Date: Tue, 28 Oct 2025 13:48:58 +0100 Subject: [PATCH 4/5] EPMRPP-109127 || Update changelog --- CHANGELOG.md | 2 ++ version_fragment | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e7b78d..506842a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,5 @@ +### Added +- Full http/https proxy support with `noProxy` configuration, check [Authentication Options](https://github.com/reportportal/client-javascript?tab=readme-ov-file#proxy-configuration-options) for more details. ## [5.4.3] - 2025-10-20 ### Added diff --git a/version_fragment b/version_fragment index 9eb7b90..acb503f 100644 --- a/version_fragment +++ b/version_fragment @@ -1 +1 @@ -patch +minor From b8440ad6f3146bce215e2bc80ae3dc6ce132fb8b Mon Sep 17 00:00:00 2001 From: Ilya_Hancharyk Date: Tue, 28 Oct 2025 13:49:48 +0100 Subject: [PATCH 5/5] EPMRPP-109127 || Fix typo --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 506842a..22bbe94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,9 @@ ### Added -- Full http/https proxy support with `noProxy` configuration, check [Authentication Options](https://github.com/reportportal/client-javascript?tab=readme-ov-file#proxy-configuration-options) for more details. +- Full http/https proxy support with `noProxy` configuration, check [Proxy configuration options](https://github.com/reportportal/client-javascript?tab=readme-ov-file#proxy-configuration-options) for more details. ## [5.4.3] - 2025-10-20 ### Added -- OAuth 2.0 Password Grant authentication, check [Authentication Options](https://github.com/reportportal/client-javascript?tab=readme-ov-file#authentication-options) for more details. +- OAuth 2.0 Password Grant authentication, check [Authentication options](https://github.com/reportportal/client-javascript?tab=readme-ov-file#authentication-options) for more details. ## [5.4.2] - 2025-10-02 ### Added