From 465b78411a95231174d54dc209dc4601937c6706 Mon Sep 17 00:00:00 2001 From: jubnl Date: Thu, 16 Apr 2026 19:49:08 +0200 Subject: [PATCH] fix(synology): resolve pagination offset using correct size before computing page offset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `size` → `limit` assignment was evaluated after `page * limit`, causing the offset to be computed using the hardcoded default (100) instead of the caller-supplied page size. Swapping the two `if` blocks ensures `limit` is resolved from `size` first so the offset is always `(page-1) * size`. Adds SYNO-025 and SYNO-026 integration tests that capture the raw Synology API body and assert `offset` and `limit` are forwarded correctly. --- server/src/routes/memories/synology.ts | 4 +- .../integration/memories-synology.test.ts | 77 +++++++++++++++++++ 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/server/src/routes/memories/synology.ts b/server/src/routes/memories/synology.ts index 5cfaa57b..52d4d5fe 100644 --- a/server/src/routes/memories/synology.ts +++ b/server/src/routes/memories/synology.ts @@ -100,8 +100,8 @@ router.post('/search', authenticate, async (req: Request, res: Response) => { const page = _parseNumberBodyField(body.page, 1) - 1; let limit = _parseNumberBodyField(body.limit, 100); const size = _parseNumberBodyField(body.size, 0); - if(page > 0) offset = page*limit; - if(size > 0) limit = size; + if (size > 0) limit = size; + if (page > 0) offset = page * limit; handleServiceResult(res, await searchSynologyPhotos( authReq.user.id, diff --git a/server/tests/integration/memories-synology.test.ts b/server/tests/integration/memories-synology.test.ts index ce390f82..001a1c27 100644 --- a/server/tests/integration/memories-synology.test.ts +++ b/server/tests/integration/memories-synology.test.ts @@ -843,6 +843,83 @@ describe('Synology searchSynologyPhotos date range', () => { }); }); +// ── Search pagination ───────────────────────────────────────────────────────── + +describe('Synology search pagination', () => { + it('SYNO-025 — POST /search with { page: 2, size: 50 } sends offset=50 and limit=50 to Synology API', async () => { + const { user } = createUser(testDb); + setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass'); + + let capturedBody: URLSearchParams | null = null; + vi.mocked(safeFetch) + .mockResolvedValueOnce({ + // login + ok: true, status: 200, + headers: { get: () => 'application/json' }, + json: async () => ({ success: true, data: { sid: 'fake-sid' } }), + body: null, + } as any) + .mockImplementationOnce((_url: string, init?: any) => { + capturedBody = init?.body instanceof URLSearchParams + ? init.body + : new URLSearchParams(String(init?.body ?? '')); + return Promise.resolve({ + ok: true, status: 200, + headers: { get: () => 'application/json' }, + json: async () => ({ success: true, data: { list: [] } }), + body: null, + } as any); + }); + + const res = await request(app) + .post(`${SYNO}/search`) + .set('Cookie', authCookie(user.id)) + .send({ page: 2, size: 50 }); + + expect(res.status).toBe(200); + expect(capturedBody).not.toBeNull(); + // With the fix: limit=50 is resolved first, then offset = (2-1)*50 = 50 + expect(capturedBody!.get('offset')).toBe('50'); + expect(capturedBody!.get('limit')).toBe('50'); + }); + + it('SYNO-026 — POST /search with { page: 3, size: 25 } sends offset=50 and limit=25 to Synology API', async () => { + const { user } = createUser(testDb); + setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass'); + + let capturedBody: URLSearchParams | null = null; + vi.mocked(safeFetch) + .mockResolvedValueOnce({ + ok: true, status: 200, + headers: { get: () => 'application/json' }, + json: async () => ({ success: true, data: { sid: 'fake-sid' } }), + body: null, + } as any) + .mockImplementationOnce((_url: string, init?: any) => { + capturedBody = init?.body instanceof URLSearchParams + ? init.body + : new URLSearchParams(String(init?.body ?? '')); + return Promise.resolve({ + ok: true, status: 200, + headers: { get: () => 'application/json' }, + json: async () => ({ success: true, data: { list: [] } }), + body: null, + } as any); + }); + + const res = await request(app) + .post(`${SYNO}/search`) + .set('Cookie', authCookie(user.id)) + .send({ page: 3, size: 25 }); + + expect(res.status).toBe(200); + expect(capturedBody).not.toBeNull(); + // page 3 → page index = 2 (after subtracting 1), offset = 2 * 25 = 50 + expect(capturedBody!.get('offset')).toBe('50'); + expect(capturedBody!.get('limit')).toBe('25'); + }); +}); + // ── SSRF catch branch in _fetchSynologyJson ──────────────────────────────────── describe('Synology SSRF blocked error handling', () => {