Skip to content

Commit 6c8a34e

Browse files
refactor(knowledge): declare v2 body lifecycle
1 parent d3fdf85 commit 6c8a34e

14 files changed

Lines changed: 850 additions & 218 deletions

File tree

apps/sim/app/api/v2/knowledge/[id]/documents/route.test.ts

Lines changed: 123 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const {
1515
mockUploadWorkspaceFile,
1616
mockPlatformUploaded,
1717
mockCapture,
18+
mockIsPayloadSizeLimitError,
1819
} = vi.hoisted(() => ({
1920
mockAuthenticate: vi.fn(),
2021
mockCheckPreAuth: vi.fn(),
@@ -26,6 +27,7 @@ const {
2627
mockUploadWorkspaceFile: vi.fn(),
2728
mockPlatformUploaded: vi.fn(),
2829
mockCapture: vi.fn(),
30+
mockIsPayloadSizeLimitError: vi.fn(),
2931
}))
3032

3133
vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({
@@ -64,7 +66,8 @@ vi.mock('@/lib/knowledge/application/documents', () => ({
6466
}))
6567

6668
vi.mock('@/lib/core/utils/stream-limits', () => ({
67-
isPayloadSizeLimitError: () => false,
69+
MAX_MULTIPART_OVERHEAD_BYTES: 1024 * 1024,
70+
isPayloadSizeLimitError: mockIsPayloadSizeLimitError,
6871
readFormDataWithLimit: mockReadFormData,
6972
readFileToBufferWithLimit: mockReadFile,
7073
}))
@@ -79,7 +82,10 @@ vi.mock('@/lib/core/telemetry', () => ({
7982

8083
vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mockCapture }))
8184

85+
import { OrchestrationError } from '@/lib/core/orchestration/types'
8286
import { KnowledgeUsageLimitExceededError } from '@/lib/knowledge/application/billing'
87+
import { MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE } from '@/lib/uploads/shared/types'
88+
import { validateFileType } from '@/lib/uploads/utils/validation'
8389
import { POST } from '@/app/api/v2/knowledge/[id]/documents/route'
8490

8591
const WORKSPACE_ID = 'workspace-1'
@@ -103,6 +109,7 @@ describe('POST /api/v2/knowledge/[id]/documents', () => {
103109
vi.clearAllMocks()
104110
mockCheckPreAuth.mockResolvedValue(RATE_LIMIT_OK)
105111
mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
112+
mockIsPayloadSizeLimitError.mockReturnValue(false)
106113
mockAuthenticate.mockResolvedValue({
107114
principal: PRINCIPAL,
108115
rolloutUserId: 'user-1',
@@ -184,6 +191,16 @@ describe('POST /api/v2/knowledge/[id]/documents', () => {
184191
expect.objectContaining({ knowledge_base_id: 'kb-1' }),
185192
expect.any(Object)
186193
)
194+
expect(mockReadFormData).toHaveBeenCalledWith(request, {
195+
maxBytes: MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE + 1024 * 1024,
196+
label: 'knowledge document upload body',
197+
})
198+
expect(mockReadFile).toHaveBeenCalledWith(expect.any(File), {
199+
maxBytes: MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE,
200+
label: 'knowledge document file',
201+
})
202+
expect(response.headers.get('cache-control')).toBe('private, no-store')
203+
expect(response.headers.get('x-ratelimit-limit')).toBe('100')
187204
})
188205

189206
it('maps usage admission to the v2 error before multipart buffering', async () => {
@@ -215,4 +232,109 @@ describe('POST /api/v2/knowledge/[id]/documents', () => {
215232
expect(mockPlatformUploaded).toHaveBeenCalledOnce()
216233
expect(mockCapture).not.toHaveBeenCalled()
217234
})
235+
236+
it('preserves the malformed multipart envelope without transferring storage', async () => {
237+
mockReadFormData.mockRejectedValueOnce(new Error('multipart boundary missing'))
238+
239+
const response = await POST(buildRequest(), { params: Promise.resolve({ id: 'kb-1' }) })
240+
241+
expect(response.status).toBe(400)
242+
expect(await response.json()).toEqual({
243+
error: { code: 'BAD_REQUEST', message: 'Request body must be valid multipart form data' },
244+
})
245+
expect(mockUploadWorkspaceFile).not.toHaveBeenCalled()
246+
expect(mockUploadDocument).not.toHaveBeenCalled()
247+
expect(mockPlatformUploaded).not.toHaveBeenCalled()
248+
})
249+
250+
it('preserves bounded multipart rejection and stops before storage transfer', async () => {
251+
const error = new Error('knowledge document upload body exceeds maximum size')
252+
mockReadFormData.mockRejectedValueOnce(error)
253+
mockIsPayloadSizeLimitError.mockImplementation((candidate: unknown) => candidate === error)
254+
255+
const response = await POST(buildRequest(), { params: Promise.resolve({ id: 'kb-1' }) })
256+
257+
expect(response.status).toBe(413)
258+
expect(await response.json()).toEqual({
259+
error: { code: 'PAYLOAD_TOO_LARGE', message: error.message },
260+
})
261+
expect(mockUploadWorkspaceFile).not.toHaveBeenCalled()
262+
expect(mockUploadDocument).not.toHaveBeenCalled()
263+
})
264+
265+
it('requires a file form field before storage transfer', async () => {
266+
mockReadFormData.mockResolvedValueOnce(new FormData())
267+
268+
const response = await POST(buildRequest(), { params: Promise.resolve({ id: 'kb-1' }) })
269+
270+
expect(response.status).toBe(400)
271+
expect(await response.json()).toEqual({
272+
error: { code: 'BAD_REQUEST', message: 'file form field is required' },
273+
})
274+
expect(mockUploadWorkspaceFile).not.toHaveBeenCalled()
275+
})
276+
277+
it('preserves the exact file-size rejection before reading file bytes', async () => {
278+
const formData = new FormData()
279+
const file = new File(['x'], 'large.txt', { type: 'text/plain' })
280+
Object.defineProperty(file, 'size', { value: MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE + 1 })
281+
formData.set('file', file)
282+
mockReadFormData.mockResolvedValueOnce(formData)
283+
284+
const response = await POST(buildRequest(), { params: Promise.resolve({ id: 'kb-1' }) })
285+
286+
expect(response.status).toBe(413)
287+
expect(await response.json()).toEqual({
288+
error: { code: 'PAYLOAD_TOO_LARGE', message: 'File size exceeds 100MB limit (100.00MB)' },
289+
})
290+
expect(mockReadFile).not.toHaveBeenCalled()
291+
expect(mockUploadWorkspaceFile).not.toHaveBeenCalled()
292+
})
293+
294+
it('preserves unsupported file-type validation before reading file bytes', async () => {
295+
const formData = new FormData()
296+
formData.set('file', new File(['x'], 'malware.exe', { type: 'application/octet-stream' }))
297+
mockReadFormData.mockResolvedValueOnce(formData)
298+
const expectedMessage = validateFileType('malware.exe', 'application/octet-stream')?.message
299+
if (!expectedMessage) throw new Error('Expected unsupported file type validation to fail')
300+
301+
const response = await POST(buildRequest(), { params: Promise.resolve({ id: 'kb-1' }) })
302+
303+
expect(response.status).toBe(415)
304+
expect(await response.json()).toEqual({
305+
error: { code: 'UNSUPPORTED_MEDIA_TYPE', message: expectedMessage },
306+
})
307+
expect(mockReadFile).not.toHaveBeenCalled()
308+
expect(mockUploadWorkspaceFile).not.toHaveBeenCalled()
309+
})
310+
311+
it('does not register or emit effects when storage transfer fails', async () => {
312+
mockUploadWorkspaceFile.mockRejectedValueOnce(new Error('storage unavailable'))
313+
314+
const response = await POST(buildRequest(), { params: Promise.resolve({ id: 'kb-1' }) })
315+
316+
expect(response.status).toBe(500)
317+
expect(await response.json()).toEqual({
318+
error: { code: 'INTERNAL_ERROR', message: 'Internal server error' },
319+
})
320+
expect(mockUploadDocument).not.toHaveBeenCalled()
321+
expect(mockPlatformUploaded).not.toHaveBeenCalled()
322+
expect(mockCapture).not.toHaveBeenCalled()
323+
})
324+
325+
it('preserves application authorization errors after storage transfer', async () => {
326+
mockUploadDocument.mockRejectedValueOnce(
327+
new OrchestrationError('forbidden', 'Insufficient workspace permissions')
328+
)
329+
330+
const response = await POST(buildRequest(), { params: Promise.resolve({ id: 'kb-1' }) })
331+
332+
expect(response.status).toBe(403)
333+
expect(await response.json()).toEqual({
334+
error: { code: 'FORBIDDEN', message: 'Insufficient workspace permissions' },
335+
})
336+
expect(mockUploadWorkspaceFile).toHaveBeenCalledOnce()
337+
expect(mockPlatformUploaded).not.toHaveBeenCalled()
338+
expect(mockCapture).not.toHaveBeenCalled()
339+
})
218340
})

0 commit comments

Comments
 (0)