|
| 1 | +/** |
| 2 | + * Wiring coverage: `stripCacheBust`'s own behavior is covered in |
| 3 | + * `packages/api/src/storage/__tests__/path.test.ts`. These tests assert that the local read paths |
| 4 | + * hand their raw filepath to it, so a reused code output resolves to the file that exists on disk. |
| 5 | + */ |
| 6 | +jest.mock('@librechat/api', () => ({ |
| 7 | + deleteRagFile: jest.fn(), |
| 8 | + stripCacheBust: jest.fn((filepath) => filepath.split('?')[0]), |
| 9 | +})); |
| 10 | +jest.mock('@librechat/data-schemas', () => ({ |
| 11 | + logger: { warn: jest.fn(), error: jest.fn() }, |
| 12 | +})); |
| 13 | + |
| 14 | +const mockTmpBase = require('fs').mkdtempSync( |
| 15 | + require('path').join(require('os').tmpdir(), 'local-cache-bust-'), |
| 16 | +); |
| 17 | + |
| 18 | +jest.mock('~/config/paths', () => { |
| 19 | + const path = require('path'); |
| 20 | + return { |
| 21 | + publicPath: path.join(mockTmpBase, 'public'), |
| 22 | + uploads: path.join(mockTmpBase, 'uploads'), |
| 23 | + imageOutput: path.join(mockTmpBase, 'public', 'images'), |
| 24 | + }; |
| 25 | +}); |
| 26 | + |
| 27 | +const fs = require('fs'); |
| 28 | +const path = require('path'); |
| 29 | +const { stripCacheBust } = require('@librechat/api'); |
| 30 | +const { getLocalFileStream } = require('../crud'); |
| 31 | + |
| 32 | +const imageOutput = path.join(mockTmpBase, 'public', 'images'); |
| 33 | +const uploads = path.join(mockTmpBase, 'uploads'); |
| 34 | + |
| 35 | +const makeReq = () => ({ |
| 36 | + user: { id: 'user-1' }, |
| 37 | + config: { paths: { publicPath: path.join(mockTmpBase, 'public'), uploads, imageOutput } }, |
| 38 | +}); |
| 39 | + |
| 40 | +const readStream = (stream) => |
| 41 | + new Promise((resolve, reject) => { |
| 42 | + const chunks = []; |
| 43 | + stream.on('data', (chunk) => chunks.push(chunk)); |
| 44 | + stream.on('error', reject); |
| 45 | + stream.on('end', () => resolve(Buffer.concat(chunks).toString())); |
| 46 | + }); |
| 47 | + |
| 48 | +describe('getLocalFileStream cache-busted filepaths', () => { |
| 49 | + beforeAll(() => { |
| 50 | + fs.mkdirSync(path.join(imageOutput, 'user-1'), { recursive: true }); |
| 51 | + fs.mkdirSync(path.join(uploads, 'user-1'), { recursive: true }); |
| 52 | + fs.writeFileSync(path.join(imageOutput, 'user-1', 'chart.png'), 'image-bytes'); |
| 53 | + fs.writeFileSync(path.join(uploads, 'user-1', 'doc.pdf'), 'upload-bytes'); |
| 54 | + }); |
| 55 | + |
| 56 | + afterAll(() => { |
| 57 | + fs.rmSync(mockTmpBase, { recursive: true, force: true }); |
| 58 | + }); |
| 59 | + |
| 60 | + it('streams a reused code-output image whose filepath carries `?v=`', async () => { |
| 61 | + const requested = '/images/user-1/chart.png?v=1789460622697'; |
| 62 | + |
| 63 | + const stream = await getLocalFileStream(makeReq(), requested); |
| 64 | + |
| 65 | + await expect(readStream(stream)).resolves.toBe('image-bytes'); |
| 66 | + expect(stripCacheBust).toHaveBeenCalledWith(requested); |
| 67 | + }); |
| 68 | + |
| 69 | + it('streams an upload whose filepath carries a query string', async () => { |
| 70 | + const requested = '/uploads/user-1/doc.pdf?manual=true'; |
| 71 | + |
| 72 | + const stream = await getLocalFileStream(makeReq(), requested); |
| 73 | + |
| 74 | + await expect(readStream(stream)).resolves.toBe('upload-bytes'); |
| 75 | + expect(stripCacheBust).toHaveBeenCalledWith(requested); |
| 76 | + }); |
| 77 | + |
| 78 | + it('still streams a filepath without a query string', async () => { |
| 79 | + const stream = await getLocalFileStream(makeReq(), '/images/user-1/chart.png'); |
| 80 | + |
| 81 | + await expect(readStream(stream)).resolves.toBe('image-bytes'); |
| 82 | + }); |
| 83 | + |
| 84 | + it('still rejects traversal hidden behind a query string', async () => { |
| 85 | + await expect(getLocalFileStream(makeReq(), '/images/../../../etc/passwd?v=1')).rejects.toThrow( |
| 86 | + 'Invalid file path', |
| 87 | + ); |
| 88 | + }); |
| 89 | +}); |
0 commit comments