From a644167d148af0d8ec65a364b4d7fa172262890b Mon Sep 17 00:00:00 2001 From: Phuc Nguyen Date: Wed, 17 Jun 2026 11:02:53 +0700 Subject: [PATCH] Validate browse pagination params --- src/app/api/browse/pagination.test.ts | 104 ++++++++++++++++++++++++++ src/app/api/browse/route.ts | 21 +++++- 2 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 src/app/api/browse/pagination.test.ts diff --git a/src/app/api/browse/pagination.test.ts b/src/app/api/browse/pagination.test.ts new file mode 100644 index 00000000..b0f50285 --- /dev/null +++ b/src/app/api/browse/pagination.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +const { mocks } = vi.hoisted(() => ({ + mocks: { + range: vi.fn(), + order: vi.fn(), + eq: vi.fn(), + select: vi.fn(), + from: vi.fn(), + }, +})); + +vi.mock('@/lib/logger', () => { + const logger = { + child: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }; + logger.child.mockReturnValue(logger); + + return { + createLogger: vi.fn(() => logger), + generateRequestId: vi.fn(() => 'request-1'), + }; +}); + +vi.mock('@/lib/supabase/client', () => ({ + getServerClient: vi.fn(() => ({ + from: mocks.from, + })), + resetServerClient: vi.fn(), +})); + +vi.mock('@/lib/transforms', () => ({ + transformTorrents: vi.fn((rows: unknown[]) => rows), +})); + +vi.mock('@/lib/imdb/enrich', () => ({ + batchEnrichWithImdb: vi.fn(async (rows: unknown[]) => rows), +})); + +import { GET } from './route'; + +function setupBrowseQuery(): void { + mocks.range.mockResolvedValue({ data: [], error: null, count: 0 }); + mocks.order.mockReturnValue({ range: mocks.range }); + mocks.eq.mockReturnValue({ + ilike: vi.fn().mockReturnThis(), + eq: mocks.eq, + order: mocks.order, + }); + mocks.select.mockReturnValue({ eq: mocks.eq }); + mocks.from.mockReturnValue({ select: mocks.select }); +} + +function createRequest(path: string): NextRequest { + return new NextRequest(new URL(path, 'http://localhost:3000')); +} + +describe('GET /api/browse pagination', () => { + beforeEach(() => { + vi.clearAllMocks(); + setupBrowseQuery(); + }); + + it('falls back to default pagination when params are malformed', async () => { + const response = await GET( + createRequest('/api/browse?contentType=movie&limit=bad&offset=wat') + ); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(mocks.range).toHaveBeenCalledWith(0, 49); + expect(data.limit).toBe(50); + expect(data.offset).toBe(0); + }); + + it('rejects negative and fractional pagination params', async () => { + const response = await GET( + createRequest('/api/browse?contentType=movie&limit=1.5&offset=-10') + ); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(mocks.range).toHaveBeenCalledWith(0, 49); + expect(data.limit).toBe(50); + expect(data.offset).toBe(0); + }); + + it('caps valid limits while preserving valid offsets', async () => { + const response = await GET( + createRequest('/api/browse?contentType=movie&limit=500&offset=25') + ); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(mocks.range).toHaveBeenCalledWith(25, 124); + expect(data.limit).toBe(100); + expect(data.offset).toBe(25); + }); +}); diff --git a/src/app/api/browse/route.ts b/src/app/api/browse/route.ts index 47af0f50..ee64191d 100644 --- a/src/app/api/browse/route.ts +++ b/src/app/api/browse/route.ts @@ -44,6 +44,23 @@ const SORT_COLUMN_MAP: Record = { name: 'name', }; +function parseBoundedIntegerParam( + value: string | null, + fallback: number, + options: { min: number; max?: number } +): number { + if (value == null || !/^\d+$/.test(value)) { + return fallback; + } + + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < options.min) { + return fallback; + } + + return options.max == null ? parsed : Math.min(parsed, options.max); +} + /** * Check if an error is a network/connection error that warrants client reset */ @@ -131,8 +148,8 @@ export async function GET(request: NextRequest): Promise { // Extract pagination const limitParam = searchParams.get('limit'); const offsetParam = searchParams.get('offset'); - const limit = Math.min(limitParam ? parseInt(limitParam, 10) : 50, 100); - const offset = offsetParam ? parseInt(offsetParam, 10) : 0; + const limit = parseBoundedIntegerParam(limitParam, 50, { min: 1, max: 100 }); + const offset = parseBoundedIntegerParam(offsetParam, 0, { min: 0 }); reqLogger.info('GET /api/browse', { contentType,