@@ -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
3133vi . mock ( '@/lib/api/server/routes/v2-api-key-auth' , ( ) => ( {
@@ -64,7 +66,8 @@ vi.mock('@/lib/knowledge/application/documents', () => ({
6466} ) )
6567
6668vi . 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
8083vi . mock ( '@/lib/posthog/server' , ( ) => ( { captureServerEvent : mockCapture } ) )
8184
85+ import { OrchestrationError } from '@/lib/core/orchestration/types'
8286import { 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'
8389import { POST } from '@/app/api/v2/knowledge/[id]/documents/route'
8490
8591const 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