11import path from 'path'
2+ import { NextRequest } from 'next/server'
23/**
34 * Tests for file parse API route
45 *
56 * @vitest -environment node
67 */
78import { afterEach , beforeEach , describe , expect , it , vi } from 'vitest'
89import { createMockRequest } from '@/app/api/__test-utils__/utils'
10+ import { POST } from './route'
911
10- // Create actual mocks for path functions that we can use instead of using vi.doMock for path
1112const mockJoin = vi . fn ( ( ...args : string [ ] ) : string => {
12- // For the UPLOAD_DIR paths, just return a test path
1313 if ( args [ 0 ] === '/test/uploads' ) {
1414 return `/test/uploads/${ args [ args . length - 1 ] } `
1515 }
1616 return path . join ( ...args )
1717} )
1818
1919describe ( 'File Parse API Route' , ( ) => {
20- // Mock file system and parser modules
2120 const mockReadFile = vi . fn ( ) . mockResolvedValue ( Buffer . from ( 'test file content' ) )
2221 const mockWriteFile = vi . fn ( ) . mockResolvedValue ( undefined )
2322 const mockUnlink = vi . fn ( ) . mockResolvedValue ( undefined )
@@ -36,15 +35,12 @@ describe('File Parse API Route', () => {
3635 beforeEach ( ( ) => {
3736 vi . resetModules ( )
3837
39- // Reset all mocks
4038 vi . resetAllMocks ( )
4139
42- // Create a test upload file that exists for all tests
4340 mockReadFile . mockResolvedValue ( Buffer . from ( 'test file content' ) )
4441 mockAccessFs . mockResolvedValue ( undefined )
4542 mockStatFs . mockImplementation ( ( ) => ( { isFile : ( ) => true } ) )
4643
47- // Mock filesystem operations
4844 vi . doMock ( 'fs' , ( ) => ( {
4945 existsSync : vi . fn ( ) . mockReturnValue ( true ) ,
5046 constants : { R_OK : 4 } ,
@@ -63,19 +59,16 @@ describe('File Parse API Route', () => {
6359 stat : mockStatFs ,
6460 } ) )
6561
66- // Mock the S3 client
6762 vi . doMock ( '@/lib/uploads/s3-client' , ( ) => ( {
6863 downloadFromS3 : mockDownloadFromS3 ,
6964 } ) )
7065
71- // Mock file parsers
7266 vi . doMock ( '@/lib/file-parsers' , ( ) => ( {
7367 isSupportedFileType : vi . fn ( ) . mockReturnValue ( true ) ,
7468 parseFile : mockParseFile ,
7569 parseBuffer : mockParseBuffer ,
7670 } ) )
7771
78- // Mock path module with our custom join function
7972 vi . doMock ( 'path' , ( ) => {
8073 return {
8174 ...path ,
@@ -85,7 +78,6 @@ describe('File Parse API Route', () => {
8578 }
8679 } )
8780
88- // Mock the logger
8981 vi . doMock ( '@/lib/logs/console-logger' , ( ) => ( {
9082 createLogger : vi . fn ( ) . mockReturnValue ( {
9183 info : vi . fn ( ) ,
@@ -95,7 +87,6 @@ describe('File Parse API Route', () => {
9587 } ) ,
9688 } ) )
9789
98- // Configure upload directory and S3 mode
9990 vi . doMock ( '@/lib/uploads/setup' , ( ) => ( {
10091 UPLOAD_DIR : '/test/uploads' ,
10192 USE_S3_STORAGE : false ,
@@ -105,15 +96,13 @@ describe('File Parse API Route', () => {
10596 } ,
10697 } ) )
10798
108- // Skip setup.server.ts side effects
10999 vi . doMock ( '@/lib/uploads/setup.server' , ( ) => ( { } ) )
110100 } )
111101
112102 afterEach ( ( ) => {
113103 vi . clearAllMocks ( )
114104 } )
115105
116- // Basic tests testing the API structure
117106 it ( 'should handle missing file path' , async ( ) => {
118107 const req = createMockRequest ( 'POST' , { } )
119108 const { POST } = await import ( './route' )
@@ -125,47 +114,37 @@ describe('File Parse API Route', () => {
125114 expect ( data ) . toHaveProperty ( 'error' , 'No file path provided' )
126115 } )
127116
128- // Test skipping the implementation details and testing what users would care about
129117 it ( 'should accept and process a local file' , async ( ) => {
130- // Given: A request with a file path
131118 const req = createMockRequest ( 'POST' , {
132119 filePath : '/api/files/serve/test-file.txt' ,
133120 } )
134121
135- // When: The API processes the request
136122 const { POST } = await import ( './route' )
137123 const response = await POST ( req )
138124 const data = await response . json ( )
139125
140- // Then: Check the API contract without making assumptions about implementation
141126 expect ( response . status ) . toBe ( 200 )
142- expect ( data ) . not . toBeNull ( ) // We got a response
127+ expect ( data ) . not . toBeNull ( )
143128
144- // The response either has a success indicator with output OR an error
145129 if ( data . success === true ) {
146130 expect ( data ) . toHaveProperty ( 'output' )
147131 } else {
148- // If error, there should be an error message
149132 expect ( data ) . toHaveProperty ( 'error' )
150133 expect ( typeof data . error ) . toBe ( 'string' )
151134 }
152135 } )
153136
154137 it ( 'should process S3 files' , async ( ) => {
155- // Given: A request with an S3 file path
156138 const req = createMockRequest ( 'POST' , {
157139 filePath : '/api/files/serve/s3/test-file.pdf' ,
158140 } )
159141
160- // When: The API processes the request
161142 const { POST } = await import ( './route' )
162143 const response = await POST ( req )
163144 const data = await response . json ( )
164145
165- // Then: We should get a response with parsed content or error
166146 expect ( response . status ) . toBe ( 200 )
167147
168- // The data should either have a success flag with output or an error
169148 if ( data . success === true ) {
170149 expect ( data ) . toHaveProperty ( 'output' )
171150 } else {
@@ -174,17 +153,14 @@ describe('File Parse API Route', () => {
174153 } )
175154
176155 it ( 'should handle multiple files' , async ( ) => {
177- // Given: A request with multiple file paths
178156 const req = createMockRequest ( 'POST' , {
179157 filePath : [ '/api/files/serve/file1.txt' , '/api/files/serve/file2.txt' ] ,
180158 } )
181159
182- // When: The API processes the request
183160 const { POST } = await import ( './route' )
184161 const response = await POST ( req )
185162 const data = await response . json ( )
186163
187- // Then: We get an array of results
188164 expect ( response . status ) . toBe ( 200 )
189165 expect ( data ) . toHaveProperty ( 'success' )
190166 expect ( data ) . toHaveProperty ( 'results' )
@@ -193,43 +169,220 @@ describe('File Parse API Route', () => {
193169 } )
194170
195171 it ( 'should handle S3 access errors gracefully' , async ( ) => {
196- // Given: S3 will throw an error
197172 mockDownloadFromS3 . mockRejectedValueOnce ( new Error ( 'S3 access denied' ) )
198173
199- // And: A request with an S3 file path
200174 const req = createMockRequest ( 'POST' , {
201175 filePath : '/api/files/serve/s3/access-denied.pdf' ,
202176 } )
203177
204- // When: The API processes the request
205178 const { POST } = await import ( './route' )
206179 const response = await POST ( req )
207180 const data = await response . json ( )
208181
209- // Then: We get an appropriate error
210182 expect ( response . status ) . toBe ( 200 )
211183 expect ( data ) . toHaveProperty ( 'success' , false )
212184 expect ( data ) . toHaveProperty ( 'error' )
213185 expect ( data . error ) . toContain ( 'S3 access denied' )
214186 } )
215187
216188 it ( 'should handle access errors gracefully' , async ( ) => {
217- // Given: File access will fail
218189 mockAccessFs . mockRejectedValueOnce ( new Error ( 'ENOENT: no such file' ) )
219190
220- // And: A request with a nonexistent file
221191 const req = createMockRequest ( 'POST' , {
222192 filePath : '/api/files/serve/nonexistent.txt' ,
223193 } )
224194
225- // When: The API processes the request
226195 const { POST } = await import ( './route' )
227196 const response = await POST ( req )
228197 const data = await response . json ( )
229198
230- // Then: We get an appropriate error response
231199 expect ( response . status ) . toBe ( 200 )
232200 expect ( data ) . toHaveProperty ( 'success' )
233201 expect ( data ) . toHaveProperty ( 'error' )
234202 } )
235203} )
204+
205+ describe ( 'Files Parse API - Path Traversal Security' , ( ) => {
206+ beforeEach ( ( ) => {
207+ vi . clearAllMocks ( )
208+ } )
209+
210+ describe ( 'Path Traversal Prevention' , ( ) => {
211+ it ( 'should reject path traversal attempts with .. segments' , async ( ) => {
212+ const maliciousRequests = [
213+ '../../../etc/passwd' ,
214+ '/api/files/serve/../../../etc/passwd' ,
215+ '/api/files/serve/../../app.js' ,
216+ '/api/files/serve/../.env' ,
217+ 'uploads/../../../etc/hosts' ,
218+ ]
219+
220+ for ( const maliciousPath of maliciousRequests ) {
221+ const request = new NextRequest ( 'http://localhost:3000/api/files/parse' , {
222+ method : 'POST' ,
223+ body : JSON . stringify ( {
224+ filePath : maliciousPath ,
225+ } ) ,
226+ } )
227+
228+ const response = await POST ( request )
229+ const result = await response . json ( )
230+
231+ expect ( result . success ) . toBe ( false )
232+ expect ( result . error ) . toMatch ( / A c c e s s d e n i e d | I n v a l i d p a t h | P a t h o u t s i d e a l l o w e d d i r e c t o r y / )
233+ }
234+ } )
235+
236+ it ( 'should reject paths with tilde characters' , async ( ) => {
237+ const maliciousPaths = [
238+ '~/../../etc/passwd' ,
239+ '/api/files/serve/~/secret.txt' ,
240+ '~root/.ssh/id_rsa' ,
241+ ]
242+
243+ for ( const maliciousPath of maliciousPaths ) {
244+ const request = new NextRequest ( 'http://localhost:3000/api/files/parse' , {
245+ method : 'POST' ,
246+ body : JSON . stringify ( {
247+ filePath : maliciousPath ,
248+ } ) ,
249+ } )
250+
251+ const response = await POST ( request )
252+ const result = await response . json ( )
253+
254+ expect ( result . success ) . toBe ( false )
255+ expect ( result . error ) . toMatch ( / A c c e s s d e n i e d | I n v a l i d p a t h / )
256+ }
257+ } )
258+
259+ it ( 'should reject absolute paths outside upload directory' , async ( ) => {
260+ const maliciousPaths = [
261+ '/etc/passwd' ,
262+ '/root/.bashrc' ,
263+ '/app/.env' ,
264+ '/var/log/auth.log' ,
265+ 'C:\\Windows\\System32\\drivers\\etc\\hosts' , // Windows path
266+ ]
267+
268+ for ( const maliciousPath of maliciousPaths ) {
269+ const request = new NextRequest ( 'http://localhost:3000/api/files/parse' , {
270+ method : 'POST' ,
271+ body : JSON . stringify ( {
272+ filePath : maliciousPath ,
273+ } ) ,
274+ } )
275+
276+ const response = await POST ( request )
277+ const result = await response . json ( )
278+
279+ expect ( result . success ) . toBe ( false )
280+ expect ( result . error ) . toMatch ( / A c c e s s d e n i e d | P a t h o u t s i d e a l l o w e d d i r e c t o r y / )
281+ }
282+ } )
283+
284+ it ( 'should allow valid paths within upload directory' , async ( ) => {
285+ // Test that valid paths don't trigger path validation errors
286+ const validPaths = [
287+ '/api/files/serve/document.txt' ,
288+ '/api/files/serve/folder/file.pdf' ,
289+ '/api/files/serve/subfolder/image.png' ,
290+ ]
291+
292+ for ( const validPath of validPaths ) {
293+ const request = new NextRequest ( 'http://localhost:3000/api/files/parse' , {
294+ method : 'POST' ,
295+ body : JSON . stringify ( {
296+ filePath : validPath ,
297+ } ) ,
298+ } )
299+
300+ const response = await POST ( request )
301+ const result = await response . json ( )
302+
303+ // Should not fail due to path validation (may fail for other reasons like file not found)
304+ if ( result . error ) {
305+ expect ( result . error ) . not . toMatch (
306+ / A c c e s s d e n i e d | P a t h o u t s i d e a l l o w e d d i r e c t o r y | I n v a l i d p a t h /
307+ )
308+ }
309+ }
310+ } )
311+
312+ it ( 'should handle encoded path traversal attempts' , async ( ) => {
313+ const encodedMaliciousPaths = [
314+ '/api/files/serve/%2e%2e%2f%2e%2e%2fetc%2fpasswd' , // ../../../etc/passwd
315+ '/api/files/serve/..%2f..%2f..%2fetc%2fpasswd' ,
316+ '/api/files/serve/%2e%2e/%2e%2e/etc/passwd' ,
317+ ]
318+
319+ for ( const maliciousPath of encodedMaliciousPaths ) {
320+ const request = new NextRequest ( 'http://localhost:3000/api/files/parse' , {
321+ method : 'POST' ,
322+ body : JSON . stringify ( {
323+ filePath : decodeURIComponent ( maliciousPath ) , // Simulate URL decoding
324+ } ) ,
325+ } )
326+
327+ const response = await POST ( request )
328+ const result = await response . json ( )
329+
330+ expect ( result . success ) . toBe ( false )
331+ expect ( result . error ) . toMatch ( / A c c e s s d e n i e d | I n v a l i d p a t h | P a t h o u t s i d e a l l o w e d d i r e c t o r y / )
332+ }
333+ } )
334+
335+ it ( 'should handle null byte injection attempts' , async ( ) => {
336+ const nullBytePaths = [
337+ '/api/files/serve/file.txt\0../../etc/passwd' ,
338+ 'file.txt\0/etc/passwd' ,
339+ '/api/files/serve/document.pdf\0/var/log/auth.log' ,
340+ ]
341+
342+ for ( const maliciousPath of nullBytePaths ) {
343+ const request = new NextRequest ( 'http://localhost:3000/api/files/parse' , {
344+ method : 'POST' ,
345+ body : JSON . stringify ( {
346+ filePath : maliciousPath ,
347+ } ) ,
348+ } )
349+
350+ const response = await POST ( request )
351+ const result = await response . json ( )
352+
353+ expect ( result . success ) . toBe ( false )
354+ // Should be rejected either by path validation or file system access
355+ }
356+ } )
357+ } )
358+
359+ describe ( 'Edge Cases' , ( ) => {
360+ it ( 'should handle empty file paths' , async ( ) => {
361+ const request = new NextRequest ( 'http://localhost:3000/api/files/parse' , {
362+ method : 'POST' ,
363+ body : JSON . stringify ( {
364+ filePath : '' ,
365+ } ) ,
366+ } )
367+
368+ const response = await POST ( request )
369+ const result = await response . json ( )
370+
371+ expect ( response . status ) . toBe ( 400 )
372+ expect ( result . error ) . toBe ( 'No file path provided' )
373+ } )
374+
375+ it ( 'should handle missing filePath parameter' , async ( ) => {
376+ const request = new NextRequest ( 'http://localhost:3000/api/files/parse' , {
377+ method : 'POST' ,
378+ body : JSON . stringify ( { } ) ,
379+ } )
380+
381+ const response = await POST ( request )
382+ const result = await response . json ( )
383+
384+ expect ( response . status ) . toBe ( 400 )
385+ expect ( result . error ) . toBe ( 'No file path provided' )
386+ } )
387+ } )
388+ } )
0 commit comments