From 2a554ecb8a4a15b39c8d26f58f476d6061a88e5f Mon Sep 17 00:00:00 2001 From: Sarthak-Nayak Date: Mon, 25 May 2026 14:30:56 +0530 Subject: [PATCH 1/4] test: add unit tests for safeCompare timing-safe comparison --- src/app/api/webhooks/github/route.test.ts | 52 +++++++++++++++++++++++ src/app/api/webhooks/github/route.ts | 2 +- 2 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 src/app/api/webhooks/github/route.test.ts diff --git a/src/app/api/webhooks/github/route.test.ts b/src/app/api/webhooks/github/route.test.ts new file mode 100644 index 000000000..7dabed810 --- /dev/null +++ b/src/app/api/webhooks/github/route.test.ts @@ -0,0 +1,52 @@ +import crypto from "crypto"; +import { describe, it, expect, beforeEach, afterEach, vi, type MockInstance } from "vitest"; +import { safeCompare } from "./route"; + +describe("safeCompare", () => { + let timingSafeEqualSpy: MockInstance; + + beforeEach(() => { + // Vitest uses vi.spyOn to monitor module methods + timingSafeEqualSpy = vi.spyOn(crypto, "timingSafeEqual"); + }); + + afterEach(() => { + // Clear mock data and restore original implementation + timingSafeEqualSpy.mockRestore(); + }); + + // Test Case 1: Early return optimization on length mismatch + it("should return false immediately and not call timingSafeEqual when lengths differ", () => { + const result = safeCompare("short", "muchlongerstring"); + + expect(result).toBe(false); + expect(timingSafeEqualSpy).not.toHaveBeenCalled(); + }); + + // Test Case 2: Standard identical buffers + it("should return true and call timingSafeEqual when buffers are identical", () => { + const stringA = "secure_token_123"; + const stringB = "secure_token_123"; + + const result = safeCompare(stringA, stringB); + + expect(result).toBe(true); + expect(timingSafeEqualSpy).toHaveBeenCalledTimes(1); + }); + + // Test Case 3: Handles empty strings safely + it("should handle empty strings correctly without throwing exceptions", () => { + const result = safeCompare("", ""); + + expect(result).toBe(true); + expect(timingSafeEqualSpy).toHaveBeenCalledTimes(1); + }); + + // Test Case 4: Identical length but different content (Should trigger full timing check) + it("should return false and call timingSafeEqual when lengths match but content differs", () => { + const result = safeCompare("abc", "xyz"); + + expect(result).toBe(false); + expect(timingSafeEqualSpy).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file diff --git a/src/app/api/webhooks/github/route.ts b/src/app/api/webhooks/github/route.ts index 25dea87c8..ff49a1841 100644 --- a/src/app/api/webhooks/github/route.ts +++ b/src/app/api/webhooks/github/route.ts @@ -27,7 +27,7 @@ function getExpectedSignature(secret: string, body: string): string { return `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`; } -function safeCompare(a: string, b: string): boolean { +export function safeCompare(a: string, b: string): boolean { const left = Buffer.from(a, "utf8"); const right = Buffer.from(b, "utf8"); From 60231e316cf6567011ffa98f57492165803f31ce Mon Sep 17 00:00:00 2001 From: Sarthak-Nayak Date: Tue, 26 May 2026 12:41:57 +0530 Subject: [PATCH 2/4] refactor: remove hardcoded data window strings in dashboard components --- src/app/u/[username]/page.tsx | 4 +++- src/components/ActivityRingChart.tsx | 7 ++++--- src/components/CommitTimeChart.tsx | 7 ++++--- src/components/LocalCodingTime.tsx | 13 +++++++------ src/components/RepoHealthPanel.tsx | 3 ++- src/components/StreakTracker.tsx | 5 +++-- src/components/TopRepos.tsx | 7 ++++--- 7 files changed, 27 insertions(+), 19 deletions(-) diff --git a/src/app/u/[username]/page.tsx b/src/app/u/[username]/page.tsx index 4ad8b0751..e8a69381f 100644 --- a/src/app/u/[username]/page.tsx +++ b/src/app/u/[username]/page.tsx @@ -10,6 +10,8 @@ import { type PublicProfileData, } from "@/lib/public-profile-data"; +const DATA_WINDOW_DAYS = 90; + async function fetchPublicProfile( username: string ): Promise { @@ -242,7 +244,7 @@ function PublicStreakTracker({ streak }: { streak: any }) { icon: "🏆", }, { - label: "Active Days (90d)", + label: `Active Days (${DATA_WINDOW_DAYS}d)`, value: streak.totalActiveDays, unit: "days", highlight: false, diff --git a/src/components/ActivityRingChart.tsx b/src/components/ActivityRingChart.tsx index 2b5a125c2..adf7bf431 100644 --- a/src/components/ActivityRingChart.tsx +++ b/src/components/ActivityRingChart.tsx @@ -19,6 +19,7 @@ export default function ActivityRingChart() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [days, setDays] = useState(30); + const WINDOW_OPTIONS = [7, 30, 90]; const [hoveredHour, setHoveredHour] = useState(null); const [animated, setAnimated] = useState(false); const prefersReduced = useRef( @@ -91,9 +92,9 @@ export default function ActivityRingChart() { onChange={(e) => setDays(Number(e.target.value))} className="rounded-lg border border-[var(--border)] bg-[var(--card)] px-2 py-1 text-sm text-[var(--card-foreground)] focus:outline-none focus:border-[var(--accent)]" > - - - + {WINDOW_OPTIONS.map((n) => ( + + ))} diff --git a/src/components/CommitTimeChart.tsx b/src/components/CommitTimeChart.tsx index d8587332d..ee7878d46 100644 --- a/src/components/CommitTimeChart.tsx +++ b/src/components/CommitTimeChart.tsx @@ -25,6 +25,7 @@ export default function CommitTimeChart() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [days, setDays] = useState(30); + const WINDOW_OPTIONS = [7, 30, 90]; const [peakTime, setPeakTime] = useState(null); const fetchContributions = useCallback(() => { @@ -98,9 +99,9 @@ export default function CommitTimeChart() { onChange={(e) => setDays(Number(e.target.value))} className="rounded-lg border border-[var(--border)] bg-[var(--control)] px-2 py-1 text-sm text-[var(--card-foreground)] focus:outline-none focus:border-[var(--accent)]" > - - - + {WINDOW_OPTIONS.map((n) => ( + + ))} diff --git a/src/components/LocalCodingTime.tsx b/src/components/LocalCodingTime.tsx index 3b88f0b3c..9af6a0a58 100644 --- a/src/components/LocalCodingTime.tsx +++ b/src/components/LocalCodingTime.tsx @@ -39,6 +39,7 @@ export default function LocalCodingTime() { } | null>(null); const [loading, setLoading] = useState(true); const [days, setDays] = useState(30); + const WINDOW_OPTIONS = [7, 30, 90]; useEffect(() => { async function loadStats() { @@ -82,9 +83,9 @@ export default function LocalCodingTime() { onChange={(e) => setDays(Number(e.target.value))} className="bg-[var(--control)] border border-[var(--border)] rounded px-2 py-1 text-sm text-[var(--foreground)]" > - - - + {WINDOW_OPTIONS.map((n) => ( + + ))}
@@ -125,9 +126,9 @@ export default function LocalCodingTime() { onChange={(e) => setDays(Number(e.target.value))} className="bg-[var(--control)] border border-[var(--border)] rounded px-2 py-1 text-sm text-[var(--foreground)]" > - - - + {WINDOW_OPTIONS.map((n) => ( + + ))}
diff --git a/src/components/RepoHealthPanel.tsx b/src/components/RepoHealthPanel.tsx index 36b10d9cc..0b7bead2b 100644 --- a/src/components/RepoHealthPanel.tsx +++ b/src/components/RepoHealthPanel.tsx @@ -2,6 +2,7 @@ import { useEffect } from "react"; import type { RepoHealthScore } from "@/types/repo-health"; +const DATA_WINDOW_DAYS = 30; interface Props { health: RepoHealthScore; isOpen: boolean; @@ -92,7 +93,7 @@ export default function RepoHealthPanel({ health, isOpen, onClose }: Props) { ))}

- Score based on activity in the last 30 days. Updates on page refresh. + {`Score based on activity in the last ${DATA_WINDOW_DAYS} days. Updates on page refresh.`}

diff --git a/src/components/StreakTracker.tsx b/src/components/StreakTracker.tsx index 03ef1aa12..d17b6891b 100644 --- a/src/components/StreakTracker.tsx +++ b/src/components/StreakTracker.tsx @@ -7,6 +7,7 @@ import { useHeatmapTheme } from "@/hooks/useHeatmapTheme"; import { toast } from "sonner"; import { toPng } from "html-to-image"; +const DATA_WINDOW_DAYS = 90; const STREAK_MILESTONES = [7, 30, 50, 100, 200, 365]; interface StreakData { @@ -285,12 +286,12 @@ export default function StreakTracker() { tooltip: "Your longest streak ever", }, { - label: "Active Days (90d)", + label: `Active Days (${DATA_WINDOW_DAYS}d)`, value: animatedActiveDays, unit: "days", highlight: false, icon: "📅", - tooltip: "Days you made commits in the last 90 days", + tooltip: `Days you made commits in the last ${DATA_WINDOW_DAYS} days`, }, { label: "Last Commit", diff --git a/src/components/TopRepos.tsx b/src/components/TopRepos.tsx index 4f7a59158..3a6d4b5d6 100644 --- a/src/components/TopRepos.tsx +++ b/src/components/TopRepos.tsx @@ -77,6 +77,7 @@ export default function TopRepos() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [days, setDays] = useState(30); + const WINDOW_OPTIONS = [7, 30, 90]; const [lastUpdated, setLastUpdated] = useState(null); const [minutesAgo, setMinutesAgo] = useState(0); const [healthScores, setHealthScores] = useState>({}); @@ -222,9 +223,9 @@ export default function TopRepos() { aria-label="Select time range for top repositories" className="rounded-lg border border-[var(--border)] bg-[var(--control)] px-2 py-1 text-sm text-[var(--card-foreground)] focus:outline-none focus:border-[var(--accent)]" > - - - + {WINDOW_OPTIONS.map((n) => ( + + ))} {loading ? ( From 47d548c8efd73e6f8ab2babd22cf6e0791f377f2 Mon Sep 17 00:00:00 2001 From: Sarthak-Nayak Date: Thu, 28 May 2026 17:33:38 +0530 Subject: [PATCH 3/4] Delete src/app/api/webhooks/github/route.ts --- src/app/api/webhooks/github/route.ts | 162 --------------------------- 1 file changed, 162 deletions(-) delete mode 100644 src/app/api/webhooks/github/route.ts diff --git a/src/app/api/webhooks/github/route.ts b/src/app/api/webhooks/github/route.ts deleted file mode 100644 index 5cc31d5c1..000000000 --- a/src/app/api/webhooks/github/route.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { createHmac } from "crypto"; -import { revalidatePath } from "next/cache"; -import { NextRequest, NextResponse } from "next/server"; -import { supabaseAdmin } from "@/lib/supabase"; -import { safeCompare } from "./safe-compare"; -import { logError } from "@/lib/error-handler"; - -export const dynamic = "force-dynamic"; - -const SIGNATURE_HEADER = "x-hub-signature-256"; -const GITHUB_EVENT_HEADER = "x-github-event"; - -interface GitHubPushPayload { - after?: string; - commits?: Array; - pusher?: { - name?: string; - }; - repository?: { - full_name?: string; - }; - sender?: { - login?: string; - }; -} - -function getExpectedSignature(secret: string, body: string): string { - return `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`; -} - - -function verifyGitHubSignature( - body: string, - signature: string | null, - secret: string -): boolean { - if (!signature?.startsWith("sha256=")) { - return false; - } - - return safeCompare(signature, getExpectedSignature(secret, body)); -} - -function getPushActor(payload: GitHubPushPayload): string | null { - return payload.sender?.login ?? payload.pusher?.name ?? null; -} - -async function markUserMetricsStale(githubLogin: string) { - const updatedAt = new Date().toISOString(); - - const { data: primaryUser, error: primaryError } = await supabaseAdmin - .from("users") - .update({ updated_at: updatedAt }) - .eq("github_login", githubLogin) - .select("id") - .maybeSingle(); - - if (primaryError) { - throw primaryError; - } - - if (primaryUser) { - return { userId: primaryUser.id as string, accountType: "primary" }; - } - - const { data: linkedAccount, error: linkedError } = await supabaseAdmin - .from("user_github_accounts") - .select("user_id") - .eq("github_login", githubLogin) - .maybeSingle(); - - if (linkedError) { - throw linkedError; - } - - if (!linkedAccount?.user_id) { - return null; - } - - const { error: updateError } = await supabaseAdmin - .from("users") - .update({ updated_at: updatedAt }) - .eq("id", linkedAccount.user_id); - - if (updateError) { - throw updateError; - } - - return { userId: linkedAccount.user_id as string, accountType: "linked" }; -} - -export async function POST(req: NextRequest) { - const secret = process.env.GITHUB_WEBHOOK_SECRET; - - if (!secret) { - return NextResponse.json( - { error: "GitHub webhook secret is not configured" }, - { status: 500 } - ); - } - - const body = await req.text(); - const signature = req.headers.get(SIGNATURE_HEADER); - - if (!verifyGitHubSignature(body, signature, secret)) { - return NextResponse.json({ error: "Invalid signature" }, { status: 401 }); - } - - const event = req.headers.get(GITHUB_EVENT_HEADER); - if (event !== "push") { - return NextResponse.json({ received: true, ignored: true, event }); - } - - let payload: GitHubPushPayload; - try { - payload = JSON.parse(body) as GitHubPushPayload; - } catch { - return NextResponse.json({ error: "Invalid JSON payload" }, { status: 400 }); - } - - const githubLogin = getPushActor(payload); - if (!githubLogin) { - return NextResponse.json( - { received: true, userMatched: false, reason: "Missing GitHub actor" }, - { status: 200 } - ); - } - - let staleResult: Awaited>; - try { - staleResult = await markUserMetricsStale(githubLogin); - } catch (error) { - logError(error, { - endpoint: "/api/webhooks/github", - operation: "mark_metrics_stale", - userId: githubLogin, - additionalContext: { - repository: (payload.repository?.full_name), - commitCount: payload.commits?.length, - }, - }); - return NextResponse.json( - { error: "Failed to trigger metric refresh" }, - { status: 500 } - ); - } - - if (staleResult) { - revalidatePath(`/u/${githubLogin}`); - revalidatePath("/dashboard"); - } - - return NextResponse.json({ - received: true, - userMatched: Boolean(staleResult), - accountType: staleResult?.accountType ?? null, - githubLogin, - repository: payload.repository?.full_name ?? null, - after: payload.after ?? null, - commitCount: payload.commits?.length ?? 0, - }); -} From ee72eac1584862b10ba794e8319e1b03b98becb9 Mon Sep 17 00:00:00 2001 From: Sarthak-Nayak Date: Thu, 28 May 2026 17:34:00 +0530 Subject: [PATCH 4/4] Delete src/app/api/webhooks/github/route.test.ts --- src/app/api/webhooks/github/route.test.ts | 52 ----------------------- 1 file changed, 52 deletions(-) delete mode 100644 src/app/api/webhooks/github/route.test.ts diff --git a/src/app/api/webhooks/github/route.test.ts b/src/app/api/webhooks/github/route.test.ts deleted file mode 100644 index 54a5cb60d..000000000 --- a/src/app/api/webhooks/github/route.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import crypto from "crypto"; -import { describe, it, expect, beforeEach, afterEach, vi, type MockInstance } from "vitest"; -import { safeCompare } from "./safe-compare"; - -describe("safeCompare", () => { - let timingSafeEqualSpy: MockInstance; - - beforeEach(() => { - // Vitest uses vi.spyOn to monitor module methods - timingSafeEqualSpy = vi.spyOn(crypto, "timingSafeEqual"); - }); - - afterEach(() => { - // Clear mock data and restore original implementation - timingSafeEqualSpy.mockRestore(); - }); - - // Test Case 1: Early return optimization on length mismatch - it("should return false immediately and not call timingSafeEqual when lengths differ", () => { - const result = safeCompare("short", "muchlongerstring"); - - expect(result).toBe(false); - expect(timingSafeEqualSpy).not.toHaveBeenCalled(); - }); - - // Test Case 2: Standard identical buffers - it("should return true and call timingSafeEqual when buffers are identical", () => { - const stringA = "secure_token_123"; - const stringB = "secure_token_123"; - - const result = safeCompare(stringA, stringB); - - expect(result).toBe(true); - expect(timingSafeEqualSpy).toHaveBeenCalledTimes(1); - }); - - // Test Case 3: Handles empty strings safely - it("should handle empty strings correctly without throwing exceptions", () => { - const result = safeCompare("", ""); - - expect(result).toBe(true); - expect(timingSafeEqualSpy).toHaveBeenCalledTimes(1); - }); - - // Test Case 4: Identical length but different content (Should trigger full timing check) - it("should return false and call timingSafeEqual when lengths match but content differs", () => { - const result = safeCompare("abc", "xyz"); - - expect(result).toBe(false); - expect(timingSafeEqualSpy).toHaveBeenCalledTimes(1); - }); -}); \ No newline at end of file