Skip to content

Commit edffece

Browse files
Validate browse pagination params (#108)
1 parent 0124f76 commit edffece

2 files changed

Lines changed: 123 additions & 2 deletions

File tree

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { NextRequest } from 'next/server';
3+
4+
const { mocks } = vi.hoisted(() => ({
5+
mocks: {
6+
range: vi.fn(),
7+
order: vi.fn(),
8+
eq: vi.fn(),
9+
select: vi.fn(),
10+
from: vi.fn(),
11+
},
12+
}));
13+
14+
vi.mock('@/lib/logger', () => {
15+
const logger = {
16+
child: vi.fn(),
17+
debug: vi.fn(),
18+
error: vi.fn(),
19+
info: vi.fn(),
20+
warn: vi.fn(),
21+
};
22+
logger.child.mockReturnValue(logger);
23+
24+
return {
25+
createLogger: vi.fn(() => logger),
26+
generateRequestId: vi.fn(() => 'request-1'),
27+
};
28+
});
29+
30+
vi.mock('@/lib/supabase/client', () => ({
31+
getServerClient: vi.fn(() => ({
32+
from: mocks.from,
33+
})),
34+
resetServerClient: vi.fn(),
35+
}));
36+
37+
vi.mock('@/lib/transforms', () => ({
38+
transformTorrents: vi.fn((rows: unknown[]) => rows),
39+
}));
40+
41+
vi.mock('@/lib/imdb/enrich', () => ({
42+
batchEnrichWithImdb: vi.fn(async (rows: unknown[]) => rows),
43+
}));
44+
45+
import { GET } from './route';
46+
47+
function setupBrowseQuery(): void {
48+
mocks.range.mockResolvedValue({ data: [], error: null, count: 0 });
49+
mocks.order.mockReturnValue({ range: mocks.range });
50+
mocks.eq.mockReturnValue({
51+
ilike: vi.fn().mockReturnThis(),
52+
eq: mocks.eq,
53+
order: mocks.order,
54+
});
55+
mocks.select.mockReturnValue({ eq: mocks.eq });
56+
mocks.from.mockReturnValue({ select: mocks.select });
57+
}
58+
59+
function createRequest(path: string): NextRequest {
60+
return new NextRequest(new URL(path, 'http://localhost:3000'));
61+
}
62+
63+
describe('GET /api/browse pagination', () => {
64+
beforeEach(() => {
65+
vi.clearAllMocks();
66+
setupBrowseQuery();
67+
});
68+
69+
it('falls back to default pagination when params are malformed', async () => {
70+
const response = await GET(
71+
createRequest('/api/browse?contentType=movie&limit=bad&offset=wat')
72+
);
73+
const data = await response.json();
74+
75+
expect(response.status).toBe(200);
76+
expect(mocks.range).toHaveBeenCalledWith(0, 49);
77+
expect(data.limit).toBe(50);
78+
expect(data.offset).toBe(0);
79+
});
80+
81+
it('rejects negative and fractional pagination params', async () => {
82+
const response = await GET(
83+
createRequest('/api/browse?contentType=movie&limit=1.5&offset=-10')
84+
);
85+
const data = await response.json();
86+
87+
expect(response.status).toBe(200);
88+
expect(mocks.range).toHaveBeenCalledWith(0, 49);
89+
expect(data.limit).toBe(50);
90+
expect(data.offset).toBe(0);
91+
});
92+
93+
it('caps valid limits while preserving valid offsets', async () => {
94+
const response = await GET(
95+
createRequest('/api/browse?contentType=movie&limit=500&offset=25')
96+
);
97+
const data = await response.json();
98+
99+
expect(response.status).toBe(200);
100+
expect(mocks.range).toHaveBeenCalledWith(25, 124);
101+
expect(data.limit).toBe(100);
102+
expect(data.offset).toBe(25);
103+
});
104+
});

src/app/api/browse/route.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,23 @@ const SORT_COLUMN_MAP: Record<SortBy, string> = {
4444
name: 'name',
4545
};
4646

47+
function parseBoundedIntegerParam(
48+
value: string | null,
49+
fallback: number,
50+
options: { min: number; max?: number }
51+
): number {
52+
if (value == null || !/^\d+$/.test(value)) {
53+
return fallback;
54+
}
55+
56+
const parsed = Number(value);
57+
if (!Number.isSafeInteger(parsed) || parsed < options.min) {
58+
return fallback;
59+
}
60+
61+
return options.max == null ? parsed : Math.min(parsed, options.max);
62+
}
63+
4764
/**
4865
* Check if an error is a network/connection error that warrants client reset
4966
*/
@@ -131,8 +148,8 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
131148
// Extract pagination
132149
const limitParam = searchParams.get('limit');
133150
const offsetParam = searchParams.get('offset');
134-
const limit = Math.min(limitParam ? parseInt(limitParam, 10) : 50, 100);
135-
const offset = offsetParam ? parseInt(offsetParam, 10) : 0;
151+
const limit = parseBoundedIntegerParam(limitParam, 50, { min: 1, max: 100 });
152+
const offset = parseBoundedIntegerParam(offsetParam, 0, { min: 0 });
136153

137154
reqLogger.info('GET /api/browse', {
138155
contentType,

0 commit comments

Comments
 (0)