From 00966e8c4802dd78ea27445e2e1293c269b15d3b Mon Sep 17 00:00:00 2001 From: Prerak Yadav Date: Tue, 9 Jun 2026 11:15:49 +0530 Subject: [PATCH 1/3] fix(api): clamp days query param in hourly contributions route Parse days as integer and clamp to 1..365, matching other metrics routes and preventing malformed input from causing 502 errors. --- .../api/metrics/contributions/hourly/route.ts | 4 +- test/contributions-hourly.test.ts | 81 +++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 test/contributions-hourly.test.ts diff --git a/src/app/api/metrics/contributions/hourly/route.ts b/src/app/api/metrics/contributions/hourly/route.ts index f4c75abc1..6c8740c62 100644 --- a/src/app/api/metrics/contributions/hourly/route.ts +++ b/src/app/api/metrics/contributions/hourly/route.ts @@ -17,7 +17,9 @@ export async function GET(req: NextRequest) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } - const days = Number(req.nextUrl.searchParams.get("days")) || 30; + const daysParam = req.nextUrl.searchParams.get("days"); + const parsedDays = daysParam ? parseInt(daysParam, 10) : NaN; + const days = isNaN(parsedDays) ? 30 : Math.max(1, Math.min(365, parsedDays)); const bypass = isMetricsCacheBypassed(req); const key = metricsCacheKey( session.githubId ?? session.githubLogin, diff --git a/test/contributions-hourly.test.ts b/test/contributions-hourly.test.ts new file mode 100644 index 000000000..91803e66a --- /dev/null +++ b/test/contributions-hourly.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; +import { GET } from "@/app/api/metrics/contributions/hourly/route"; + +const mocks = vi.hoisted(() => ({ + getServerSession: vi.fn(), + isMetricsCacheBypassed: vi.fn(() => false), + metricsCacheKey: vi.fn(() => "test-cache-key"), + withMetricsCache: vi.fn(), + fetch: vi.fn(), +})); + +vi.mock("next-auth", () => ({ getServerSession: mocks.getServerSession })); +vi.mock("@/lib/auth", () => ({ authOptions: {} })); +vi.mock("@/lib/metrics-cache", () => ({ + isMetricsCacheBypassed: mocks.isMetricsCacheBypassed, + METRICS_CACHE_TTL_SECONDS: { contributions: 3600 }, + metricsCacheKey: mocks.metricsCacheKey, + withMetricsCache: mocks.withMetricsCache, +})); + +vi.stubGlobal("fetch", mocks.fetch); + +function makeRequest(days?: string): NextRequest { + const url = + days === undefined + ? "http://localhost/api/metrics/contributions/hourly" + : `http://localhost/api/metrics/contributions/hourly?days=${encodeURIComponent(days)}`; + return new NextRequest(url); +} + +function authedSession() { + mocks.getServerSession.mockResolvedValue({ + accessToken: "gh-token", + githubLogin: "alice", + githubId: "12345", + }); +} + +describe("GET /api/metrics/contributions/hourly — days validation", () => { + beforeEach(() => { + vi.clearAllMocks(); + authedSession(); + mocks.withMetricsCache.mockImplementation(async (_opts, fn) => fn()); + mocks.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ items: [] }), + }); + }); + + it.each([ + ["-30", 1], + ["1.5", 1], + ["0", 1], + ["Infinity", 30], + ["999999", 365], + ])("clamps days=%s to %i", async (daysParam, expectedDays) => { + const res = await GET(makeRequest(daysParam)); + + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ days: expectedDays }); + expect(mocks.fetch).toHaveBeenCalled(); + }); + + it("defaults to 30 days when the parameter is missing", async () => { + const res = await GET(makeRequest()); + + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ days: 30 }); + }); + + it("uses a valid author-date in the GitHub search URL for unbounded input", async () => { + const res = await GET(makeRequest("Infinity")); + + expect(res.status).toBe(200); + const fetchUrl = String(mocks.fetch.mock.calls[0]?.[0] ?? ""); + const match = fetchUrl.match(/author-date:>=(\d{4}-\d{2}-\d{2})/); + expect(match).not.toBeNull(); + expect(new Date(match![1]).toString()).not.toBe("Invalid Date"); + }); +}); From a3c1fbc802e977704071e8979d2d0400f35b1e42 Mon Sep 17 00:00:00 2001 From: Prerak Yadav Date: Tue, 9 Jun 2026 11:55:58 +0530 Subject: [PATCH 2/3] test(e2e): fix contributions API tests using request context Replace page.evaluate fetch on about:blank with Playwright request + session cookie so relative API URLs resolve. Assert authenticated access returns JSON instead of requiring GitHub 200 with mock token. --- e2e/api.spec.ts | 82 +++++++++---------------------------------------- 1 file changed, 14 insertions(+), 68 deletions(-) diff --git a/e2e/api.spec.ts b/e2e/api.spec.ts index e8c2d9aad..3607d4317 100644 --- a/e2e/api.spec.ts +++ b/e2e/api.spec.ts @@ -51,49 +51,20 @@ test("[API E2E] /api/metrics/streak returns 401 without a session", async ({ expect([401, 302, 403]).toContain(res.status()); }); -test("[API E2E] /api/metrics/contributions returns 200 with valid session cookie", async ({ - page, +test("[API E2E] /api/metrics/contributions accepts valid session cookie", async ({ request, }) => { const sessionToken = await buildSessionCookie(); - // Add the signed cookie to the browser context. - await page.context().addCookies([ - { - name: "next-auth.session-token", - value: sessionToken, - domain: "127.0.0.1", - path: "/", - httpOnly: true, - sameSite: "Lax", - secure: false, - expires: Math.floor(Date.now() / 1000) + 60 * 60, + const res = await request.get("/api/metrics/contributions?days=7", { + headers: { + Cookie: `next-auth.session-token=${sessionToken}`, }, - ]); - - // Mock the NextAuth session verify call so the API handler resolves the user. - await page.route("**/api/auth/session**", (route) => - route.fulfill({ - contentType: "application/json", - body: JSON.stringify({ - user: { name: "Playwright User", email: "playwright@devtrack.test" }, - githubLogin: "playwright-user", - githubId: "99001", - accessToken: "mock-access-token", - expires: "2099-01-01T00:00:00.000Z", - }), - }) - ); - - // Use the same browser context's fetch so the cookie is sent. - const res = await page.evaluate(async () => { - const r = await fetch("/api/metrics/contributions?days=7"); - return { status: r.status, ok: r.ok }; }); - // With a valid session the route must respond 200. - expect(res.status).toBe(200); - expect(res.ok).toBe(true); + // Session must be accepted; upstream GitHub may return 502 with the mock token. + expect(res.status()).not.toBe(401); + expect(res.headers()["content-type"] ?? "").toContain("application/json"); }); test("[API E2E] /api/auth/session returns a JSON object", async ({ @@ -116,42 +87,17 @@ test("[API E2E] /api/goals POST without session returns 401 or 403", async ({ }); test("[API E2E] /api/metrics/contributions with days param returns valid JSON when authenticated", async ({ - page, + request, }) => { const sessionToken = await buildSessionCookie(); - await page.context().addCookies([ - { - name: "next-auth.session-token", - value: sessionToken, - domain: "127.0.0.1", - path: "/", - httpOnly: true, - sameSite: "Lax", - secure: false, - expires: Math.floor(Date.now() / 1000) + 60 * 60, + const res = await request.get("/api/metrics/contributions?days=30", { + headers: { + Cookie: `next-auth.session-token=${sessionToken}`, }, - ]); - - await page.route("**/api/auth/session**", (route) => - route.fulfill({ - contentType: "application/json", - body: JSON.stringify({ - user: { name: "Playwright User", email: "playwright@devtrack.test" }, - githubLogin: "playwright-user", - githubId: "99001", - accessToken: "mock-access-token", - expires: "2099-01-01T00:00:00.000Z", - }), - }) - ); - - const result = await page.evaluate(async () => { - const r = await fetch("/api/metrics/contributions?days=30"); - const body = await r.json(); - return { status: r.status, bodyType: typeof body }; }); - expect(result.status).toBe(200); - expect(result.bodyType).toBe("object"); + expect(res.status()).not.toBe(401); + const body = await res.json(); + expect(typeof body).toBe("object"); }); \ No newline at end of file From 3f1e17a847ae6947c34c43f4206b519516d2a6f4 Mon Sep 17 00:00:00 2001 From: Prerak Yadav Date: Tue, 9 Jun 2026 11:58:33 +0530 Subject: [PATCH 3/3] revert: remove e2e fix from hourly contributions branch The Playwright api.spec.ts fix belongs on security/x-dns-prefetch-control-on, not this branch. --- e2e/api.spec.ts | 82 ++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 68 insertions(+), 14 deletions(-) diff --git a/e2e/api.spec.ts b/e2e/api.spec.ts index 3607d4317..e8c2d9aad 100644 --- a/e2e/api.spec.ts +++ b/e2e/api.spec.ts @@ -51,20 +51,49 @@ test("[API E2E] /api/metrics/streak returns 401 without a session", async ({ expect([401, 302, 403]).toContain(res.status()); }); -test("[API E2E] /api/metrics/contributions accepts valid session cookie", async ({ +test("[API E2E] /api/metrics/contributions returns 200 with valid session cookie", async ({ + page, request, }) => { const sessionToken = await buildSessionCookie(); - const res = await request.get("/api/metrics/contributions?days=7", { - headers: { - Cookie: `next-auth.session-token=${sessionToken}`, + // Add the signed cookie to the browser context. + await page.context().addCookies([ + { + name: "next-auth.session-token", + value: sessionToken, + domain: "127.0.0.1", + path: "/", + httpOnly: true, + sameSite: "Lax", + secure: false, + expires: Math.floor(Date.now() / 1000) + 60 * 60, }, + ]); + + // Mock the NextAuth session verify call so the API handler resolves the user. + await page.route("**/api/auth/session**", (route) => + route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + user: { name: "Playwright User", email: "playwright@devtrack.test" }, + githubLogin: "playwright-user", + githubId: "99001", + accessToken: "mock-access-token", + expires: "2099-01-01T00:00:00.000Z", + }), + }) + ); + + // Use the same browser context's fetch so the cookie is sent. + const res = await page.evaluate(async () => { + const r = await fetch("/api/metrics/contributions?days=7"); + return { status: r.status, ok: r.ok }; }); - // Session must be accepted; upstream GitHub may return 502 with the mock token. - expect(res.status()).not.toBe(401); - expect(res.headers()["content-type"] ?? "").toContain("application/json"); + // With a valid session the route must respond 200. + expect(res.status).toBe(200); + expect(res.ok).toBe(true); }); test("[API E2E] /api/auth/session returns a JSON object", async ({ @@ -87,17 +116,42 @@ test("[API E2E] /api/goals POST without session returns 401 or 403", async ({ }); test("[API E2E] /api/metrics/contributions with days param returns valid JSON when authenticated", async ({ - request, + page, }) => { const sessionToken = await buildSessionCookie(); - const res = await request.get("/api/metrics/contributions?days=30", { - headers: { - Cookie: `next-auth.session-token=${sessionToken}`, + await page.context().addCookies([ + { + name: "next-auth.session-token", + value: sessionToken, + domain: "127.0.0.1", + path: "/", + httpOnly: true, + sameSite: "Lax", + secure: false, + expires: Math.floor(Date.now() / 1000) + 60 * 60, }, + ]); + + await page.route("**/api/auth/session**", (route) => + route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + user: { name: "Playwright User", email: "playwright@devtrack.test" }, + githubLogin: "playwright-user", + githubId: "99001", + accessToken: "mock-access-token", + expires: "2099-01-01T00:00:00.000Z", + }), + }) + ); + + const result = await page.evaluate(async () => { + const r = await fetch("/api/metrics/contributions?days=30"); + const body = await r.json(); + return { status: r.status, bodyType: typeof body }; }); - expect(res.status()).not.toBe(401); - const body = await res.json(); - expect(typeof body).toBe("object"); + expect(result.status).toBe(200); + expect(result.bodyType).toBe("object"); }); \ No newline at end of file