From 3c0489297e3f9641825c381c7e0d02cc92ea9bd6 Mon Sep 17 00:00:00 2001 From: Jorel97 <83238249+Jorel97@users.noreply.github.com> Date: Fri, 29 May 2026 14:01:18 -0600 Subject: [PATCH 1/2] fix(feed): clamp public user feed pagination --- .../api/users/[username]/feed/route.test.ts | 85 +++++++++++++++++++ src/app/api/users/[username]/feed/route.ts | 17 +++- 2 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 src/app/api/users/[username]/feed/route.test.ts diff --git a/src/app/api/users/[username]/feed/route.test.ts b/src/app/api/users/[username]/feed/route.test.ts new file mode 100644 index 00000000..86cc0378 --- /dev/null +++ b/src/app/api/users/[username]/feed/route.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +const mockFrom = vi.fn(); + +vi.mock("@/lib/supabase/server", () => ({ + createClient: vi.fn(() => Promise.resolve({ from: mockFrom })), +})); + +import { GET } from "./route"; + +function makeRequest(params: Record = {}) { + const url = new URL("http://localhost/api/users/alice/feed"); + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value); + } + return new NextRequest(url.toString(), { method: "GET" }); +} + +function makeProfileChain() { + return { + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + single: vi.fn().mockResolvedValue({ + data: { id: "user-1" }, + error: null, + }), + }; +} + +function makeListChain(data: unknown[] = []) { + return { + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + order: vi.fn().mockReturnThis(), + range: vi.fn().mockResolvedValue({ data }), + in: vi.fn().mockResolvedValue({ data }), + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("GET /api/users/[username]/feed", () => { + it("defaults invalid pagination params before applying ranges", async () => { + const profileChain = makeProfileChain(); + const postsChain = makeListChain([]); + const commentsChain = makeListChain([]); + mockFrom + .mockReturnValueOnce(profileChain) + .mockReturnValueOnce(postsChain) + .mockReturnValueOnce(commentsChain); + + const res = await GET(makeRequest({ limit: "abc", offset: "-10" }), { + params: Promise.resolve({ username: "alice" }), + }); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(postsChain.range).toHaveBeenCalledWith(0, 19); + expect(commentsChain.range).toHaveBeenCalledWith(0, 19); + expect(body.pagination).toEqual({ total: 0, limit: 20, offset: 0 }); + }); + + it("truncates fractional params and caps high limits", async () => { + const profileChain = makeProfileChain(); + const postsChain = makeListChain([]); + const commentsChain = makeListChain([]); + mockFrom + .mockReturnValueOnce(profileChain) + .mockReturnValueOnce(postsChain) + .mockReturnValueOnce(commentsChain); + + const res = await GET(makeRequest({ limit: "250.7", offset: "3.9" }), { + params: Promise.resolve({ username: "alice" }), + }); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(postsChain.range).toHaveBeenCalledWith(0, 52); + expect(commentsChain.range).toHaveBeenCalledWith(0, 52); + expect(body.pagination).toEqual({ total: 0, limit: 50, offset: 3 }); + }); +}); diff --git a/src/app/api/users/[username]/feed/route.ts b/src/app/api/users/[username]/feed/route.ts index c79036e0..484c5a72 100644 --- a/src/app/api/users/[username]/feed/route.ts +++ b/src/app/api/users/[username]/feed/route.ts @@ -1,6 +1,19 @@ import { NextRequest, NextResponse } from "next/server"; import { createClient } from "@/lib/supabase/server"; +function parsePaginationParam( + value: string | null, + defaultValue: number, + min: number, + max: number +) { + const parsed = Number(value && value.trim() !== "" ? value : defaultValue); + if (!Number.isFinite(parsed)) { + return defaultValue; + } + return Math.min(Math.max(Math.trunc(parsed), min), max); +} + // GET /api/users/:username/feed - Public posts + comments by user export async function GET( request: NextRequest, @@ -9,8 +22,8 @@ export async function GET( try { const { username } = await params; const searchParams = request.nextUrl.searchParams; - const limit = Math.min(parseInt(searchParams.get("limit") || "20"), 50); - const offset = parseInt(searchParams.get("offset") || "0"); + const limit = parsePaginationParam(searchParams.get("limit"), 20, 1, 50); + const offset = parsePaginationParam(searchParams.get("offset"), 0, 0, 100_000); const supabase = await createClient(); From f4d925ff11795b872fe583a22e93455da5cc7043 Mon Sep 17 00:00:00 2001 From: Jorel97 <83238249+Jorel97@users.noreply.github.com> Date: Fri, 29 May 2026 14:05:50 -0600 Subject: [PATCH 2/2] test(feed): cover zero public feed limit --- .../api/users/[username]/feed/route.test.ts | 20 +++++++++++++++++++ src/app/api/users/[username]/feed/route.ts | 6 ++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/app/api/users/[username]/feed/route.test.ts b/src/app/api/users/[username]/feed/route.test.ts index 86cc0378..19b25453 100644 --- a/src/app/api/users/[username]/feed/route.test.ts +++ b/src/app/api/users/[username]/feed/route.test.ts @@ -82,4 +82,24 @@ describe("GET /api/users/[username]/feed", () => { expect(commentsChain.range).toHaveBeenCalledWith(0, 52); expect(body.pagination).toEqual({ total: 0, limit: 50, offset: 3 }); }); + + it("clamps zero limits to the minimum page size", async () => { + const profileChain = makeProfileChain(); + const postsChain = makeListChain([]); + const commentsChain = makeListChain([]); + mockFrom + .mockReturnValueOnce(profileChain) + .mockReturnValueOnce(postsChain) + .mockReturnValueOnce(commentsChain); + + const res = await GET(makeRequest({ limit: "0", offset: "" }), { + params: Promise.resolve({ username: "alice" }), + }); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(postsChain.range).toHaveBeenCalledWith(0, 0); + expect(commentsChain.range).toHaveBeenCalledWith(0, 0); + expect(body.pagination).toEqual({ total: 0, limit: 1, offset: 0 }); + }); }); diff --git a/src/app/api/users/[username]/feed/route.ts b/src/app/api/users/[username]/feed/route.ts index 484c5a72..8a21b1bc 100644 --- a/src/app/api/users/[username]/feed/route.ts +++ b/src/app/api/users/[username]/feed/route.ts @@ -8,10 +8,8 @@ function parsePaginationParam( max: number ) { const parsed = Number(value && value.trim() !== "" ? value : defaultValue); - if (!Number.isFinite(parsed)) { - return defaultValue; - } - return Math.min(Math.max(Math.trunc(parsed), min), max); + const finiteValue = Number.isFinite(parsed) ? parsed : defaultValue; + return Math.min(Math.max(Math.trunc(finiteValue), min), max); } // GET /api/users/:username/feed - Public posts + comments by user