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
8 changes: 6 additions & 2 deletions app/api/streak/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,9 +126,13 @@ export async function GET(request: Request) {
timezone = new Intl.DateTimeFormat(undefined, { timeZone: tzParam }).resolvedOptions()
.timeZone;
} catch (error) {
if (error instanceof Error && error.name === 'ValidationError') {
return NextResponse.json({ error: error.message }, { status: 400 });
if (error instanceof RangeError) {
// MINIMAL FIX: Translate native RangeErrors to named ValidationErrors so they cascade to the 400 SVG response catcher cleanly
const validationErr = new Error(`Invalid timezone: ${tzParam}`);
validationErr.name = 'ValidationError';
throw validationErr;
}
throw error;
}
}

Expand Down
88 changes: 88 additions & 0 deletions app/api/streak/tests/security-resilience.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
import { middleware } from '../../../../middleware';
import { GET } from '../route';
import { streakParamsSchema } from '@/lib/validations';

// Stub core internal modules to prevent cascading unmocked downstream network fetches
vi.mock('@/lib/github', () => ({
fetchGitHubContributions: vi.fn(() =>
Promise.resolve({
calendar: { totalContributions: 0, weeks: [] },
isOfflineFallback: false,
})
),
getOrgDashboardData: vi.fn(),
}));

describe('Streak Endpoint - Security & Timezone Resilience Architecture', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal('process', {
env: {
KV_REST_API_URL: '', // Keeps test execution isolated inside predictable in-memory fallbacks
KV_REST_API_TOKEN: '',
},
});
});

// Security Verification Path: Cache Bypass Sliding Window Bucket Rate Limiter Checks
it('enforces a strict rate-limiting barrier of 3 requests max per 10-minute window block for cache bypass queries', async () => {
const createBypassRequest = () =>
new NextRequest('http://localhost:3000/api/streak?user=octocat&refresh=true', {
headers: { 'x-forwarded-for': '192.168.1.50' },
});

const res1 = await middleware(createBypassRequest());
expect(res1.status).toBeLessThan(400);

const res2 = await middleware(createBypassRequest());
expect(res2.status).toBeLessThan(400);

const res3 = await middleware(createBypassRequest());
expect(res3.status).toBeLessThan(400);

const res4 = await middleware(createBypassRequest());
expect(res4.status).toBe(429);

const body = await res4.json();
expect(body.error).toContain('Too many refresh requests');
});

// Resilience Verification Path: Forced Timezone RangeError Exception Mapping Checks
it('safely intercepts native Intl RangeErrors and gracefully builds a structured 400 SVG card', async () => {
// 1. Clean Fix: Cast through unknown to satisfy both TS and strict ESLint any bans
const fakeParsedData = {
success: true,
data: {
user: 'octocat',
tz: 'ForcedTriggerTimezoneString',
},
} as unknown as ReturnType<typeof streakParamsSchema.safeParse>;

const zodSpy = vi.spyOn(streakParamsSchema, 'safeParse').mockReturnValue(fakeParsedData);

// 2. Clean Fix: Use an ES5 constructor function cast through unknown to match the constructor type signature
const constructibleMock = function () {
throw new RangeError('unsupported time zone');
} as unknown as typeof Intl.DateTimeFormat;

const dateTimeFormatSpy = vi
.spyOn(Intl, 'DateTimeFormat')
.mockImplementation(constructibleMock);

const dummyRequest = new Request('http://localhost:3000/api/streak?user=octocat&tz=UTC');
const response = await GET(dummyRequest);

// Clean up spy instances immediately to preserve environment state
dateTimeFormatSpy.mockRestore();
zodSpy.mockRestore();

// Asserts exception code maps successfully to a 400 client error state instead of a 500 or JSON error
expect(response.status).toBe(400);

const svgContent = await response.text();
expect(svgContent).toContain('svg');
expect(svgContent).toContain('Invalid timezone');
});
});
3 changes: 2 additions & 1 deletion middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ export async function middleware(request: NextRequest): Promise<NextResponse> {
request.nextUrl.searchParams.get('bypassCache') === 'true';

if (isRefresh) {
const refreshResult = await rateLimit(`refresh:${ip}`, 5, 60000);
// MINIMAL FIX: Pass strict windows straight into the existing multi-bucket rate limiter engine
const refreshResult = await rateLimit(`refresh:${ip}`, 3, 600000);

if (!refreshResult.success) {
const resp = NextResponse.json(
Expand Down
Loading
Loading