diff --git a/app/api/pr-insights/route.mouse-interactivity.test.ts b/app/api/pr-insights/route.mouse-interactivity.test.ts index b6f881a27..3ecc622fb 100644 --- a/app/api/pr-insights/route.mouse-interactivity.test.ts +++ b/app/api/pr-insights/route.mouse-interactivity.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { GET } from './route'; import { fetchPRInsights } from '@/services/github/pr-insights'; +import { RateLimiter } from '@/lib/rate-limit'; vi.mock('@/services/github/pr-insights', () => ({ fetchPRInsights: vi.fn(), @@ -9,6 +10,12 @@ vi.mock('@/services/github/pr-insights', () => ({ describe('pr-insights mouse interactivity contract', () => { beforeEach(() => { vi.clearAllMocks(); + vi.spyOn(RateLimiter.prototype, 'checkWithResult').mockResolvedValue({ + success: true, + limit: 10, + remaining: 9, + reset: 123456789, + }); }); it('returns 400 when username is missing', async () => { @@ -48,6 +55,32 @@ describe('pr-insights mouse interactivity contract', () => { expect(await response.json()).toEqual(mockData); }); + it('rejects requests when the endpoint abuse budget is exhausted', async () => { + vi.spyOn(RateLimiter.prototype, 'checkWithResult').mockResolvedValueOnce({ + success: false, + limit: 10, + remaining: 0, + reset: 123456789, + }); + + const response = await GET(new Request('http://localhost/api/pr-insights?username=octocat')); + + expect(response.status).toBe(429); + expect(response.headers.get('x-ratelimit-remaining')).toBe('0'); + expect(fetchPRInsights).not.toHaveBeenCalled(); + }); + + it('uses a fixed endpoint bucket that cannot be rotated with usernames', async () => { + vi.mocked(fetchPRInsights).mockResolvedValue({} as never); + const checkSpy = vi.spyOn(RateLimiter.prototype, 'checkWithResult'); + + await GET(new Request('http://localhost/api/pr-insights?username=octocat')); + await GET(new Request('http://localhost/api/pr-insights?username=torvalds')); + + expect(checkSpy).toHaveBeenNthCalledWith(1, 'pr-insights'); + expect(checkSpy).toHaveBeenNthCalledWith(2, 'pr-insights'); + }); + it('returns error message from thrown Error', async () => { vi.mocked(fetchPRInsights).mockRejectedValue(new Error('Service failed')); diff --git a/app/api/pr-insights/route.ts b/app/api/pr-insights/route.ts index 7408b908a..6067304e0 100644 --- a/app/api/pr-insights/route.ts +++ b/app/api/pr-insights/route.ts @@ -1,6 +1,9 @@ import { NextResponse } from 'next/server'; import { fetchPRInsights } from '@/services/github/pr-insights'; import { validateGitHubUsername } from '@/lib/validations'; +import { getRateLimitHeaders, RateLimiter } from '@/lib/rate-limit'; + +const prInsightsLimiter = new RateLimiter(10, 60_000, 1); export async function GET(request: Request) { const { searchParams } = new URL(request.url); @@ -15,6 +18,14 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'Invalid GitHub username' }, { status: 400 }); } + const rateLimitResult = await prInsightsLimiter.checkWithResult('pr-insights'); + if (!rateLimitResult.success) { + return NextResponse.json( + { error: 'Too many requests. Please try again later.' }, + { status: 429, headers: getRateLimitHeaders(rateLimitResult) } + ); + } + try { const data = await fetchPRInsights(trimmed); return NextResponse.json(data); diff --git a/proxy.rate-limit.test.ts b/proxy.rate-limit.test.ts index 6136c64ec..ecbb9c24c 100644 --- a/proxy.rate-limit.test.ts +++ b/proxy.rate-limit.test.ts @@ -78,6 +78,7 @@ describe('Proxy rate-limit consistency', () => { '/api/track-user/:path*', '/api/stats/:path*', '/api/og/:path*', + '/api/pr-insights/:path*', ]; for (const route of expectedRoutes) { expect(mwConfig.matcher).toContain(route); diff --git a/proxy.ts b/proxy.ts index aca34f4e7..b23f3ba08 100644 --- a/proxy.ts +++ b/proxy.ts @@ -58,5 +58,6 @@ export const config = { '/api/compare/:path*', '/api/wrapped/:path*', '/api/student/:path*', + '/api/pr-insights/:path*', ], }; diff --git a/services/github/pr-insights.massive-scaling.test.ts b/services/github/pr-insights.massive-scaling.test.ts index 12181b6b9..68056e5ab 100644 --- a/services/github/pr-insights.massive-scaling.test.ts +++ b/services/github/pr-insights.massive-scaling.test.ts @@ -216,4 +216,29 @@ describe('PR Insights - Massive Data Sets and Extreme High Bounds Scaling', () = expect(layoutGrid.style.gridTemplateColumns).toBe('repeat(20000, 1fr)'); expect(document.body.contains(layoutGrid)).toBe(true); }); + + it('caps GraphQL pagination at three pages even when GitHub reports more results', async () => { + const mockResponse = { + ok: true, + status: 200, + json: async () => ({ + data: { + authored: { + nodes: [], + pageInfo: { + hasNextPage: true, + endCursor: 'next-page', + }, + }, + reviewed: { issueCount: 0 }, + }, + }), + }; + + vi.mocked(fetchWithRetry).mockResolvedValue(mockResponse as any); + + await fetchPRInsights('PaginationBudgetContributor'); + + expect(fetchWithRetry).toHaveBeenCalledTimes(3); + }); }); diff --git a/services/github/pr-insights.ts b/services/github/pr-insights.ts index c625fda4e..691075f50 100644 --- a/services/github/pr-insights.ts +++ b/services/github/pr-insights.ts @@ -2,6 +2,7 @@ import { fetchWithRetry, getGitHubTokens } from '@/lib/github'; import { DistributedCache } from '@/lib/cache'; const GITHUB_GRAPHQL_URL = 'https://api.github.com/graphql'; +const MAX_PAGES = 3; export interface PRInsightData { totalPRs: number; @@ -128,8 +129,6 @@ async function fetchPRInsightsUncached(username: string): Promise let hasNextPage = true; let after: string | null = null; let reviewsGivenCount = 0; - const MAX_PAGES = 10; // Cap at 1000 PRs (10 pages x 100) - for (let page = 0; page < MAX_PAGES && hasNextPage; page++) { const res = await fetchWithRetry(GITHUB_GRAPHQL_URL, { method: 'POST',