Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/(root)/dashboard/[username]/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ describe('DashboardPage', () => {
achievements: [],
commitClock: [],
graphData: { nodes: [], links: [] },
lastSyncedAt: undefined,
};

beforeEach(() => {
Expand Down
180 changes: 113 additions & 67 deletions app/api/github/route.test.ts
Original file line number Diff line number Diff line change
@@ -1,98 +1,166 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { GET } from './route';
// Replace the real GitHub API with a fake function so tests can run without hitting real APIs

// Replace the real GitHub API with a fake function
vi.mock('../../../lib/github', () => ({
getFullDashboardData: vi.fn(),
}));

import { getFullDashboardData } from '../../../lib/github';

function makeRequest(params: Record<string, string> = {}): Request {
import { quotaMonitor } from '@/services/github/quota-monitor';
import { refreshPolicy } from '@/services/github/refresh-policy';
import { refreshRateLimiter } from '@/services/github/refresh-rate-limiter';
import { backgroundRefresh } from '@/services/github/background-refresh';

function makeRequest(
params: Record<string, string> = {},
headers: Record<string, string> = {}
): Request {
const url = new URL('http://localhost/api/github');
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value);
}
return new Request(url.toString());
return new Request(url.toString(), {
headers: new Headers(headers),
});
}

describe('GET /api/github', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getFullDashboardData).mockResolvedValue({} as never);
vi.mocked(getFullDashboardData).mockResolvedValue({
profile: { lastSyncedAt: new Date().toISOString() },
calendar: {},
lastSyncedAt: new Date().toISOString(),
} as unknown as Awaited<ReturnType<typeof getFullDashboardData>>);

quotaMonitor.reset();
refreshPolicy.reset();
refreshRateLimiter.reset();
backgroundRefresh.reset();
});

describe('cache bypass via ?refresh=true', () => {
it('calls getFullDashboardData with { bypassCache: true } when ?refresh=true', async () => {
await GET(makeRequest({ username: 'octocat', refresh: 'true' }));
describe('Unrestricted Cache Bypass & Abuse Mitigation (Issue #1978)', () => {
// Scenario 1: Normal cached request
it('Scenario 1: serves cached data and checks SWR background refresh', async () => {
// Mock data that is stale (15 minutes ago)
const staleTime = new Date(Date.now() - 15 * 60 * 1000).toISOString();
vi.mocked(getFullDashboardData).mockResolvedValue({
profile: { lastSyncedAt: staleTime },
calendar: {},
} as unknown as Awaited<ReturnType<typeof getFullDashboardData>>);

expect(getFullDashboardData).toHaveBeenCalledWith('octocat', { bypassCache: true });
});
const triggerSpy = vi.spyOn(backgroundRefresh, 'triggerRefresh');

it('calls getFullDashboardData with { bypassCache: false } when refresh is omitted', async () => {
await GET(makeRequest({ username: 'octocat' }));
expect(getFullDashboardData).toHaveBeenCalledWith('octocat', {
bypassCache: false,
});
const response = await GET(makeRequest({ username: 'torvalds' }));
expect(response.status).toBe(200);
expect(getFullDashboardData).toHaveBeenCalledWith('torvalds', { bypassCache: false });
expect(triggerSpy).toHaveBeenCalledWith('torvalds');
});
it('returns 400 when username contains invalid characters', async () => {
const response = await GET(makeRequest({ username: '@@@@@' }));
const body = await response.json();

expect(response.status).toBe(400);
expect(body.error).toContain('Invalid parameters');
// Scenario 2: Single refresh request allowed
it('Scenario 2: allows a single refresh request when limits are respected', async () => {
const response = await GET(makeRequest({ username: 'torvalds', refresh: 'true' }));

expect(response.status).toBe(200);
expect(getFullDashboardData).toHaveBeenCalledWith('torvalds', { bypassCache: true });
expect(response.headers.get('X-Refresh-Status')).toBe('Fresh');
});

it('returns 400 when username contains only whitespace', async () => {
const response = await GET(makeRequest({ username: ' ' }));
const body = await response.json();
// Scenario 3: Repeated refresh within cooldown served from cache
it('Scenario 3: serves cached response for repeated refresh requests within cooldown', async () => {
// First refresh is allowed
await GET(makeRequest({ username: 'torvalds', refresh: 'true' }));
expect(getFullDashboardData).toHaveBeenLastCalledWith('torvalds', { bypassCache: true });

expect(response.status).toBe(400);
expect(body.error).toContain('Invalid parameters');
// Second refresh within cooldown (5 minutes)
const response = await GET(makeRequest({ username: 'torvalds', refresh: 'true' }));

expect(response.status).toBe(200);
// Cooldown fallback triggers cached read
expect(getFullDashboardData).toHaveBeenLastCalledWith('torvalds', { bypassCache: false });
expect(response.headers.get('X-Refresh-Status')).toBe('Cooldown-Served-Cached');
});

it('returns 400 when username exceeds GitHub maximum length', async () => {
// Scenario 4: Refresh rate limit exceeded per client IP
it('Scenario 4: returns 429 when client refresh rate limit is exceeded', async () => {
// Set rate limit to 2 per window for testing
refreshRateLimiter.setLimit(2);

// Refresh 1
await GET(
makeRequest({ username: 'torvalds', refresh: 'true' }, { 'x-real-ip': '203.0.113.5' })
);
// Refresh 2
await GET(
makeRequest({ username: 'octocat', refresh: 'true' }, { 'x-real-ip': '203.0.113.5' })
);

// Refresh 3 (exceeds limit of 2)
const response = await GET(
makeRequest({
username: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
})
makeRequest({ username: 'torvalds', refresh: 'true' }, { 'x-real-ip': '203.0.113.5' })
);

expect(response.status).toBe(429);
const body = await response.json();
expect(body.error).toContain('Refresh rate limit exceeded');
});

expect(response.status).toBe(400);
expect(body.error).toContain('Invalid parameters');
// Scenario 5: Low GitHub quota blocks refresh
it('Scenario 5: blocks manual refresh when remaining GitHub quota is low (<10%)', async () => {
// Set global remaining quota to 400 out of 5000 (8%)
quotaMonitor.setQuota(5000, 400, Date.now() + 60000);

const response = await GET(makeRequest({ username: 'torvalds', refresh: 'true' }));

expect(response.status).toBe(429);
const body = await response.json();
expect(body.error).toContain('quota is low');
expect(getFullDashboardData).not.toHaveBeenCalled();
});

// Test 1 — missing username → 400
it('returns 400 when username is missing', async () => {
const response = await GET(makeRequest());
// Scenario 6: Background refresh execution
it('Scenario 6: asynchronous background refresh completes successfully', async () => {
const loadSpy = vi.spyOn(backgroundRefresh, 'triggerRefresh');

// Mock data that is stale
const staleTime = new Date(Date.now() - 15 * 60 * 1000).toISOString();
vi.mocked(getFullDashboardData).mockResolvedValue({
profile: { lastSyncedAt: staleTime },
calendar: {},
} as unknown as Awaited<ReturnType<typeof getFullDashboardData>>);

await GET(makeRequest({ username: 'torvalds' }));

expect(loadSpy).toHaveBeenCalledWith('torvalds');
});
});

describe('Standard route behavior', () => {
it('returns 400 when username contains invalid characters', async () => {
const response = await GET(makeRequest({ username: '@@@@@' }));
const body = await response.json();

expect(response.status).toBe(400);
expect(body.error).toContain('Invalid parameters');
});

it('returns 400 and skips GitHub when username format is invalid', async () => {
const response = await GET(makeRequest({ username: 'bad user' }));
it('returns 400 when username contains only whitespace', async () => {
const response = await GET(makeRequest({ username: ' ' }));
const body = await response.json();

expect(response.status).toBe(400);
expect(body.error).toContain('Invalid parameters');
expect(getFullDashboardData).not.toHaveBeenCalled();
});

// Test 2 — valid username → 200
it('returns 200 with JSON body for a valid username', async () => {
vi.mocked(getFullDashboardData).mockResolvedValue({ profile: 'octocat' } as never);

const response = await GET(makeRequest({ username: 'octocat' }));
it('returns 400 when username is missing', async () => {
const response = await GET(makeRequest());
const body = await response.json();

expect(response.status).toBe(200);
expect(body).toEqual({ profile: 'octocat' });
expect(response.status).toBe(400);
expect(body.error).toContain('Invalid parameters');
});

// Test 3 — throws 'User not found' → 404
it('returns 404 when getFullDashboardData throws User not found', async () => {
vi.mocked(getFullDashboardData).mockRejectedValue(new Error('User not found'));

Expand All @@ -102,27 +170,5 @@ describe('GET /api/github', () => {
expect(response.status).toBe(404);
expect(body.error).toContain('User not found');
});

// Test 4 — throws 'API limit reached' → 403
it('returns 403 when getFullDashboardData throws API limit reached', async () => {
vi.mocked(getFullDashboardData).mockRejectedValue(new Error('API limit reached'));

const response = await GET(makeRequest({ username: 'octocat' }));
const body = await response.json();

expect(response.status).toBe(403);
expect(body.error).toContain('rate limit');
});

// Test 5 — throws generic error → 500
it('returns 500 for a generic unexpected error', async () => {
vi.mocked(getFullDashboardData).mockRejectedValue(new Error('Something went wrong'));

const response = await GET(makeRequest({ username: 'octocat' }));
const body = await response.json();

expect(response.status).toBe(500);
expect(body.error).toContain('Something went wrong');
});
});
});
89 changes: 86 additions & 3 deletions app/api/github/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,22 @@
import { NextResponse } from 'next/server';
import { getFullDashboardData } from '@/lib/github';
import { githubParamsSchema } from '@/lib/validations';
import { getClientIp } from '@/utils/getClientIp';
import { quotaMonitor } from '@/services/github/quota-monitor';
import { refreshPolicy } from '@/services/github/refresh-policy';
import { refreshRateLimiter } from '@/services/github/refresh-rate-limiter';
import { backgroundRefresh } from '@/services/github/background-refresh';

function logSecurityEvent(event: string, details: Record<string, unknown>) {
console.warn(
JSON.stringify({
timestamp: new Date().toISOString(),
type: 'SECURITY_EVENT',
event,
...details,
})
);
}

/**
* Returns GitHub dashboard data as JSON.
Expand All @@ -18,11 +34,12 @@ import { githubParamsSchema } from '@/lib/validations';
* - 400 → Invalid query parameters
* - 403 → GitHub API rate limit reached
* - 404 → GitHub user not found
* - 429 → Too many requests (Refresh rate limit or low quota)
* - 500 → Internal server error
*/

export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const ip = getClientIp(request);

const parseResult = githubParamsSchema.safeParse(Object.fromEntries(searchParams.entries()));

Expand All @@ -35,16 +52,82 @@ export async function GET(request: Request) {

const { username, refresh } = parseResult.data;

// 1. Quota awareness check - if remaining quota is low, disable manual refresh
if (refresh && quotaMonitor.isQuotaLow()) {
logSecurityEvent('LOW_QUOTA_REFRESH_BLOCKED', {
username,
ip,
remainingQuota: quotaMonitor.getQuota().remaining,
});
return NextResponse.json(
{ error: 'GitHub API quota is low. Cache refresh temporarily disabled.' },
{ status: 429 }
);
}

// 2. Separate Refresh Rate Limiter
if (refresh) {
const rateLimitCheck = refreshRateLimiter.checkLimit(ip);
if (!rateLimitCheck.success) {
logSecurityEvent('REFRESH_RATE_LIMIT_EXCEEDED', {
username,
ip,
limit: rateLimitCheck.limit,
});
return NextResponse.json(
{ error: 'Refresh rate limit exceeded. Please try again later.' },
{
status: 429,
headers: {
'X-RateLimit-Limit': rateLimitCheck.limit.toString(),
'X-RateLimit-Remaining': rateLimitCheck.remaining.toString(),
'X-RateLimit-Reset': rateLimitCheck.reset.toString(),
},
}
);
}
}

// 3. Per-Username Refresh Cooldown
let shouldBypassCache = refresh;
if (refresh) {
if (!refreshPolicy.isRefreshAllowed(username)) {
logSecurityEvent('REFRESH_COOLDOWN_VIOLATION', {
username,
ip,
remainingMs: refreshPolicy.getRemainingCooldown(username),
});
// Fallback: serve cached data instead of bypassing cache
shouldBypassCache = false;
} else {
refreshPolicy.recordRefresh(username);
}
}

try {
const data = await getFullDashboardData(username, { bypassCache: refresh });
const cacheControl = refresh
const data = await getFullDashboardData(username, { bypassCache: shouldBypassCache });

// 4. Stale-While-Revalidate background refresh for normal cached requests
if (!shouldBypassCache) {
const lastSynced = data.lastSyncedAt;
if (backgroundRefresh.isStale(lastSynced)) {
backgroundRefresh.triggerRefresh(username);
}
}

const cacheControl = shouldBypassCache
? 'no-cache, no-store, must-revalidate'
: 's-maxage=3600, stale-while-revalidate=86400';

return NextResponse.json(data, {
status: 200,
headers: {
'Cache-Control': cacheControl,
'X-Refresh-Status': shouldBypassCache
? 'Fresh'
: refresh
? 'Cooldown-Served-Cached'
: 'Cached',
},
});
} catch (error: unknown) {
Expand Down
Loading
Loading