Skip to content

Commit b245053

Browse files
authored
refactor(agent-handler): simplify agent handler, update tests, fix resolution of env vars in function execution (#437)
* refactored agent handler, fixed envvar resolution for function block * resolve missing envvar resolution from function execution for custom tool * fix path traversal risk * removed extraneous comments * ack PR comments
1 parent 3b82e7d commit b245053

File tree

22 files changed

+1068
-741
lines changed

22 files changed

+1068
-741
lines changed
Lines changed: 188 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,22 @@
11
import path from 'path'
2+
import { NextRequest } from 'next/server'
23
/**
34
* Tests for file parse API route
45
*
56
* @vitest-environment node
67
*/
78
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
89
import { 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
1112
const 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

1919
describe('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(/Access denied|Invalid path|Path outside allowed directory/)
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(/Access denied|Invalid path/)
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(/Access denied|Path outside allowed directory/)
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+
/Access denied|Path outside allowed directory|Invalid path/
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(/Access denied|Invalid path|Path outside allowed directory/)
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

Comments
 (0)