From a254b45e6ce81e7d1c766842dc48a06779bae02c Mon Sep 17 00:00:00 2001 From: Jorel97 <83238249+Jorel97@users.noreply.github.com> Date: Fri, 29 May 2026 13:36:54 -0600 Subject: [PATCH 1/2] fix(webhooks): clamp delivery pagination ranges --- .../webhooks/[id]/deliveries/route.test.ts | 87 +++++++++++++++++++ src/app/api/webhooks/[id]/deliveries/route.ts | 20 +++-- 2 files changed, 102 insertions(+), 5 deletions(-) create mode 100644 src/app/api/webhooks/[id]/deliveries/route.test.ts diff --git a/src/app/api/webhooks/[id]/deliveries/route.test.ts b/src/app/api/webhooks/[id]/deliveries/route.test.ts new file mode 100644 index 00000000..6c4672f6 --- /dev/null +++ b/src/app/api/webhooks/[id]/deliveries/route.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +const mockFrom = vi.fn(); +const mockGetAuthContext = vi.fn(); + +vi.mock("@/lib/auth/get-user", () => ({ + getAuthContext: mockGetAuthContext, +})); + +import { GET } from "./route"; + +function makeRequest(params: Record = {}) { + const url = new URL("http://localhost/api/webhooks/webhook-1/deliveries"); + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value); + } + return new NextRequest(url.toString(), { method: "GET" }); +} + +function chainResult(result: { data: unknown; error: unknown; count?: number | null }) { + const chain: Record> = {}; + for (const method of ["select", "eq", "single", "order", "range"]) { + chain[method] = vi.fn().mockReturnValue(chain); + } + chain.single.mockResolvedValue({ + data: { id: "webhook-1", user_id: "user-1" }, + error: null, + }); + chain.range.mockResolvedValue(result); + return chain; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockGetAuthContext.mockResolvedValue({ + user: { id: "user-1" }, + supabase: { from: mockFrom }, + }); +}); + +describe("GET /api/webhooks/[id]/deliveries", () => { + it("applies default pagination", async () => { + const webhookChain = chainResult({ data: null, error: null }); + const deliveriesChain = chainResult({ data: [], error: null, count: 0 }); + mockFrom.mockReturnValueOnce(webhookChain).mockReturnValueOnce(deliveriesChain); + + const res = await GET(makeRequest(), { + params: Promise.resolve({ id: "webhook-1" }), + }); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(deliveriesChain.range).toHaveBeenCalledWith(0, 49); + expect(json.pagination).toEqual({ total: 0, limit: 50, offset: 0 }); + }); + + it("clamps invalid pagination params before applying range", async () => { + const webhookChain = chainResult({ data: null, error: null }); + const deliveriesChain = chainResult({ data: [], error: null, count: 12 }); + mockFrom.mockReturnValueOnce(webhookChain).mockReturnValueOnce(deliveriesChain); + + const res = await GET(makeRequest({ limit: "abc", offset: "-5" }), { + params: Promise.resolve({ id: "webhook-1" }), + }); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(deliveriesChain.range).toHaveBeenCalledWith(0, 49); + expect(json.pagination).toEqual({ total: 12, limit: 50, offset: 0 }); + }); + + it("truncates fractional pagination params and caps high limits", async () => { + const webhookChain = chainResult({ data: null, error: null }); + const deliveriesChain = chainResult({ data: [], error: null, count: 200 }); + mockFrom.mockReturnValueOnce(webhookChain).mockReturnValueOnce(deliveriesChain); + + const res = await GET(makeRequest({ limit: "250.9", offset: "3.8" }), { + params: Promise.resolve({ id: "webhook-1" }), + }); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(deliveriesChain.range).toHaveBeenCalledWith(3, 102); + expect(json.pagination).toEqual({ total: 200, limit: 100, offset: 3 }); + }); +}); diff --git a/src/app/api/webhooks/[id]/deliveries/route.ts b/src/app/api/webhooks/[id]/deliveries/route.ts index b33ab341..e023f3c0 100644 --- a/src/app/api/webhooks/[id]/deliveries/route.ts +++ b/src/app/api/webhooks/[id]/deliveries/route.ts @@ -1,6 +1,19 @@ import { NextRequest, NextResponse } from "next/server"; import { getAuthContext } from "@/lib/auth/get-user"; +function parsePaginationParam( + value: string | null, + defaultValue: number, + min: number, + max: number +) { + const parsed = Number(value ?? defaultValue); + if (!Number.isFinite(parsed)) { + return defaultValue; + } + return Math.min(Math.max(Math.trunc(parsed), min), max); +} + // GET /api/webhooks/[id]/deliveries - View delivery logs export async function GET( request: NextRequest, @@ -34,11 +47,8 @@ export async function GET( // Parse pagination const { searchParams } = new URL(request.url); - const limit = Math.min( - parseInt(searchParams.get("limit") || "50"), - 100 - ); - const offset = parseInt(searchParams.get("offset") || "0"); + const limit = parsePaginationParam(searchParams.get("limit"), 50, 1, 100); + const offset = parsePaginationParam(searchParams.get("offset"), 0, 0, 100_000); const { data: deliveries, From 2190876d08c9a7379bb8e4ad8bef31352f814d7e Mon Sep 17 00:00:00 2001 From: Jorel97 <83238249+Jorel97@users.noreply.github.com> Date: Fri, 29 May 2026 13:42:27 -0600 Subject: [PATCH 2/2] fix(webhooks): default empty delivery pagination params --- .../api/webhooks/[id]/deliveries/route.test.ts | 15 +++++++++++++++ src/app/api/webhooks/[id]/deliveries/route.ts | 9 +++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/app/api/webhooks/[id]/deliveries/route.test.ts b/src/app/api/webhooks/[id]/deliveries/route.test.ts index 6c4672f6..0b2fc000 100644 --- a/src/app/api/webhooks/[id]/deliveries/route.test.ts +++ b/src/app/api/webhooks/[id]/deliveries/route.test.ts @@ -70,6 +70,21 @@ describe("GET /api/webhooks/[id]/deliveries", () => { expect(json.pagination).toEqual({ total: 12, limit: 50, offset: 0 }); }); + it("uses defaults for empty pagination params", async () => { + const webhookChain = chainResult({ data: null, error: null }); + const deliveriesChain = chainResult({ data: [], error: null, count: 3 }); + mockFrom.mockReturnValueOnce(webhookChain).mockReturnValueOnce(deliveriesChain); + + const res = await GET(makeRequest({ limit: "", offset: "" }), { + params: Promise.resolve({ id: "webhook-1" }), + }); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(deliveriesChain.range).toHaveBeenCalledWith(0, 49); + expect(json.pagination).toEqual({ total: 3, limit: 50, offset: 0 }); + }); + it("truncates fractional pagination params and caps high limits", async () => { const webhookChain = chainResult({ data: null, error: null }); const deliveriesChain = chainResult({ data: [], error: null, count: 200 }); diff --git a/src/app/api/webhooks/[id]/deliveries/route.ts b/src/app/api/webhooks/[id]/deliveries/route.ts index e023f3c0..a097aacd 100644 --- a/src/app/api/webhooks/[id]/deliveries/route.ts +++ b/src/app/api/webhooks/[id]/deliveries/route.ts @@ -7,7 +7,7 @@ function parsePaginationParam( min: number, max: number ) { - const parsed = Number(value ?? defaultValue); + const parsed = Number(value && value.trim() !== "" ? value : defaultValue); if (!Number.isFinite(parsed)) { return defaultValue; } @@ -48,7 +48,12 @@ export async function GET( // Parse pagination const { searchParams } = new URL(request.url); const limit = parsePaginationParam(searchParams.get("limit"), 50, 1, 100); - const offset = parsePaginationParam(searchParams.get("offset"), 0, 0, 100_000); + const offset = parsePaginationParam( + searchParams.get("offset"), + 0, + 0, + 100_000 + ); const { data: deliveries,