Skip to content

Commit 5bbb425

Browse files
Validate torrents pagination params (#106)
1 parent edffece commit 5bbb425

2 files changed

Lines changed: 146 additions & 3 deletions

File tree

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { describe, expect, it, beforeEach, vi } from 'vitest';
2+
import { NextRequest } from 'next/server';
3+
4+
const { mocks, logger } = vi.hoisted(() => {
5+
const testLogger = {
6+
child: vi.fn(),
7+
debug: vi.fn(),
8+
error: vi.fn(),
9+
info: vi.fn(),
10+
warn: vi.fn(),
11+
};
12+
testLogger.child.mockReturnValue(testLogger);
13+
14+
return {
15+
logger: testLogger,
16+
mocks: {
17+
rangeEq: vi.fn(),
18+
range: vi.fn(),
19+
order: vi.fn(),
20+
select: vi.fn(),
21+
from: vi.fn(),
22+
},
23+
};
24+
});
25+
26+
vi.mock('@/lib/logger', () => ({
27+
createLogger: vi.fn(() => logger),
28+
generateRequestId: vi.fn(() => 'request-1'),
29+
}));
30+
31+
vi.mock('@/lib/supabase/client', () => ({
32+
getServerClient: vi.fn(() => ({
33+
from: mocks.from,
34+
})),
35+
resetServerClient: vi.fn(),
36+
}));
37+
38+
vi.mock('@/lib/transforms', () => ({
39+
transformTorrents: vi.fn((rows: unknown[]) => rows),
40+
}));
41+
42+
vi.mock('@/lib/indexer', () => ({
43+
IndexerService: vi.fn(),
44+
IndexerError: class IndexerError extends Error {},
45+
}));
46+
47+
vi.mock('@/lib/metadata-enrichment', () => ({
48+
cleanTorrentNameForDisplay: vi.fn((name: string) => name),
49+
enrichTorrentMetadata: vi.fn(),
50+
}));
51+
52+
vi.mock('@/lib/codec-detection', () => ({
53+
detectCodecFromUrl: vi.fn(),
54+
formatCodecInfoForDb: vi.fn(),
55+
}));
56+
57+
import { GET } from './route';
58+
59+
function setupTorrentsQuery(): void {
60+
mocks.rangeEq.mockResolvedValue({ data: [], error: null, count: 0 });
61+
mocks.range.mockReturnValue({
62+
data: [],
63+
error: null,
64+
count: 0,
65+
eq: mocks.rangeEq,
66+
});
67+
mocks.order.mockReturnValue({ range: mocks.range });
68+
mocks.select.mockReturnValue({ order: mocks.order });
69+
mocks.from.mockReturnValue({ select: mocks.select });
70+
}
71+
72+
describe('GET /api/torrents pagination', () => {
73+
beforeEach(() => {
74+
vi.clearAllMocks();
75+
setupTorrentsQuery();
76+
});
77+
78+
it('falls back to default pagination when params are malformed', async () => {
79+
const request = new NextRequest(
80+
'http://localhost:3000/api/torrents?limit=bad&offset=wat&page=nope'
81+
);
82+
83+
const response = await GET(request);
84+
const data = await response.json();
85+
86+
expect(response.status).toBe(200);
87+
expect(mocks.range).toHaveBeenCalledWith(0, 49);
88+
expect(data.limit).toBe(50);
89+
expect(data.offset).toBe(0);
90+
expect(data.pagination).toMatchObject({
91+
page: 1,
92+
limit: 50,
93+
total: 0,
94+
hasMore: false,
95+
});
96+
});
97+
98+
it('rejects negative and fractional pagination params', async () => {
99+
const request = new NextRequest(
100+
'http://localhost:3000/api/torrents?limit=1.5&page=-2'
101+
);
102+
103+
const response = await GET(request);
104+
const data = await response.json();
105+
106+
expect(response.status).toBe(200);
107+
expect(mocks.range).toHaveBeenCalledWith(0, 49);
108+
expect(data.limit).toBe(50);
109+
expect(data.offset).toBe(0);
110+
});
111+
112+
it('caps valid limits and computes page offsets safely', async () => {
113+
const request = new NextRequest(
114+
'http://localhost:3000/api/torrents?limit=500&page=3'
115+
);
116+
117+
const response = await GET(request);
118+
const data = await response.json();
119+
120+
expect(response.status).toBe(200);
121+
expect(mocks.range).toHaveBeenCalledWith(200, 299);
122+
expect(data.limit).toBe(100);
123+
expect(data.offset).toBe(200);
124+
expect(data.pagination.page).toBe(3);
125+
});
126+
});

src/app/api/torrents/route.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,23 @@ const SORT_COLUMN_MAP: Record<SortBy, string> = {
5757
name: 'name',
5858
};
5959

60+
function parseBoundedIntegerParam(
61+
value: string | null,
62+
fallback: number,
63+
options: { min: number; max?: number }
64+
): number {
65+
if (value == null || !/^\d+$/.test(value)) {
66+
return fallback;
67+
}
68+
69+
const parsed = Number(value);
70+
if (!Number.isSafeInteger(parsed) || parsed < options.min) {
71+
return fallback;
72+
}
73+
74+
return options.max == null ? parsed : Math.min(parsed, options.max);
75+
}
76+
6077
/**
6178
* GET /api/torrents
6279
*
@@ -88,14 +105,14 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
88105
const sortByParam = searchParams.get('sortBy');
89106
const sortOrderParam = searchParams.get('sortOrder');
90107

91-
const limit = Math.min(limitParam ? parseInt(limitParam, 10) : 50, 100);
108+
const limit = parseBoundedIntegerParam(limitParam, 50, { min: 1, max: 100 });
92109

93110
// Support both offset and page-based pagination
94111
let offset: number;
95112
if (offsetParam) {
96-
offset = parseInt(offsetParam, 10);
113+
offset = parseBoundedIntegerParam(offsetParam, 0, { min: 0 });
97114
} else if (pageParam) {
98-
const page = parseInt(pageParam, 10);
115+
const page = parseBoundedIntegerParam(pageParam, 1, { min: 1 });
99116
offset = (page - 1) * limit;
100117
} else {
101118
offset = 0;

0 commit comments

Comments
 (0)