diff --git a/packages/cli/src/config/extensions/github_fetch.test.ts b/packages/cli/src/config/extensions/github_fetch.test.ts new file mode 100644 index 0000000000..fe6edbedb2 --- /dev/null +++ b/packages/cli/src/config/extensions/github_fetch.test.ts @@ -0,0 +1,199 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import * as https from 'node:https'; +import { EventEmitter } from 'node:events'; +import { fetchJson, getGitHubToken } from './github_fetch.js'; +import type { ClientRequest, IncomingMessage } from 'node:http'; + +vi.mock('node:https'); + +describe('getGitHubToken', () => { + const originalToken = process.env['GITHUB_TOKEN']; + + afterEach(() => { + if (originalToken) { + process.env['GITHUB_TOKEN'] = originalToken; + } else { + delete process.env['GITHUB_TOKEN']; + } + }); + + it('should return the token if GITHUB_TOKEN is set', () => { + process.env['GITHUB_TOKEN'] = 'test-token'; + expect(getGitHubToken()).toBe('test-token'); + }); + + it('should return undefined if GITHUB_TOKEN is not set', () => { + delete process.env['GITHUB_TOKEN']; + expect(getGitHubToken()).toBeUndefined(); + }); +}); + +describe('fetchJson', () => { + const getMock = vi.mocked(https.get); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('should fetch and parse JSON successfully', async () => { + getMock.mockImplementationOnce((_url, _options, callback) => { + const res = new EventEmitter() as IncomingMessage; + res.statusCode = 200; + (callback as (res: IncomingMessage) => void)(res); + res.emit('data', Buffer.from('{"foo":')); + res.emit('data', Buffer.from('"bar"}')); + res.emit('end'); + return new EventEmitter() as ClientRequest; + }); + await expect(fetchJson('https://example.com/data.json')).resolves.toEqual({ + foo: 'bar', + }); + }); + + it('should handle redirects (301 and 302)', async () => { + // Test 302 + getMock.mockImplementationOnce((_url, _options, callback) => { + const res = new EventEmitter() as IncomingMessage; + res.statusCode = 302; + res.headers = { location: 'https://example.com/final' }; + (callback as (res: IncomingMessage) => void)(res); + res.emit('end'); + return new EventEmitter() as ClientRequest; + }); + getMock.mockImplementationOnce((url, _options, callback) => { + expect(url).toBe('https://example.com/final'); + const res = new EventEmitter() as IncomingMessage; + res.statusCode = 200; + (callback as (res: IncomingMessage) => void)(res); + res.emit('data', Buffer.from('{"success": true}')); + res.emit('end'); + return new EventEmitter() as ClientRequest; + }); + + await expect(fetchJson('https://example.com/redirect')).resolves.toEqual({ + success: true, + }); + + // Test 301 + getMock.mockImplementationOnce((_url, _options, callback) => { + const res = new EventEmitter() as IncomingMessage; + res.statusCode = 301; + res.headers = { location: 'https://example.com/final-permanent' }; + (callback as (res: IncomingMessage) => void)(res); + res.emit('end'); + return new EventEmitter() as ClientRequest; + }); + getMock.mockImplementationOnce((url, _options, callback) => { + expect(url).toBe('https://example.com/final-permanent'); + const res = new EventEmitter() as IncomingMessage; + res.statusCode = 200; + (callback as (res: IncomingMessage) => void)(res); + res.emit('data', Buffer.from('{"permanent": true}')); + res.emit('end'); + return new EventEmitter() as ClientRequest; + }); + + await expect( + fetchJson('https://example.com/redirect-perm'), + ).resolves.toEqual({ permanent: true }); + }); + + it('should reject on non-200/30x status code', async () => { + getMock.mockImplementationOnce((_url, _options, callback) => { + const res = new EventEmitter() as IncomingMessage; + res.statusCode = 404; + (callback as (res: IncomingMessage) => void)(res); + res.emit('end'); + return new EventEmitter() as ClientRequest; + }); + + await expect(fetchJson('https://example.com/error')).rejects.toThrow( + 'Request failed with status code 404', + ); + }); + + it('should reject on request error', async () => { + const error = new Error('Network error'); + getMock.mockImplementationOnce(() => { + const req = new EventEmitter() as ClientRequest; + req.emit('error', error); + return req; + }); + + await expect(fetchJson('https://example.com/error')).rejects.toThrow( + 'Network error', + ); + }); + + describe('with GITHUB_TOKEN', () => { + const originalToken = process.env['GITHUB_TOKEN']; + + beforeEach(() => { + process.env['GITHUB_TOKEN'] = 'my-secret-token'; + }); + + afterEach(() => { + if (originalToken) { + process.env['GITHUB_TOKEN'] = originalToken; + } else { + delete process.env['GITHUB_TOKEN']; + } + }); + + it('should include Authorization header if token is present', async () => { + getMock.mockImplementationOnce((_url, options, callback) => { + expect(options.headers).toEqual({ + 'User-Agent': 'gemini-cli', + Authorization: 'token my-secret-token', + }); + const res = new EventEmitter() as IncomingMessage; + res.statusCode = 200; + (callback as (res: IncomingMessage) => void)(res); + res.emit('data', Buffer.from('{"foo": "bar"}')); + res.emit('end'); + return new EventEmitter() as ClientRequest; + }); + await expect(fetchJson('https://api.github.com/user')).resolves.toEqual({ + foo: 'bar', + }); + }); + }); + + describe('without GITHUB_TOKEN', () => { + const originalToken = process.env['GITHUB_TOKEN']; + + beforeEach(() => { + delete process.env['GITHUB_TOKEN']; + }); + + afterEach(() => { + if (originalToken) { + process.env['GITHUB_TOKEN'] = originalToken; + } + }); + + it('should not include Authorization header if token is not present', async () => { + getMock.mockImplementationOnce((_url, options, callback) => { + expect(options.headers).toEqual({ + 'User-Agent': 'gemini-cli', + }); + const res = new EventEmitter() as IncomingMessage; + res.statusCode = 200; + (callback as (res: IncomingMessage) => void)(res); + res.emit('data', Buffer.from('{"foo": "bar"}')); + res.emit('end'); + return new EventEmitter() as ClientRequest; + }); + + await expect(fetchJson('https://api.github.com/user')).resolves.toEqual({ + foo: 'bar', + }); + }); + }); +}); diff --git a/packages/cli/src/config/extensions/github_fetch.ts b/packages/cli/src/config/extensions/github_fetch.ts index 3940275699..a4f9d29b70 100644 --- a/packages/cli/src/config/extensions/github_fetch.ts +++ b/packages/cli/src/config/extensions/github_fetch.ts @@ -10,7 +10,10 @@ export function getGitHubToken(): string | undefined { return process.env['GITHUB_TOKEN']; } -export async function fetchJson(url: string): Promise { +export async function fetchJson( + url: string, + redirectCount: number = 0, +): Promise { const headers: { 'User-Agent': string; Authorization?: string } = { 'User-Agent': 'gemini-cli', }; @@ -21,6 +24,18 @@ export async function fetchJson(url: string): Promise { return new Promise((resolve, reject) => { https .get(url, { headers }, (res) => { + if (res.statusCode === 302 || res.statusCode === 301) { + if (redirectCount >= 10) { + return reject(new Error('Too many redirects')); + } + if (!res.headers.location) { + return reject(new Error('No location header in redirect response')); + } + fetchJson(res.headers.location!, redirectCount++) + .then(resolve) + .catch(reject); + return; + } if (res.statusCode !== 200) { return reject( new Error(`Request failed with status code ${res.statusCode}`),