Skip to content

Commit 68f2423

Browse files
authored
Merge pull request #298 from sonatype-nexus-community/fix/forward-proxy-for-guide
fix: Support forward proxy when communicating with Sonatype Guide
2 parents 01a7791 + 0c75000 commit 68f2423

5 files changed

Lines changed: 113 additions & 64 deletions

File tree

package-lock.json

Lines changed: 14 additions & 59 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@
8585
"@xmldom/xmldom": "^0.9.0",
8686
"chalk": "^4.1.2",
8787
"figlet": "^1.2.4",
88-
"js-yaml": "^4.1.0",
88+
"js-yaml": "^4.2.0",
8989
"log4js": "^6.4.0",
9090
"node-persist": "^3.1.0",
9191
"ora": "^5.4.1",

src/Services/GuideRequestService.spec.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { OSSIndexCompatibilityApi } from '@sonatype/sonatype-guide-api-client';
2020
import type { InitOverrideFunction } from '@sonatype/sonatype-guide-api-client';
2121
import { Coordinates } from '../Types/Coordinates';
2222
import { rmSync, existsSync } from 'node:fs';
23+
import { ProxyAgent } from 'undici';
2324

2425
// node-persist is mocked globally so tests never touch the filesystem cache.
2526
vi.mock('node-persist', () => ({
@@ -36,12 +37,19 @@ const CACHE_PATH = '/tmp/.sonatype-guide-test';
3637
const SERVER = 'https://api.guide.sonatype.com';
3738

3839
describe('GuideRequestService', () => {
40+
const mockFetch = vi.fn();
41+
3942
beforeEach(() => {
4043
if (existsSync(CACHE_PATH)) rmSync(CACHE_PATH, { recursive: true, force: true });
44+
vi.stubGlobal('fetch', mockFetch);
4145
});
4246

4347
afterEach(() => {
4448
vi.restoreAllMocks();
49+
vi.unstubAllGlobals();
50+
delete process.env.http_proxy;
51+
delete process.env.https_proxy;
52+
mockFetch.mockClear();
4553
});
4654

4755
it('sends Authorization: Bearer when accessToken is set (PAT token mode)', async () => {
@@ -119,4 +127,49 @@ describe('GuideRequestService', () => {
119127
expect(result).toEqual([]);
120128
});
121129
});
130+
131+
describe('proxy support', () => {
132+
const mockSuccessResponse = {
133+
ok: true,
134+
status: 200,
135+
statusText: 'OK',
136+
json: vi.fn().mockResolvedValue([
137+
{
138+
coordinates: 'pkg:npm/test@1.0.0',
139+
reference: 'https://guide.sonatype.com/blah',
140+
vulnerabilities: [],
141+
},
142+
]),
143+
};
144+
145+
it('should pass ProxyAgent as dispatcher when http_proxy is set', async () => {
146+
process.env.http_proxy = 'http://proxy.example.com:8080';
147+
mockFetch.mockResolvedValueOnce(mockSuccessResponse);
148+
149+
const svc = new GuideRequestService('user', 'token', CACHE_PATH, SERVER);
150+
const coords = [new Coordinates('test', '1.0.0')];
151+
await svc.callGuideOrGetFromCache(coords, 'npm');
152+
153+
// Verify fetch was called with a dispatcher (ProxyAgent)
154+
expect(mockFetch).toHaveBeenCalled();
155+
const fetchOptions = mockFetch.mock.calls[0][1] as RequestInit & { dispatcher?: ProxyAgent };
156+
expect(fetchOptions.dispatcher).toBeDefined();
157+
expect(fetchOptions.dispatcher).toBeInstanceOf(ProxyAgent);
158+
});
159+
160+
it('should not include dispatcher when no proxy is configured', async () => {
161+
// Ensure no proxy env vars are set before creating service
162+
delete process.env.http_proxy;
163+
delete process.env.https_proxy;
164+
mockFetch.mockResolvedValueOnce(mockSuccessResponse);
165+
166+
const svc = new GuideRequestService('user', 'token', CACHE_PATH, SERVER);
167+
const coords = [new Coordinates('test', '1.0.0')];
168+
await svc.callGuideOrGetFromCache(coords, 'npm');
169+
170+
expect(mockFetch).toHaveBeenCalled();
171+
const fetchOptions = mockFetch.mock.calls[0][1] as RequestInit & { dispatcher?: unknown };
172+
expect(fetchOptions.dispatcher).toBeUndefined();
173+
});
174+
});
122175
});

src/Services/GuideRequestService.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,13 @@
1515
*/
1616

1717
import { OSSIndexCompatibilityApi, RecommendationsApi, Configuration } from '@sonatype/sonatype-guide-api-client';
18-
import type { RecommendationResponse, InitOverrideFunction } from '@sonatype/sonatype-guide-api-client';
18+
import type { RecommendationResponse, InitOverrideFunction, FetchAPI } from '@sonatype/sonatype-guide-api-client';
1919
import NodePersist from 'node-persist';
2020
import path from 'path';
2121
import { homedir } from 'os';
2222
import { Coordinates } from '../Types/Coordinates';
2323
import { OssIndexServerResultJSON } from '../Types/OssIndexServerResult';
24+
import { RequestHelpers } from './RequestHelpers';
2425

2526
const GUIDE_BASE_URL = 'https://api.guide.sonatype.com';
2627

@@ -41,20 +42,27 @@ export class GuideRequestService {
4142
readonly server: string = GUIDE_BASE_URL,
4243
readonly accessToken?: string,
4344
) {
45+
// Create a custom fetch wrapper that includes proxy support via dispatcher
46+
const proxyAgent = RequestHelpers.getHttpAgent();
47+
const fetchApi: FetchAPI | undefined = proxyAgent
48+
? (url: string | URL | Request, init?: RequestInit) =>
49+
fetch(url, { ...init, dispatcher: proxyAgent } as unknown as RequestInit)
50+
: undefined;
51+
4452
// OSSIndexCompatibilityApi only supports HTTP Basic auth.
4553
// In PAT-only mode (no username), send the PAT as password with empty username
4654
// so the generated client includes an Authorization: Basic :<PAT> header.
4755
const ossUsername = username ?? (accessToken ? '' : undefined);
4856
const ossPassword = token ?? accessToken;
4957
this.api = new OSSIndexCompatibilityApi(
50-
new Configuration({ username: ossUsername, password: ossPassword, basePath: server }),
58+
new Configuration({ username: ossUsername, password: ossPassword, basePath: server, fetchApi }),
5159
);
5260

5361
// RecommendationsApi supports Bearer auth; fall back to Basic when username is present.
5462
const recConfig =
5563
accessToken && !username
56-
? new Configuration({ accessToken, basePath: server })
57-
: new Configuration({ username, password: token, basePath: server });
64+
? new Configuration({ accessToken, basePath: server, fetchApi })
65+
: new Configuration({ username, password: token, basePath: server, fetchApi });
5866
this.recommendationsApi = new RecommendationsApi(recConfig);
5967
}
6068

src/Services/OssIndexRequestService.spec.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { expect, vi, describe, it, afterEach, beforeEach } from 'vitest';
1818
import { OssIndexRequestService } from './OssIndexRequestService';
1919
import { Coordinates } from '../Types/Coordinates';
2020
import { rmSync, existsSync } from 'fs';
21+
import { ProxyAgent } from 'undici';
2122

2223
// This will only work on Linux/OS X; find a better Windows-friendly path
2324
const CACHE_LOCATION = '/tmp/.ossindex';
@@ -34,6 +35,8 @@ describe('OssIndexRequestService', () => {
3435
afterEach(() => {
3536
vi.clearAllMocks();
3637
vi.unstubAllGlobals();
38+
delete process.env.http_proxy;
39+
delete process.env.https_proxy;
3740
});
3841

3942
it('should have its request rejected when the OSS Index server is down', async () => {
@@ -66,4 +69,34 @@ describe('OssIndexRequestService', () => {
6669
const result = await requestService.callOSSIndexOrGetFromCache(coords);
6770
expect(result).toEqual(expectedOutput);
6871
});
72+
73+
it('should pass ProxyAgent as dispatcher when http_proxy is set', async () => {
74+
if (existsSync(CACHE_LOCATION)) rmSync(CACHE_LOCATION, { recursive: true, force: true });
75+
process.env.http_proxy = 'http://proxy.example.com:8080';
76+
77+
const expectedOutput = [
78+
{
79+
coordinates: 'pkg:npm/test@1.0.0',
80+
reference: 'https://ossindex.sonatype.org/blah',
81+
vulnerabilities: [],
82+
},
83+
];
84+
85+
mockFetch.mockResolvedValueOnce({
86+
ok: true,
87+
statusText: 'OK',
88+
json: vi.fn().mockResolvedValue(expectedOutput),
89+
});
90+
91+
const requestService = new OssIndexRequestService(undefined, undefined, CACHE_LOCATION, OSS_INDEX_BASE_URL);
92+
const coords = [new Coordinates('test', '1.0.0')];
93+
await requestService.callOSSIndexOrGetFromCache(coords);
94+
95+
// Verify fetch was called with a dispatcher (ProxyAgent)
96+
expect(mockFetch).toHaveBeenCalled();
97+
const fetchCall = mockFetch.mock.calls[0];
98+
const fetchOptions = fetchCall[1] as RequestInit & { dispatcher?: ProxyAgent };
99+
expect(fetchOptions.dispatcher).toBeDefined();
100+
expect(fetchOptions.dispatcher).toBeInstanceOf(ProxyAgent);
101+
});
69102
});

0 commit comments

Comments
 (0)