mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-21 17:44:11 +00:00
🗜️ fix: Bound Conversation Page Size (#15576)
`GET /api/convos` read its page size as `parseInt(req.query.limit, 10) || 25` and handed it to `getConvosByCursor` unclamped, where it becomes `.limit(limit + 1)`. `?limit=-1` therefore issued `.limit(0)`, which MongoDB reads as no limit at all, and the route returned the caller's entire conversation list in one response; `?limit=999999999` forced the same unbounded fetch. `attachSharedFlags` then amplified it with a second query whose `$in` carried every returned id. Every other list route already clamps, so the fix reuses that shape: the projects handler's local `normalizeLimit` moves to `packages/api/src/utils/query` and both routes share it, and `getConvosByCursor` bounds its own page size so the invariant holds for any caller.
This commit is contained in:
parent
374ed8df14
commit
78b320cbbc
9 changed files with 246 additions and 22 deletions
|
|
@ -36,6 +36,15 @@ module.exports = {
|
|||
}
|
||||
return false;
|
||||
}),
|
||||
/** Mirrors the real helper so the route's page-size clamping is exercised. */
|
||||
normalizeLimit: jest.fn((value, { fallback = 25, max = 100 } = {}) => {
|
||||
const raw = Array.isArray(value) ? value[0] : value;
|
||||
const limit = parseInt(typeof raw === 'string' ? raw : '', 10);
|
||||
if (!Number.isFinite(limit)) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.min(Math.max(limit, 1), max);
|
||||
}),
|
||||
resolveImportMaxFileSize: jest.fn(() => 262144000),
|
||||
createAxiosInstance: jest.fn(() => ({
|
||||
get: jest.fn(),
|
||||
|
|
|
|||
|
|
@ -1417,6 +1417,73 @@ describe('Convos Routes', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('GET / limit clamping', () => {
|
||||
const { getConvosByCursor } = require('~/models');
|
||||
|
||||
beforeEach(() => {
|
||||
getConvosByCursor.mockResolvedValue({ conversations: [], nextCursor: null });
|
||||
});
|
||||
|
||||
/** `parseInt('-1') || 25` kept the -1, and `.limit(-1 + 1)` is `.limit(0)`, which
|
||||
* MongoDB reads as "no limit" — one request drained the caller's whole list. */
|
||||
it('clamps a negative limit instead of forwarding it', async () => {
|
||||
const response = await request(app).get('/api/convos').query({ limit: '-1' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(getConvosByCursor).toHaveBeenCalledWith(
|
||||
'test-user-123',
|
||||
expect.objectContaining({ limit: 1 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('caps an oversized limit at the page ceiling', async () => {
|
||||
const response = await request(app).get('/api/convos').query({ limit: '999999999' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(getConvosByCursor).toHaveBeenCalledWith(
|
||||
'test-user-123',
|
||||
expect.objectContaining({ limit: 100 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('raises a zero limit to a single row', async () => {
|
||||
const response = await request(app).get('/api/convos').query({ limit: '0' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(getConvosByCursor).toHaveBeenCalledWith(
|
||||
'test-user-123',
|
||||
expect.objectContaining({ limit: 1 }),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['absent', undefined],
|
||||
['non-numeric', 'all'],
|
||||
])('falls back to the default page size when the limit is %s', async (_label, limit) => {
|
||||
const response = await request(app)
|
||||
.get('/api/convos')
|
||||
.query(limit === undefined ? {} : { limit });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(getConvosByCursor).toHaveBeenCalledWith(
|
||||
'test-user-123',
|
||||
expect.objectContaining({ limit: 25 }),
|
||||
);
|
||||
});
|
||||
|
||||
/** Express parses a repeated key into an array, which `parseInt` silently reads as
|
||||
* its first element's digits rather than rejecting. */
|
||||
it('clamps a repeated limit parameter', async () => {
|
||||
const response = await request(app).get('/api/convos?limit=999999&limit=5');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(getConvosByCursor).toHaveBeenCalledWith(
|
||||
'test-user-123',
|
||||
expect.objectContaining({ limit: 100 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /archive', () => {
|
||||
it('should archive a conversation successfully', async () => {
|
||||
const mockConversationId = 'conv-123';
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ const express = require('express');
|
|||
const { sleep } = require('@librechat/agents');
|
||||
const {
|
||||
isEnabled,
|
||||
normalizeLimit,
|
||||
deleteAgentCheckpoints,
|
||||
createArchiveAllHandler,
|
||||
createSubagentActivityStreamHandler,
|
||||
|
|
@ -141,7 +142,7 @@ const isValidProjectFilter = (projectId) =>
|
|||
!projectId || projectId === 'unassigned' || /^[a-f\d]{24}$/i.test(projectId);
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const limit = parseInt(req.query.limit, 10) || 25;
|
||||
const limit = normalizeLimit(req.query.limit);
|
||||
const cursor = req.query.cursor;
|
||||
const isArchived = isEnabled(req.query.isArchived);
|
||||
const pinned = isEnabled(req.query.pinned);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue