From 6c196f53b5d556fe8d800ab531fdfe14df7e7f1e Mon Sep 17 00:00:00 2001 From: Jeevitha S Date: Sun, 7 Jun 2026 21:56:02 +0530 Subject: [PATCH] test(sanitizer-accessibility): verify Accessibility Standards & Screen Reader Aria Compliance --- .env.local.example | 34 ++ .gitignore | 3 + app/(root)/dashboard/[username]/page.test.tsx | 1 + app/api/notify/route.test.ts | 31 ++ app/api/notify/route.ts | 19 +- app/api/reviews/route.ts | 120 +++++ app/api/streak/route.test.ts | 7 +- app/api/streak/route.ts | 21 +- .../CompareClient.accessibility.test.tsx | 21 +- .../CompareClient.empty-fallback.test.tsx | 4 + .../CompareClient.mock-integrations.test.tsx | 8 +- ...CompareClient.mouse-interactivity.test.tsx | 21 +- ...pareClient.responsive-breakpoints.test.tsx | 21 +- app/compare/CompareClient.test.tsx | 28 +- .../CompareClient.theme-contrast.test.tsx | 21 +- app/compare/CompareClient.tsx | 79 +++ app/compare/page.mouse-interactivity.test.tsx | 21 +- app/compare/page.theme-contrast.test.tsx | 21 +- app/components/navbar.tsx | 4 + app/customize/page.tsx | 10 +- app/customize/utils.test.ts | 50 +- app/customize/utils.ts | 24 +- .../sections/TechnologiesSection.tsx | 4 - components/InteractiveViewer.test.tsx | 88 ++++ components/InteractiveViewer.tsx | 41 +- components/Leaderboard.accessibility.test.tsx | 4 +- .../Leaderboard.error-resilience.test.tsx | 23 +- .../Leaderboard.massive-scaling.test.tsx | 8 +- .../Leaderboard.mock-integrations.test.tsx | 23 +- .../Leaderboard.mouse-interactivity.test.tsx | 16 +- ...eaderboard.responsive-breakpoints.test.tsx | 16 +- components/Leaderboard.test.tsx | 2 +- .../Leaderboard.theme-contrast.test.tsx | 12 + components/Leaderboard.tsx | 36 +- components/WallOfLove.accessibility.test.tsx | 3 +- .../WallOfLove.massive-scaling.test.tsx | 2 +- components/WallOfLove.tsx | 3 +- ...hievements.responsive-breakpoints.test.tsx | 2 + ...ActivityLandscape.massive-scaling.test.tsx | 7 +- .../dashboard/ActivityLandscape.test.ts | 43 +- .../dashboard/ActivityLandscape.test.tsx | 7 + components/dashboard/ActivityLandscape.tsx | 61 ++- .../ActivityLandscape.type-compiler.test.tsx | 6 +- .../CommitClock.timezone-boundaries.test.tsx | 28 +- .../dashboard/ComparisonStatsCard.test.tsx | 13 +- .../DashboardClient.theme-contrast.test.tsx | 4 + components/dashboard/DashboardClient.tsx | 14 +- components/dashboard/HallOfFame.tsx | 166 +++++++ .../Heatmap.massive-scaling.test.tsx | 7 +- .../Heatmap.responsive-breakpoints.test.tsx | 8 +- components/dashboard/Heatmap.test.tsx | 33 +- .../Heatmap.timezone-boundaries.test.tsx | 16 + components/dashboard/Heatmap.tsx | 34 +- ...istoricalTrendView.empty-fallback.test.tsx | 152 ++++++ ...storicalTrendView.massive-scaling.test.tsx | 2 +- .../dashboard/HistoricalTrendView.test.tsx | 44 ++ ...istoricalTrendView.theme-contrast.test.tsx | 2 +- components/dashboard/HistoricalTrendView.tsx | 12 +- .../LanguageChart.error-resilience.test.tsx | 102 ++++ .../ProfileCard.timezone-boundaries.test.tsx | 5 + .../RadarChart.empty-fallback.test.tsx | 46 +- .../RadarChart.error-resilience.test.tsx | 8 +- .../RadarChart.mock-integrations.test.tsx | 6 +- components/dashboard/RadarChart.test.tsx | 24 +- components/dashboard/RadarChart.tsx | 288 ++++++----- components/dashboard/tooltipUtils.test.ts | 5 + components/dashboard/tooltipUtils.ts | 4 + components/reviewform.tsx | 95 +++- hooks/useKeyboardShortcuts.ts | 75 +++ lib/calculate.test.ts | 87 ++++ lib/calculate.ts | 2 +- lib/github.ts | 242 ++++++++++ lib/svg/constellation.test.ts | 172 +++++++ lib/svg/constellation.ts | 448 ++++++++++++++++++ lib/svg/constellationConstants.ts | 71 +++ lib/svg/generator.test.ts | 217 +++++++-- lib/svg/generator.ts | 187 +++----- lib/svg/sanitizer.accessibility.test.ts | 96 ++++ lib/validations.test.ts | 73 +++ lib/validations.ts | 39 +- proxy.ts => middleware.ts | 10 +- models/Review.ts | 55 +++ proxy.accessibility.test.ts | 96 ++++ proxy.test.ts | 37 +- types/dashboard.ts | 16 + types/index.ts | 6 +- utils/getClientIp.ts | 18 + 87 files changed, 3522 insertions(+), 519 deletions(-) create mode 100644 .env.local.example create mode 100644 app/api/reviews/route.ts create mode 100644 components/dashboard/HallOfFame.tsx create mode 100644 components/dashboard/HistoricalTrendView.empty-fallback.test.tsx create mode 100644 components/dashboard/LanguageChart.error-resilience.test.tsx create mode 100644 hooks/useKeyboardShortcuts.ts create mode 100644 lib/svg/constellation.test.ts create mode 100644 lib/svg/constellation.ts create mode 100644 lib/svg/constellationConstants.ts create mode 100644 lib/svg/sanitizer.accessibility.test.ts rename proxy.ts => middleware.ts (83%) create mode 100644 models/Review.ts create mode 100644 proxy.accessibility.test.ts diff --git a/.env.local.example b/.env.local.example new file mode 100644 index 000000000..11c825911 --- /dev/null +++ b/.env.local.example @@ -0,0 +1,34 @@ +# Copy this file to .env.local and fill in your actual values. +# NEVER commit your .env.local file to version control. + +# The absolute URL of your deployment (e.g., http://localhost:3000) +# Required for generating full URLs like OG images and API redirects. +NEXT_PUBLIC_SITE_URL=http://localhost:3000 + +# MongoDB Connection URI for storing sessions and analytics. +# Get yours from MongoDB Atlas (e.g., mongodb+srv://:@cluster...) +MONGODB_URI=mongodb+srv://:@cluster0.mongodb.net/commitpulse?retryWrites=true&w=majority + +# GitHub Personal Access Token to avoid strict public GraphQL limits. +# Generate at: https://github.com/settings/tokens (No scopes required) +GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + +# Vercel KV / Upstash Redis URL for distributed rate limiting. +# Leave blank to operate with degraded local-memory rate limits. +KV_REST_API_URL= + +# Vercel KV / Upstash Redis API token for authentication. +# Leave blank to operate with degraded local-memory rate limits. +KV_REST_API_TOKEN= + +# Maximum background dashboard force-refreshes allowed per user per hour. +# Defaults to 5 if left blank. +MAX_REFRESHES_PER_HOUR=5 + +# Comma-separated list of trusted proxy IPs (e.g., Cloudflare, Nginx). +# Required to correctly identify client IPs behind reverse proxies. +TRUSTED_PROXIES= + +# Set to "true" to automatically trust private IPs (10.x, 192.168.x). +# Defaults to "false" (automatically enabled in development). +TRUST_PRIVATE_PROXIES=false diff --git a/.gitignore b/.gitignore index b4aae1d15..e92aed812 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,9 @@ # misc .DS_Store *.pem +*.swp +*.swo +*.swn # debug npm-debug.log* diff --git a/app/(root)/dashboard/[username]/page.test.tsx b/app/(root)/dashboard/[username]/page.test.tsx index 7e85cff2b..aa8939ac3 100644 --- a/app/(root)/dashboard/[username]/page.test.tsx +++ b/app/(root)/dashboard/[username]/page.test.tsx @@ -111,6 +111,7 @@ describe('DashboardPage', () => { lastSyncedAt: undefined, popularRepos: [], pinnedRepos: [], + hallOfFame: [], }; beforeEach(() => { diff --git a/app/api/notify/route.test.ts b/app/api/notify/route.test.ts index 34618db0d..9f745eedb 100644 --- a/app/api/notify/route.test.ts +++ b/app/api/notify/route.test.ts @@ -27,6 +27,9 @@ vi.mock('@/lib/rate-limit', () => ({ }), }, })); +vi.mock('@/utils/getClientIp', () => ({ + getClientIp: vi.fn().mockReturnValue('127.0.0.1'), +})); vi.mock('@/services/github/validate-user', () => ({ gitHubUserValidator: { validateUser: vi.fn().mockResolvedValue(true), @@ -36,6 +39,7 @@ vi.mock('@/services/github/validate-user', () => ({ import { Notification } from '@/models/Notification'; import { notifyRateLimiter } from '@/lib/rate-limit'; import { gitHubUserValidator } from '@/services/github/validate-user'; +import { getClientIp } from '@/utils/getClientIp'; const makeRequest = (method: string, body?: object, search?: string) => { const url = `http://localhost:3000/api/notify${search ? '?' + search : ''}`; @@ -265,6 +269,33 @@ describe('GET /api/notify', () => { expect(res.headers.get('x-ratelimit-reset')).toBe(reset.toString()); }); + it('applies rate limiting via user-agent fallback when IP is unknown', async () => { + // Simulates a client behind a misconfigured proxy where getClientIp returns 'unknown'. + // Previously the entire rate-limit block was skipped for these clients. + vi.mocked(getClientIp).mockReturnValueOnce('unknown'); + + const reset = Date.now() + 60000; + vi.mocked(notifyRateLimiter.checkWithResult).mockResolvedValueOnce({ + success: false, + limit: 5, + remaining: 0, + reset, + }); + + const url = 'http://localhost:3000/api/notify?user=testuser'; + const req = new NextRequest(url, { + method: 'GET', + headers: { 'user-agent': 'test-agent' }, + }); + + const res = await GET(req); + + // Must be rate limited even without a resolvable IP + expect(res.status).toBe(429); + // Verify checkWithResult was called with the user-agent fallback key + expect(notifyRateLimiter.checkWithResult).toHaveBeenCalledWith('unknown:test-agent'); + }); + // ── MONGODB_URI handling ────────────────────────────────────────────────── it('returns 500 when MONGODB_URI is not set in production', async () => { diff --git a/app/api/notify/route.ts b/app/api/notify/route.ts index ca0706b08..665668a14 100644 --- a/app/api/notify/route.ts +++ b/app/api/notify/route.ts @@ -255,18 +255,19 @@ export async function DELETE(req: NextRequest) { // ─── GET /api/notify ───────────────────────────────────────────────────────── // Fetch notification preferences for a user export async function GET(req: Request) { - // Rate limiting + // Rate limiting — always applied with user-agent fallback for unknown IPs, + // consistent with the POST and DELETE handlers in this file. const ip = getClientIp(req); - if (ip !== 'unknown') { - const rateLimitResult = await notifyRateLimiter.checkWithResult(ip); + const rateLimitKey = + ip && ip !== 'unknown' ? ip : `unknown:${req.headers.get('user-agent') ?? 'no-agent'}`; + const rateLimitResult = await notifyRateLimiter.checkWithResult(rateLimitKey); - if (!rateLimitResult.success) { - return NextResponse.json( - { success: false, message: 'Too many requests, please try again later.' }, - { status: 429, headers: getRateLimitHeaders(rateLimitResult) } - ); - } + if (!rateLimitResult.success) { + return NextResponse.json( + { success: false, message: 'Too many requests, please try again later.' }, + { status: 429, headers: getRateLimitHeaders(rateLimitResult) } + ); } // Validate query params with Zod diff --git a/app/api/reviews/route.ts b/app/api/reviews/route.ts new file mode 100644 index 000000000..dafe5e222 --- /dev/null +++ b/app/api/reviews/route.ts @@ -0,0 +1,120 @@ +import { NextResponse } from 'next/server'; +import dbConnect from '@/lib/mongodb'; +import { Review } from '@/models/Review'; +import { reviewPostSchema } from '@/lib/validations'; +import { getClientIp } from '@/utils/getClientIp'; +import { DistributedCache } from '@/lib/cache'; +import { notifyRateLimiter } from '@/lib/rate-limit'; + +// Per-IP cooldown: one submission per 10 minutes to prevent spam +const reviewWriteCache = new DistributedCache(5000, 60000); +const REVIEW_WRITE_COOLDOWN_MS = 10 * 60 * 1000; + +// ─── POST /api/reviews ──────────────────────────────────────────────────────── +// Submit a testimonial review for the Wall of Love +export async function POST(req: Request) { + // Rate limiting — always applied with user-agent fallback for unknown IPs + const ip = getClientIp(req); + const rateLimitKey = + ip && ip !== 'unknown' ? ip : `unknown:${req.headers.get('user-agent') ?? 'no-agent'}`; + + const rateLimitResult = await notifyRateLimiter.checkWithResult(rateLimitKey); + if (!rateLimitResult.success) { + return NextResponse.json( + { success: false, message: 'Too many requests, please try again later.' }, + { status: 429 } + ); + } + + // Parse JSON body safely + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json( + { success: false, message: 'Malformed JSON request body.' }, + { status: 400 } + ); + } + + // Validate with Zod + const parsed = reviewPostSchema.safeParse(body); + if (!parsed.success) { + const fieldErrors = parsed.error.flatten(); + const firstError = + Object.values(fieldErrors.fieldErrors).flat()[0] ?? + fieldErrors.formErrors[0] ?? + 'Invalid request body.'; + return NextResponse.json({ success: false, message: firstError }, { status: 400 }); + } + + const { name, handle, platform, message, accentColor } = parsed.data; + + // Per-IP write cooldown to prevent rapid duplicate submissions + const lastWrite = await reviewWriteCache.get(`review:write:${rateLimitKey}`); + if (lastWrite) { + const remaining = Math.max( + 1, + Math.ceil((REVIEW_WRITE_COOLDOWN_MS - (Date.now() - lastWrite)) / 1000) + ); + return NextResponse.json( + { + success: false, + message: `Please wait ${remaining} second${remaining === 1 ? '' : 's'} before submitting another review.`, + }, + { status: 429 } + ); + } + + try { + // Graceful MONGODB_URI handling + if (!process.env.MONGODB_URI) { + if (process.env.NODE_ENV === 'production') { + console.error( + 'CRITICAL: MONGODB_URI is not set in production environment. Review submission is disabled.' + ); + return NextResponse.json( + { success: false, message: 'Database configuration error.' }, + { status: 500 } + ); + } + + console.warn('MONGODB_URI is not set. Bypassing review submission for local development.'); + return NextResponse.json({ + success: true, + message: 'Review submission bypassed (no database configured).', + }); + } + + await dbConnect(); + + await Review.create({ + name: name.trim(), + handle: handle.trim(), + platform, + message: message.trim(), + accentColor, + approved: false, + }); + + await reviewWriteCache.set( + `review:write:${rateLimitKey}`, + Date.now(), + REVIEW_WRITE_COOLDOWN_MS + ); + + return NextResponse.json( + { + success: true, + message: 'Your testimonial has been received. It will be featured soon!', + }, + { status: 201 } + ); + } catch (error) { + console.error('[/api/reviews] Error saving review:', error); + return NextResponse.json( + { success: false, message: 'Internal server error.' }, + { status: 500 } + ); + } +} diff --git a/app/api/streak/route.test.ts b/app/api/streak/route.test.ts index 73b002eb6..f293c3a4c 100644 --- a/app/api/streak/route.test.ts +++ b/app/api/streak/route.test.ts @@ -481,11 +481,10 @@ describe('GET /api/streak', () => { expect(body).toContain('20s'); }); - it('falls back to 8s when speed is a non-integer decimal like "2.0s"', async () => { - const response = await GET(makeRequest({ user: 'octocat', speed: '2.0s' })); + it('preserves an in-range decimal speed like "8.5s" instead of forcing it to an integer', async () => { + const response = await GET(makeRequest({ user: 'octocat', speed: '8.5s' })); const body = await response.text(); - expect(body).toContain('8s'); - expect(body).not.toContain('2.0s'); + expect(body).toContain('8.5s'); }); }); diff --git a/app/api/streak/route.ts b/app/api/streak/route.ts index 574a296ce..205aecc76 100644 --- a/app/api/streak/route.ts +++ b/app/api/streak/route.ts @@ -14,13 +14,9 @@ import { generatePulseSVG, generateLanguagesSVG, } from '@/lib/svg/generator'; +import { generateConstellationSVG } from '@/lib/svg/constellation'; import { getSecondsUntilUTCMidnight, getSecondsUntilMidnightInTimezone } from '@/utils/time'; -import type { - BadgeParams, - ContributionCalendar, - RepoContribution, - ExtendedContributionData, -} from '@/types'; +import type { BadgeParams, RepoContribution, ExtendedContributionData } from '@/types'; import { themes } from '@/lib/svg/themes'; import { streakParamsSchema } from '@/lib/validations'; import { sanitizeHexColor, sanitizeRadius } from '@/lib/svg/sanitizer'; @@ -111,7 +107,13 @@ export async function GET(request: Request) { badges, entrance, } = parseResult.data; - const normalizedView = view as 'default' | 'monthly' | 'heatmap' | 'pulse' | 'languages'; + const normalizedView = view as + | 'default' + | 'monthly' + | 'heatmap' + | 'pulse' + | 'languages' + | 'constellation'; const themeName = theme || 'dark'; // Treat either ?refresh=true or ?bypassCache=true as a cache-bypass request @@ -207,7 +209,7 @@ export async function GET(request: Request) { accent: isAutoTheme ? selectedTheme.accent : accent || selectedTheme.accent, border: sanitizedBorder, radius, - speed: speed && /^(?:[2-9]|1\d|20)s$/.test(speed) ? speed : '8s', + speed, scale, font, autoTheme: isAutoTheme, @@ -426,6 +428,9 @@ export async function GET(request: Request) { // even though the sparkline generator will extract its own daily 30-day timeline below. const stats = calculateStreak(calendar, timezone, undefined, grace); svg = generatePulseSVG(stats, params, calendar); + } else if (normalizedView === 'constellation') { + const stats = calculateStreak(calendar, timezone, undefined, grace); + svg = generateConstellationSVG(stats, params, calendar); } else if (versus && versusCalendar) { const stats1 = calculateStreak(calendar, timezone, undefined, grace); const stats2 = calculateStreak(versusCalendar, timezone, undefined, grace); diff --git a/app/compare/CompareClient.accessibility.test.tsx b/app/compare/CompareClient.accessibility.test.tsx index 6af1cf0d9..bd2062f13 100644 --- a/app/compare/CompareClient.accessibility.test.tsx +++ b/app/compare/CompareClient.accessibility.test.tsx @@ -20,8 +20,25 @@ vi.mock('framer-motion', () => ({ {}, { get: (_, tag) => { - return ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => - React.createElement(tag as string, props, children); + return ({ + children, + animate, + initial, + exit, + transition, + variants, + whileHover, + whileTap, + whileFocus, + whileDrag, + whileInView, + layout, + layoutId, + ...props + }: { + children?: ReactNode; + [key: string]: unknown; + }) => React.createElement(tag as string, props, children); }, } ), diff --git a/app/compare/CompareClient.empty-fallback.test.tsx b/app/compare/CompareClient.empty-fallback.test.tsx index 7ba2c24d9..5e722e98c 100644 --- a/app/compare/CompareClient.empty-fallback.test.tsx +++ b/app/compare/CompareClient.empty-fallback.test.tsx @@ -23,6 +23,8 @@ vi.mock('framer-motion', () => ({ delete props.whileInView; delete props.whileHover; delete props.whileTap; + delete props.whileHover; + delete props.whileInView; delete props.viewport; delete props.transition; return
{children}
; @@ -32,6 +34,8 @@ vi.mock('framer-motion', () => ({ delete props.animate; delete props.whileHover; delete props.whileTap; + delete props.whileHover; + delete props.whileInView; delete props.transition; return (