diff --git a/lib/github.rotation.test.ts b/lib/github.rotation.test.ts index 44ae5950f..83e0e35d5 100644 --- a/lib/github.rotation.test.ts +++ b/lib/github.rotation.test.ts @@ -7,6 +7,12 @@ import { getGlobalCircuitBreakerOpenUntilForTests, } from './github'; +const MOCK_TOKEN_1 = 'ghp_token1AAAAAAAAAAAAAAAAAAAAAAAAAAAA'; +const MOCK_TOKEN_2 = 'ghp_token2AAAAAAAAAAAAAAAAAAAAAAAAAAAA'; +const MOCK_TOKEN_3 = 'ghp_token3AAAAAAAAAAAAAAAAAAAAAAAAAAAA'; +const MOCK_BAD_TOKEN = 'ghp_badtokenAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; +const MOCK_GOOD_TOKEN = 'ghp_goodtokenAAAAAAAAAAAAAAAAAAAAAAAAAAA'; + describe('GitHub Multi-Token Rotation & Fallback', () => { const originalGitHubPat = process.env.GITHUB_PAT; const originalGitHubToken = process.env.GITHUB_TOKEN; @@ -25,15 +31,15 @@ describe('GitHub Multi-Token Rotation & Fallback', () => { }); it('correctly parses multiple comma-separated tokens', () => { - process.env.GITHUB_PAT = ' token1, token2, token3 '; + process.env.GITHUB_PAT = ` ${MOCK_TOKEN_1}, ${MOCK_TOKEN_2}, ${MOCK_TOKEN_3} `; delete process.env.GITHUB_TOKEN; const tokens = getGitHubTokens(); - expect(tokens).toEqual(['token1', 'token2', 'token3']); + expect(tokens).toEqual([MOCK_TOKEN_1, MOCK_TOKEN_2, MOCK_TOKEN_3]); }); it('rotates to the next token on HTTP 429 rate limiting', async () => { - process.env.GITHUB_PAT = 'token1,token2'; + process.env.GITHUB_PAT = `${MOCK_TOKEN_1},${MOCK_TOKEN_2}`; delete process.env.GITHUB_TOKEN; fetchMock.mockResolvedValueOnce({ @@ -60,14 +66,14 @@ describe('GitHub Multi-Token Rotation & Fallback', () => { expect(fetchMock).toHaveBeenCalledTimes(2); const firstCallHeaders = fetchMock.mock.calls[0][1].headers; - expect(firstCallHeaders.Authorization).toBe('bearer token1'); + expect(firstCallHeaders.Authorization).toBe(`bearer ${MOCK_TOKEN_1}`); const secondCallHeaders = fetchMock.mock.calls[1][1].headers; - expect(secondCallHeaders.Authorization).toBe('bearer token2'); + expect(secondCallHeaders.Authorization).toBe(`bearer ${MOCK_TOKEN_2}`); }); it('rotates to the next token on HTTP 401 unauthorized and excludes the bad token for 24h', async () => { - process.env.GITHUB_PAT = 'bad_token,good_token'; + process.env.GITHUB_PAT = `${MOCK_BAD_TOKEN},${MOCK_GOOD_TOKEN}`; delete process.env.GITHUB_TOKEN; fetchMock.mockResolvedValueOnce({ @@ -89,8 +95,8 @@ describe('GitHub Multi-Token Rotation & Fallback', () => { expect(res.status).toBe(200); expect(fetchMock).toHaveBeenCalledTimes(2); - expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe('bearer bad_token'); - expect(fetchMock.mock.calls[1][1].headers.Authorization).toBe('bearer good_token'); + expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe(`bearer ${MOCK_BAD_TOKEN}`); + expect(fetchMock.mock.calls[1][1].headers.Authorization).toBe(`bearer ${MOCK_GOOD_TOKEN}`); fetchMock.mockResolvedValueOnce({ status: 200, @@ -104,7 +110,7 @@ describe('GitHub Multi-Token Rotation & Fallback', () => { }); expect(res2.status).toBe(200); expect(fetchMock).toHaveBeenCalledTimes(3); - expect(fetchMock.mock.calls[2][1].headers.Authorization).toBe('bearer good_token'); + expect(fetchMock.mock.calls[2][1].headers.Authorization).toBe(`bearer ${MOCK_GOOD_TOKEN}`); }); it('prioritizes token with highest remaining quota', async () => { diff --git a/lib/github.test.ts b/lib/github.test.ts index 211a3e027..5dc9cc47d 100644 --- a/lib/github.test.ts +++ b/lib/github.test.ts @@ -52,7 +52,7 @@ function mockResponse(body: unknown, status = 200): Response { beforeEach(() => { clearGitHubApiCacheForTests(); - process.env.GITHUB_PAT = 'test-token'; + process.env.GITHUB_PAT = 'ghp_testtokenAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; delete process.env.GITHUB_TOKEN; }); @@ -177,7 +177,7 @@ describe('fetchGitHubContributions', () => { expect(url).toBe('https://api.github.com/graphql'); expect(options?.method).toBe('POST'); expect(options?.headers).toMatchObject({ - Authorization: 'bearer test-token', + Authorization: 'bearer ghp_testtokenAAAAAAAAAAAAAAAAAAAAAAAAAAAA', 'Content-Type': 'application/json', }); @@ -188,7 +188,7 @@ describe('fetchGitHubContributions', () => { it('uses GITHUB_TOKEN when GITHUB_PAT is not configured', async () => { delete process.env.GITHUB_PAT; - process.env.GITHUB_TOKEN = 'actions-token'; + process.env.GITHUB_TOKEN = 'ghp_actionstokenAAAAAAAAAAAAAAAAAAAAAAAA'; vi.mocked(fetch).mockResolvedValue( mockResponse({ data: { @@ -206,13 +206,13 @@ describe('fetchGitHubContributions', () => { const [, options] = vi.mocked(fetch).mock.calls[0]; expect(options?.headers).toMatchObject({ - Authorization: 'bearer actions-token', + Authorization: 'bearer ghp_actionstokenAAAAAAAAAAAAAAAAAAAAAAAA', }); }); it('verifies Authorization header uses GITHUB_TOKEN value in fallback path', async () => { delete process.env.GITHUB_PAT; - process.env.GITHUB_TOKEN = 'my-actions-token'; + process.env.GITHUB_TOKEN = 'ghp_fallbacktokenAAAAAAAAAAAAAAAAAAAAAAAA'; vi.mocked(fetch).mockResolvedValue( mockResponse({ data: { @@ -230,7 +230,7 @@ describe('fetchGitHubContributions', () => { const [, options] = vi.mocked(fetch).mock.calls[0]; expect(options?.headers).toMatchObject({ - Authorization: 'bearer my-actions-token', + Authorization: 'bearer ghp_fallbacktokenAAAAAAAAAAAAAAAAAAAAAAAA', }); }); diff --git a/lib/github.ts b/lib/github.ts index 859435746..c04df312a 100644 --- a/lib/github.ts +++ b/lib/github.ts @@ -45,7 +45,6 @@ export function getGlobalCircuitBreakerOpenUntilForTests() { return globalCircuitBreakerOpenUntil; } -//Explicit, strongly-typed Error subclass export class RateLimitError extends Error { constructor( message: string, @@ -56,15 +55,24 @@ export class RateLimitError extends Error { } } -// Global circuit state tracking let globalCircuitBreakerOpenUntil = 0; +function isValidGitHubTokenFormat(token: string): boolean { + return ( + token.length >= 36 && + (token.startsWith('ghp_') || + token.startsWith('ghu_') || + token.startsWith('ghs_') || + token.startsWith('ghr_') || + token.startsWith('github_pat_')) + ); +} export function getGitHubTokens(): string[] { const envToken = process.env.GITHUB_PAT || process.env.GITHUB_TOKEN || ''; return envToken .split(',') .map((t) => t.trim()) - .filter((t) => t !== ''); + .filter((t) => t !== '' && isValidGitHubTokenFormat(t)); } function isAbortError(error: unknown): boolean {