22 * Tests for file serve API route
33 */
44
5+ import { Readable } from 'node:stream'
56import {
67 authMockFns ,
78 hybridAuthMockFns ,
@@ -41,7 +42,7 @@ import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types'
4142const {
4243 mockReadFile,
4344 mockAuthenticateWorkspaceFile,
44- mockReadWorkspaceFileContentByKey ,
45+ mockStreamAuthorizedWorkspaceFile ,
4546 mockReadWorkspaceFileRecordByKey,
4647 mockAuthorizeWorkspaceFileRecordByKey,
4748 mockResolveServableDocBytes,
@@ -64,7 +65,7 @@ const {
6465 mockReadOrganizationAssistantImage : vi . fn ( ) ,
6566 mockReadFile : vi . fn ( ) ,
6667 mockAuthenticateWorkspaceFile : vi . fn ( ) ,
67- mockReadWorkspaceFileContentByKey : vi . fn ( ) ,
68+ mockStreamAuthorizedWorkspaceFile : vi . fn ( ) ,
6869 mockReadWorkspaceFileRecordByKey : vi . fn ( ) ,
6970 mockAuthorizeWorkspaceFileRecordByKey : vi . fn ( async ( ) => undefined ) ,
7071 mockResolveServableDocBytes : vi . fn ( ) ,
@@ -117,8 +118,11 @@ vi.mock('@/lib/workspace-files/api', () => ({
117118 internalWorkspaceFileServeAuth : { authenticate : mockAuthenticateWorkspaceFile } ,
118119} ) )
119120
120- vi . mock ( '@/lib/workspace-files/application/read-workspace-file-content-by-key' , ( ) => ( {
121- readWorkspaceFileContentByKey : { execute : mockReadWorkspaceFileContentByKey } ,
121+ vi . mock ( '@/lib/workspace-files/application/stream-authorized-workspace-file' , ( ) => ( {
122+ streamAuthorizedWorkspaceFile : mockStreamAuthorizedWorkspaceFile ,
123+ } ) )
124+
125+ vi . mock ( '@/lib/workspace-files/application/read-workspace-file-record-by-key' , ( ) => ( {
122126 readWorkspaceFileRecordByKey : {
123127 execute : mockReadWorkspaceFileRecordByKey ,
124128 authorize : mockAuthorizeWorkspaceFileRecordByKey ,
@@ -129,7 +133,9 @@ vi.mock('@/lib/uploads/documents/compile', () => ({
129133 resolveServableDocBytes : mockResolveServableDocBytes ,
130134} ) )
131135
132- vi . mock ( '@/app/api/files/utils' , ( ) => ( {
136+ vi . mock ( '@/app/api/files/utils' , async ( importOriginal ) => ( {
137+ createStoredFileStreamResponse : ( await importOriginal < typeof import ( '@/app/api/files/utils' ) > ( ) )
138+ . createStoredFileStreamResponse ,
133139 FileNotFoundError,
134140 createFileResponse : mockCreateFileResponse ,
135141 createConditionalFileResponse : mockCreateConditionalFileResponse ,
@@ -151,6 +157,10 @@ const serveLogger = getMockLogger('FilesServeAPI')
151157const mockDownloadCopilotFile = uploadsMockFns . mockDownloadCopilotFile
152158const mockIsUsingCloudStorage = uploadsMockFns . mockIsUsingCloudStorage
153159
160+ function webStream ( text : string ) : ReadableStream < Uint8Array > {
161+ return new Blob ( [ text ] ) . stream ( )
162+ }
163+
154164describe ( 'File Serve API Route' , ( ) => {
155165 beforeEach ( ( ) => {
156166 hybridAuthMockFns . mockCheckSessionOrInternalAuth . mockResolvedValue ( {
@@ -170,14 +180,7 @@ describe('File Serve API Route', () => {
170180 mockAuthenticateWorkspaceFile . mockResolvedValue (
171181 createSessionPrincipal ( { userId : 'test-user-id' } )
172182 )
173- mockReadWorkspaceFileContentByKey . mockResolvedValue ( {
174- file : {
175- id : 'file-1' ,
176- workspaceId : 'test-workspace-id' ,
177- name : 'report.pdf' ,
178- } ,
179- content : Buffer . from ( 'generated source' ) ,
180- } )
183+ mockStreamAuthorizedWorkspaceFile . mockImplementation ( async ( ) => webStream ( 'stored bytes' ) )
181184 mockReadWorkspaceFileRecordByKey . mockResolvedValue ( {
182185 file : {
183186 id : 'file-1' ,
@@ -248,11 +251,11 @@ describe('File Serve API Route', () => {
248251 mockDownloadCopilotFile . mockResolvedValue ( Buffer . from ( 'bytes' ) )
249252
250253 await GET (
251- createMockRequest ( { url : 'http://localhost:3000/api/files/serve/copilot/doc.txt ' } ) ,
252- createRouteContext ( { path : [ 'copilot' , 'doc.txt ' ] } )
254+ createMockRequest ( { url : 'http://localhost:3000/api/files/serve/copilot/doc.pdf ' } ) ,
255+ createRouteContext ( { path : [ 'copilot' , 'doc.pdf ' ] } )
253256 )
254257
255- expect ( mockDownloadCopilotFile ) . toHaveBeenCalledWith ( 'copilot/doc.txt ' , {
258+ expect ( mockDownloadCopilotFile ) . toHaveBeenCalledWith ( 'copilot/doc.pdf ' , {
256259 maxBytes : MAX_BUFFERED_TRANSFER_BYTES ,
257260 } )
258261 } )
@@ -287,9 +290,9 @@ describe('File Serve API Route', () => {
287290 it ( 'bounds the local read rather than trusting the stored size' , async ( ) => {
288291 await GET (
289292 createMockRequest ( {
290- url : 'http://localhost:3000/api/files/serve/workspace/ws/test-file.txt ' ,
293+ url : 'http://localhost:3000/api/files/serve/workspace/ws/report.pdf ' ,
291294 } ) ,
292- createRouteContext ( { path : [ 'workspace' , 'ws' , 'test-file.txt ' ] } )
295+ createRouteContext ( { path : [ 'workspace' , 'ws' , 'report.pdf ' ] } )
293296 )
294297
295298 expect ( mockReadLocalFileWithinLimit ) . toHaveBeenCalledWith (
@@ -318,8 +321,8 @@ describe('File Serve API Route', () => {
318321 )
319322
320323 const response = await GET (
321- createMockRequest ( { url : 'http://localhost:3000/api/files/serve/workspace/ws/huge.bin ' } ) ,
322- createRouteContext ( { path : [ 'workspace' , 'ws' , 'huge.bin ' ] } )
324+ createMockRequest ( { url : 'http://localhost:3000/api/files/serve/workspace/ws/huge.pdf ' } ) ,
325+ createRouteContext ( { path : [ 'workspace' , 'ws' , 'huge.pdf ' ] } )
323326 )
324327
325328 expect ( response . status ) . toBe ( 413 )
@@ -334,10 +337,10 @@ describe('File Serve API Route', () => {
334337 } )
335338
336339 const response = await GET (
337- new NextRequest ( 'http://localhost:3000/api/files/serve/workspace/ws/report.md ' , {
340+ new NextRequest ( 'http://localhost:3000/api/files/serve/workspace/ws/report.pdf ' , {
338341 signal : controller . signal ,
339342 } ) ,
340- createRouteContext ( { path : [ 'workspace' , 'ws' , 'report.md ' ] } )
343+ createRouteContext ( { path : [ 'workspace' , 'ws' , 'report.pdf ' ] } )
341344 )
342345
343346 expect ( response . status ) . toBe ( 499 )
@@ -400,36 +403,220 @@ describe('File Serve API Route', () => {
400403 const response = await serveVersionedDoc ( true )
401404 expect ( response . headers . get ( 'Cache-Control' ) ) . toBe ( 'private, no-cache, must-revalidate' )
402405 } )
406+ } )
403407
404- it ( 'serves stored bytes under the type their extension names, not the recorded type' , async ( ) => {
408+ describe ( 'streaming stored objects' , ( ) => {
409+ const principal = createSessionPrincipal ( { userId : 'test-user-id' } )
410+ const video = {
411+ id : 'file-1' ,
412+ workspaceId : 'test-workspace-id' ,
413+ name : 'clip.mp4' ,
414+ key : 'workspace/test-workspace-id/clip.mp4' ,
415+ type : 'application/octet-stream' ,
416+ size : 300_000_000 ,
417+ updatedAt : new Date ( '2026-10-31T20:28:58Z' ) ,
418+ contentUpdatedAt : new Date ( '2026-10-31T20:28:58Z' ) ,
419+ }
420+
421+ function serveWorkspaceFile (
422+ file : typeof video ,
423+ headers : Record < string , string > = { } ,
424+ query = ''
425+ ) {
405426 mockResolveStoredFileContext . mockResolvedValue ( 'workspace' )
406427 mockParseWorkspaceFileKey . mockReturnValue ( 'test-workspace-id' )
407428 mockAuthenticateWorkspaceFile . mockResolvedValue ( principal )
408- mockReadWorkspaceFileRecordByKey . mockResolvedValue ( {
409- file : {
410- id : 'file-1' ,
411- workspaceId : 'test-workspace-id' ,
412- name : 'export.csv' ,
413- key : 'workspace/test-workspace-id/export.csv' ,
414- type : 'application/vnd.ms-excel' ,
415- size : 3 ,
416- contentUpdatedAt : new Date ( '2026-09-06' ) ,
417- } ,
418- } )
419- fileUtilsServerMockFns . mockDownloadServableFileFromStorage . mockResolvedValue ( {
420- buffer : Buffer . from ( 'a,b' ) ,
421- contentType : 'application/vnd.ms-excel' ,
422- } )
429+ mockReadWorkspaceFileRecordByKey . mockResolvedValue ( { file } )
430+ const segments = file . key . split ( '/' )
431+ return GET (
432+ new NextRequest ( `http://localhost:3000/api/files/serve/${ file . key } ${ query } ` , { headers } ) ,
433+ createRouteContext ( { path : segments } )
434+ )
435+ }
423436
424- const response = await GET (
425- createMockRequest ( {
426- url : 'http://localhost:3000/api/files/serve/workspace/test-workspace-id/export.csv' ,
427- } ) ,
428- createRouteContext ( { path : [ 'workspace' , 'test-workspace-id' , 'export.csv' ] } )
437+ it ( 'streams a file past the transfer ceiling instead of refusing it' , async ( ) => {
438+ const response = await serveWorkspaceFile ( video )
439+
440+ expect ( response . status ) . toBe ( 200 )
441+ expect ( response . headers . get ( 'Content-Length' ) ) . toBe ( String ( video . size ) )
442+ expect ( response . headers . get ( 'Content-Type' ) ) . toBe ( 'video/mp4' )
443+ expect ( response . headers . get ( 'Accept-Ranges' ) ) . toBe ( 'bytes' )
444+ expect ( await response . text ( ) ) . toBe ( 'stored bytes' )
445+ expect ( mockStreamAuthorizedWorkspaceFile ) . toHaveBeenCalledWith (
446+ expect . objectContaining ( { file : video , principal, range : undefined } )
447+ )
448+ expect ( fileUtilsServerMockFns . mockDownloadServableFileFromStorage ) . not . toHaveBeenCalled ( )
449+ } )
450+
451+ it ( 'rechecks access through the same use case that admitted the file' , async ( ) => {
452+ await serveWorkspaceFile ( video )
453+ const [ { authorize } ] = mockStreamAuthorizedWorkspaceFile . mock . calls [ 0 ]
454+ await authorize ( )
455+ expect ( mockAuthorizeWorkspaceFileRecordByKey ) . toHaveBeenCalledWith (
456+ expect . objectContaining ( {
457+ principal,
458+ input : { key : video . key , assertedWorkspaceId : 'test-workspace-id' } ,
459+ } )
460+ )
461+ } )
462+
463+ it ( 'answers a byte range with 206 and reads only that span' , async ( ) => {
464+ const response = await serveWorkspaceFile ( video , { Range : 'bytes=100-199' } )
465+
466+ expect ( response . status ) . toBe ( 206 )
467+ expect ( response . headers . get ( 'Content-Range' ) ) . toBe ( `bytes 100-199/${ video . size } ` )
468+ expect ( response . headers . get ( 'Content-Length' ) ) . toBe ( '100' )
469+ expect ( mockStreamAuthorizedWorkspaceFile ) . toHaveBeenCalledWith (
470+ expect . objectContaining ( { range : { start : 100 , end : 199 } } )
429471 )
472+ } )
473+
474+ it ( 'refuses a range past the end with 416 without opening storage' , async ( ) => {
475+ const response = await serveWorkspaceFile ( video , { Range : `bytes=${ video . size } -` } )
476+
477+ expect ( response . status ) . toBe ( 416 )
478+ expect ( response . headers . get ( 'Content-Range' ) ) . toBe ( `bytes */${ video . size } ` )
479+ expect ( mockStreamAuthorizedWorkspaceFile ) . not . toHaveBeenCalled ( )
480+ } )
481+
482+ it ( 'answers a revalidation of unchanged bytes with 304 without opening storage' , async ( ) => {
483+ const first = await serveWorkspaceFile ( video )
484+ const etag = first . headers . get ( 'ETag' )
485+ expect ( etag ) . toBeTruthy ( )
486+ mockStreamAuthorizedWorkspaceFile . mockClear ( )
487+
488+ const response = await serveWorkspaceFile ( video , { 'If-None-Match' : etag ?? '' } )
489+
490+ expect ( response . status ) . toBe ( 304 )
491+ expect ( mockStreamAuthorizedWorkspaceFile ) . not . toHaveBeenCalled ( )
492+ } )
493+
494+ it ( 'stops matching once a content write moves the file to new bytes' , async ( ) => {
495+ const etag = ( await serveWorkspaceFile ( video ) ) . headers . get ( 'ETag' ) ?? ''
496+ const rewritten = {
497+ ...video ,
498+ key : 'workspace/test-workspace-id/clip-v2.mp4' ,
499+ contentUpdatedAt : new Date ( '2026-11-01T00:00:00Z' ) ,
500+ }
501+
502+ const revalidated = await serveWorkspaceFile ( rewritten , { 'If-None-Match' : etag } )
503+ const resumed = await serveWorkspaceFile ( rewritten , { Range : 'bytes=0-9' , 'If-Range' : etag } )
504+
505+ expect ( revalidated . status ) . toBe ( 200 )
506+ expect ( resumed . status ) . toBe ( 200 )
507+ expect ( resumed . headers . get ( 'Content-Length' ) ) . toBe ( String ( video . size ) )
508+ } )
509+
510+ it ( 'serves stored bytes under the type their extension names, not the recorded type' , async ( ) => {
511+ const response = await serveWorkspaceFile ( {
512+ ...video ,
513+ name : 'export.csv' ,
514+ key : 'workspace/test-workspace-id/export.csv' ,
515+ type : 'application/vnd.ms-excel' ,
516+ size : 3 ,
517+ } )
430518
431519 expect ( response . status ) . toBe ( 200 )
432520 expect ( response . headers . get ( 'Content-Type' ) ) . toBe ( 'text/csv' )
433521 } )
522+
523+ it ( 'streams the raw source of a page rather than rendering it' , async ( ) => {
524+ mockGetContentType . mockReturnValue ( 'text/html' )
525+ const response = await serveWorkspaceFile (
526+ { ...video , name : 'page.html' , key : 'workspace/test-workspace-id/page.html' , size : 12 } ,
527+ { } ,
528+ '?raw=1'
529+ )
530+
531+ expect ( response . status ) . toBe ( 200 )
532+ expect ( await response . text ( ) ) . toBe ( 'stored bytes' )
533+ } )
534+
535+ it . each ( [
536+ [ 'a generated document' , { name : 'report.pdf' , type : 'text/x-pdflibjs' } , '' ] ,
537+ [ 'a page' , { name : 'page.html' , type : 'text/html' } , '' ] ,
538+ [
539+ 'a preview, which may be a HEIC transcode' ,
540+ { name : 'photo.jpg' , type : 'image/jpeg' } ,
541+ '?preview=1' ,
542+ ] ,
543+ ] ) ( 'keeps %s on the buffered path' , async ( _label , override , query ) => {
544+ fileUtilsServerMockFns . mockDownloadServableFileFromStorage . mockResolvedValue ( {
545+ buffer : Buffer . from ( 'rendered' ) ,
546+ contentType : 'application/octet-stream' ,
547+ } )
548+ await serveWorkspaceFile (
549+ { ...video , ...override , key : `workspace/test-workspace-id/${ override . name } ` , size : 8 } ,
550+ { } ,
551+ query
552+ )
553+
554+ expect ( mockStreamAuthorizedWorkspaceFile ) . not . toHaveBeenCalled ( )
555+ expect ( fileUtilsServerMockFns . mockDownloadServableFileFromStorage ) . toHaveBeenCalled ( )
556+ } )
557+
558+ describe ( 'outside the workspace-file model' , ( ) => {
559+ const key = 'workspace/ws/1790000000000-0123456789abcdef-clip.mp4'
560+
561+ function serveAttachment ( headers : Record < string , string > = { } ) {
562+ mockIsUsingCloudStorage . mockReturnValue ( true )
563+ storageServiceMockFns . mockDownloadFileStream . mockImplementation ( async ( ) =>
564+ Readable . from ( [ Buffer . from ( 'attachment' ) ] )
565+ )
566+ return GET (
567+ new NextRequest ( `http://localhost:3000/api/files/serve/${ key } ` , { headers } ) ,
568+ createRouteContext ( { path : key . split ( '/' ) } )
569+ )
570+ }
571+
572+ it ( 'streams a range of an attachment sized by storage, never buffering it' , async ( ) => {
573+ storageServiceMockFns . mockHeadObject . mockResolvedValue ( {
574+ size : 500_000_000 ,
575+ version : '"v1"' ,
576+ } )
577+
578+ const response = await serveAttachment ( { Range : 'bytes=0-1023' } )
579+
580+ expect ( response . status ) . toBe ( 206 )
581+ expect ( response . headers . get ( 'Content-Range' ) ) . toBe ( 'bytes 0-1023/500000000' )
582+ expect ( response . headers . get ( 'ETag' ) ) . toBeTruthy ( )
583+ expect ( await response . text ( ) ) . toBe ( 'attachment' )
584+ expect ( storageServiceMockFns . mockDownloadFileStream ) . toHaveBeenCalledWith ( {
585+ key,
586+ context : 'mothership' ,
587+ range : { start : 0 , end : 1023 } ,
588+ } )
589+ expect ( storageServiceMockFns . mockDownloadFile ) . not . toHaveBeenCalled ( )
590+ } )
591+
592+ it ( 'still checks access before asking storage anything' , async ( ) => {
593+ mockVerifyFileAccess . mockResolvedValue ( false )
594+
595+ const response = await serveAttachment ( )
596+
597+ expect ( response . status ) . toBe ( 404 )
598+ expect ( storageServiceMockFns . mockHeadObject ) . not . toHaveBeenCalled ( )
599+ expect ( storageServiceMockFns . mockDownloadFileStream ) . not . toHaveBeenCalled ( )
600+ } )
601+
602+ it ( 'answers 404 for an object storage no longer has' , async ( ) => {
603+ storageServiceMockFns . mockHeadObject . mockResolvedValue ( null )
604+
605+ const response = await serveAttachment ( )
606+
607+ expect ( response . status ) . toBe ( 404 )
608+ expect ( storageServiceMockFns . mockDownloadFileStream ) . not . toHaveBeenCalled ( )
609+ } )
610+
611+ it ( 'sends everything when storage offers no version to resume against' , async ( ) => {
612+ storageServiceMockFns . mockHeadObject . mockResolvedValue ( { size : 10 } )
613+
614+ const response = await serveAttachment ( { Range : 'bytes=0-4' , 'If-Range' : '"v1"' } )
615+
616+ expect ( response . status ) . toBe ( 200 )
617+ expect ( response . headers . get ( 'ETag' ) ) . toBeNull ( )
618+ expect ( response . headers . get ( 'Content-Length' ) ) . toBe ( '10' )
619+ } )
620+ } )
434621 } )
435622} )
0 commit comments