/** * Coverage for the new GET /files/:file_id/preview endpoint. * * Deferred-preview code-execution flow: the immediate persist step * emits a file record at `status: 'pending'`; the background render * transitions it to `'ready'` (with text) or `'failed'` (with * previewError). The frontend polls this endpoint until status is * terminal. This suite asserts the response shape across all four * states (pending, ready, failed, legacy/back-compat) and the auth * boundary (404 vs 403). */ jest.mock('@librechat/data-schemas', () => ({ logger: { warn: jest.fn(), debug: jest.fn(), error: jest.fn(), info: jest.fn() }, SystemCapabilities: {}, })); jest.mock('@librechat/api', () => ({ refreshS3FileUrls: jest.fn(), resolveUploadErrorMessage: jest.fn(), verifyAgentUploadPermission: jest.fn(), })); const mockFindFileById = jest.fn(); const mockGetFiles = jest.fn(); const mockUpdateFile = jest.fn(); const mockGetAgents = jest.fn().mockResolvedValue([]); jest.mock('~/models', () => ({ findFileById: (...args) => mockFindFileById(...args), getFiles: (...args) => mockGetFiles(...args), updateFile: (...args) => mockUpdateFile(...args), getAgents: (...args) => mockGetAgents(...args), batchUpdateFiles: jest.fn(), })); jest.mock('~/server/services/Files/process', () => ({ filterFile: jest.fn(), processFileUpload: jest.fn(), processDeleteRequest: jest.fn().mockResolvedValue({ deletedFileIds: [], failedFileIds: [] }), processAgentFileUpload: jest.fn(), })); jest.mock('~/server/services/Files/strategies', () => ({ getStrategyFunctions: jest.fn(() => ({})), })); jest.mock('~/server/controllers/assistants/helpers', () => ({ getOpenAIClient: jest.fn(), })); jest.mock('~/server/middleware/roles/capabilities', () => ({ hasCapability: jest.fn(() => (_req, _res, next) => next()), })); jest.mock('~/server/services/PermissionService', () => ({ checkPermission: jest.fn(() => (_req, _res, next) => next()), getEffectivePermissions: jest.fn().mockResolvedValue(0), })); jest.mock('~/server/services/Files', () => ({ hasAccessToFilesViaAgent: jest.fn(), })); jest.mock('~/server/utils/files', () => ({ cleanFileName: (name) => name, getContentDisposition: (name) => `attachment; filename="${name}"`, })); jest.mock('~/cache', () => ({ getLogStores: jest.fn(() => ({ get: jest.fn(), set: jest.fn() })), })); const express = require('express'); const request = require('supertest'); const filesRouter = require('./files'); /** * Mount the router with a per-request user injector so we can simulate * a logged-in user without spinning up the full auth stack. */ function buildApp({ user = { id: 'user-123', role: 'user' } } = {}) { const app = express(); app.use(express.json()); app.use((req, _res, next) => { req.user = user; req.config = { fileStrategy: 'local' }; next(); }); app.use('/files', filesRouter); return app; } const OWNER_USER_ID = 'user-123'; describe('GET /files/:file_id/preview', () => { beforeEach(() => { mockFindFileById.mockReset(); mockGetFiles.mockReset(); mockUpdateFile.mockReset(); mockGetAgents.mockReset(); mockGetAgents.mockResolvedValue([]); }); it('returns 404 when the file does not exist (auth check fails first via fileAccess)', async () => { /* `fileAccess` middleware does its own getFiles lookup and returns * 404 before our handler ever runs. This test asserts the boundary * lives there, not that the handler duplicates the check. */ mockGetFiles.mockResolvedValueOnce([]); const res = await request(buildApp()).get('/files/missing-id/preview'); expect(res.status).toBe(404); expect(res.body).toMatchObject({ error: 'Not Found' }); expect(mockFindFileById).not.toHaveBeenCalled(); }); it('returns 403 when the requester does not own the file and has no agent-based access', async () => { /* fileAccess returns 403 — the file exists but belongs to someone * else and no agent grants access. The preview handler should * never run. */ mockGetFiles.mockResolvedValueOnce([ { file_id: 'someone-elses', user: 'other-user', filename: 'x.xlsx' }, ]); const res = await request(buildApp()).get('/files/someone-elses/preview'); expect(res.status).toBe(403); expect(mockFindFileById).not.toHaveBeenCalled(); }); it('does not disclose preview text through an attacker-authored agent file reference', async () => { mockGetFiles.mockResolvedValueOnce([ { file_id: 'victim-file', user: 'victim-user', filename: 'secret.xlsx', status: 'ready' }, ]); mockGetAgents.mockResolvedValueOnce([ { id: 'agent-attacker', author: 'attacker-user', tool_resources: { execute_code: { file_ids: ['victim-file'] } }, }, ]); const res = await request(buildApp({ user: { id: 'attacker-user', role: 'user' } })).get( '/files/victim-file/preview', ); expect(res.status).toBe(403); expect(mockFindFileById).not.toHaveBeenCalled(); }); it('returns status:pending without text/textFormat while the deferred render is in flight', async () => { mockGetFiles.mockResolvedValueOnce([ { file_id: 'fid-pending', user: OWNER_USER_ID, filename: 'data.xlsx', status: 'pending', }, ]); const res = await request(buildApp()).get('/files/fid-pending/preview'); expect(res.status).toBe(200); expect(res.body).toEqual({ file_id: 'fid-pending', status: 'pending' }); /* Pending must NOT leak `text` and must NOT trigger the text re-fetch. */ expect(res.body).not.toHaveProperty('text'); expect(mockFindFileById).not.toHaveBeenCalled(); }); it('returns status:ready with text + textFormat when the deferred render succeeded', async () => { mockGetFiles.mockResolvedValueOnce([ { file_id: 'fid-ready', user: OWNER_USER_ID, filename: 'data.xlsx', status: 'ready' }, ]); /* Text is fetched only on the terminal ready response. */ mockFindFileById.mockResolvedValueOnce({ file_id: 'fid-ready', text: '
1
', textFormat: 'html', }); const res = await request(buildApp()).get('/files/fid-ready/preview'); expect(res.status).toBe(200); expect(res.body).toEqual({ file_id: 'fid-ready', status: 'ready', text: '
1
', textFormat: 'html', }); }); it('returns status:failed with previewError when the deferred render errored', async () => { mockGetFiles.mockResolvedValueOnce([ { file_id: 'fid-failed', user: OWNER_USER_ID, filename: 'data.xlsx', status: 'failed', previewError: 'parser-error', }, ]); const res = await request(buildApp()).get('/files/fid-failed/preview'); expect(res.status).toBe(200); expect(res.body).toEqual({ file_id: 'fid-failed', status: 'failed', previewError: 'parser-error', }); expect(mockFindFileById).not.toHaveBeenCalled(); }); it('defaults to status:ready for legacy records with no status field (back-compat)', async () => { mockGetFiles.mockResolvedValueOnce([ { file_id: 'fid-legacy', user: OWNER_USER_ID, filename: 'old.csv', // status intentionally absent }, ]); mockFindFileById.mockResolvedValueOnce({ file_id: 'fid-legacy', text: 'csv,header\n1,2', textFormat: 'text', }); const res = await request(buildApp()).get('/files/fid-legacy/preview'); expect(res.status).toBe(200); expect(res.body).toEqual({ file_id: 'fid-legacy', status: 'ready', text: 'csv,header\n1,2', textFormat: 'text', }); }); it('returns status:ready with no text when the record is ready but text is null (binary/oversized)', async () => { mockGetFiles.mockResolvedValueOnce([ { file_id: 'fid-binary', user: OWNER_USER_ID, filename: 'image.bin' }, ]); mockFindFileById.mockResolvedValueOnce({ file_id: 'fid-binary', text: null, textFormat: null, }); const res = await request(buildApp()).get('/files/fid-binary/preview'); expect(res.status).toBe(200); expect(res.body).toEqual({ file_id: 'fid-binary', status: 'ready' }); }); it('returns ready with no text when ready record was deleted between fileAccess and text fetch', async () => { /* `fileAccess` saw the record but the concurrent delete removed it * before the text fetch. Surface ready-without-text rather than * 500 — the client routes to download-only and stops polling. */ mockGetFiles.mockResolvedValueOnce([ { file_id: 'fid-race', user: OWNER_USER_ID, filename: 'data.xlsx', status: 'ready' }, ]); mockFindFileById.mockResolvedValueOnce(null); const res = await request(buildApp()).get('/files/fid-race/preview'); expect(res.status).toBe(200); expect(res.body).toEqual({ file_id: 'fid-race', status: 'ready' }); }); it('returns 500 with a stable shape if the text fetch throws unexpectedly', async () => { mockGetFiles.mockResolvedValueOnce([ { file_id: 'fid-boom', user: OWNER_USER_ID, filename: 'data.xlsx', status: 'ready' }, ]); mockFindFileById.mockRejectedValueOnce(new Error('mongo down')); const res = await request(buildApp()).get('/files/fid-boom/preview'); expect(res.status).toBe(500); expect(res.body).toMatchObject({ error: 'Internal Server Error' }); }); describe('lazy sweep for stale pending records', () => { /* The boot-time `sweepOrphanedPreviews` only runs once at startup * with a 5-min cutoff. A backend crash + quick restart can leave * `pending` records younger than 5 min that never get touched * again. This endpoint sweeps them on the spot whenever a polling * request lands on one — the user is exactly the consumer who * cares, so on-demand sweep is the right shape. (Codex P2 review * on PR #12957.) */ const STALE_MS = 6 * 60 * 1000; const FRESH_MS = 30 * 1000; it('marks a stale pending record as failed:orphaned and returns the swept state', async () => { const updatedAt = new Date(Date.now() - STALE_MS); mockGetFiles.mockResolvedValueOnce([ { file_id: 'fid-stale', user: OWNER_USER_ID, filename: 'data.xlsx', status: 'pending', updatedAt, }, ]); mockUpdateFile.mockResolvedValueOnce({ file_id: 'fid-stale', status: 'failed', previewError: 'orphaned', }); const res = await request(buildApp()).get('/files/fid-stale/preview'); expect(mockUpdateFile).toHaveBeenCalledWith( { file_id: 'fid-stale', status: 'failed', previewError: 'orphaned' }, { status: 'pending', updatedAt }, ); expect(res.status).toBe(200); expect(res.body).toEqual({ file_id: 'fid-stale', status: 'failed', previewError: 'orphaned', }); }); it('does NOT sweep a fresh pending record (within the cutoff window)', async () => { mockGetFiles.mockResolvedValueOnce([ { file_id: 'fid-fresh', user: OWNER_USER_ID, filename: 'data.xlsx', status: 'pending', updatedAt: new Date(Date.now() - FRESH_MS), }, ]); const res = await request(buildApp()).get('/files/fid-fresh/preview'); expect(mockUpdateFile).not.toHaveBeenCalled(); expect(res.status).toBe(200); expect(res.body).toEqual({ file_id: 'fid-fresh', status: 'pending' }); }); it('sweeps a record past the 2min cutoff but below the 5min boot-sweep threshold', async () => { /* Pins the cutoff change from 5min to 2min — without this, a * future revert wouldn't fail the suite. */ const updatedAt = new Date(Date.now() - 3 * 60 * 1000); mockGetFiles.mockResolvedValueOnce([ { file_id: 'fid-mid', user: OWNER_USER_ID, filename: 'data.xlsx', status: 'pending', updatedAt, }, ]); mockUpdateFile.mockResolvedValueOnce({ file_id: 'fid-mid', status: 'failed', previewError: 'orphaned', }); const res = await request(buildApp()).get('/files/fid-mid/preview'); expect(mockUpdateFile).toHaveBeenCalled(); expect(res.body).toEqual({ file_id: 'fid-mid', status: 'failed', previewError: 'orphaned', }); }); it('does NOT sweep a stale ready record (only pending qualifies)', async () => { mockGetFiles.mockResolvedValueOnce([ { file_id: 'fid-ready', user: OWNER_USER_ID, filename: 'data.xlsx', status: 'ready', updatedAt: new Date(Date.now() - STALE_MS), }, ]); mockFindFileById.mockResolvedValueOnce({ file_id: 'fid-ready', text: 'final', textFormat: 'html', }); const res = await request(buildApp()).get('/files/fid-ready/preview'); expect(mockUpdateFile).not.toHaveBeenCalled(); expect(res.body).toMatchObject({ status: 'ready', text: 'final' }); }); it('falls through to the original pending payload if the conditional sweep loses the race', async () => { const updatedAt = new Date(Date.now() - STALE_MS); mockGetFiles.mockResolvedValueOnce([ { file_id: 'fid-race', user: OWNER_USER_ID, filename: 'data.xlsx', status: 'pending', updatedAt, }, ]); mockUpdateFile.mockResolvedValueOnce(null); const res = await request(buildApp()).get('/files/fid-race/preview'); expect(mockUpdateFile).toHaveBeenCalled(); expect(res.status).toBe(200); expect(res.body).toEqual({ file_id: 'fid-race', status: 'pending' }); }); }); });