@@ -24,6 +24,7 @@ const mocks = vi.hoisted(() => {
2424 const mockIsUsingCloudStorage = vi . fn ( )
2525 const mockUploadFile = vi . fn ( )
2626 const mockUploadExecutionFile = vi . fn ( )
27+ const mockCheckStorageQuota = vi . fn ( )
2728
2829 return {
2930 mockVerifyFileAccess,
@@ -35,6 +36,7 @@ const mocks = vi.hoisted(() => {
3536 mockIsUsingCloudStorage,
3637 mockUploadFile,
3738 mockUploadExecutionFile,
39+ mockCheckStorageQuota,
3840 }
3941} )
4042
@@ -108,6 +110,10 @@ vi.mock('@/lib/uploads/setup.server', () => ({
108110 UPLOAD_DIR_SERVER : '/tmp/test-uploads' ,
109111} ) )
110112
113+ vi . mock ( '@/lib/billing/storage' , ( ) => ( {
114+ checkStorageQuota : mocks . mockCheckStorageQuota ,
115+ } ) )
116+
111117import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace'
112118import { POST } from '@/app/api/files/upload/route'
113119
@@ -183,6 +189,12 @@ function setupFileApiMocks(
183189 key : 'test-key' ,
184190 path : '/test/path' ,
185191 } )
192+
193+ mocks . mockCheckStorageQuota . mockResolvedValue ( {
194+ allowed : true ,
195+ currentUsage : 0 ,
196+ limit : Number . MAX_SAFE_INTEGER ,
197+ } )
186198}
187199
188200describe ( 'File Upload API Route' , ( ) => {
@@ -640,6 +652,125 @@ describe('File Upload Security Tests', () => {
640652 } )
641653 } )
642654
655+ describe ( 'Mothership Context Permission Gate' , ( ) => {
656+ const postMothershipUpload = async ( workspaceId : string | null = 'test-workspace-id' ) => {
657+ const formData = new FormData ( )
658+ const file = new File ( [ 'test content' ] , 'test.pdf' , { type : 'application/pdf' } )
659+ formData . append ( 'file' , file )
660+ formData . append ( 'context' , 'mothership' )
661+ if ( workspaceId !== null ) formData . append ( 'workspaceId' , workspaceId )
662+
663+ const req = new Request ( 'http://localhost/api/files/upload' , {
664+ method : 'POST' ,
665+ headers : { 'content-length' : '1024' } ,
666+ body : formData ,
667+ } )
668+
669+ return POST ( req as unknown as NextRequest )
670+ }
671+
672+ beforeEach ( ( ) => {
673+ setupFileApiMocks ( {
674+ cloudEnabled : false ,
675+ storageProvider : 'local' ,
676+ } )
677+ } )
678+
679+ it ( 'rejects mothership uploads without workspaceId' , async ( ) => {
680+ const response = await postMothershipUpload ( null )
681+
682+ expect ( response . status ) . toBe ( 400 )
683+ const data = await response . json ( )
684+ expect ( data . message ) . toContain ( 'workspaceId' )
685+ expect ( storageServiceMockFns . mockUploadFile ) . not . toHaveBeenCalled ( )
686+ } )
687+
688+ it ( 'rejects mothership uploads for a workspace the caller does not belong to' , async ( ) => {
689+ permissionsMockFns . mockGetUserEntityPermissions . mockResolvedValue ( null )
690+
691+ const response = await postMothershipUpload ( )
692+
693+ expect ( response . status ) . toBe ( 403 )
694+ const data = await response . json ( )
695+ expect ( data . error ) . toBe ( 'Write or Admin access required for mothership uploads' )
696+ expect ( storageServiceMockFns . mockUploadFile ) . not . toHaveBeenCalled ( )
697+ } )
698+
699+ it ( 'rejects mothership uploads for a read-only workspace member' , async ( ) => {
700+ permissionsMockFns . mockGetUserEntityPermissions . mockResolvedValue ( 'read' )
701+
702+ const response = await postMothershipUpload ( )
703+
704+ expect ( response . status ) . toBe ( 403 )
705+ expect ( storageServiceMockFns . mockUploadFile ) . not . toHaveBeenCalled ( )
706+ } )
707+
708+ it ( 'rejects mothership uploads over the caller storage quota' , async ( ) => {
709+ permissionsMockFns . mockGetUserEntityPermissions . mockResolvedValue ( 'write' )
710+ mocks . mockCheckStorageQuota . mockResolvedValue ( {
711+ allowed : false ,
712+ currentUsage : 100 ,
713+ limit : 100 ,
714+ error : 'Storage limit exceeded. Used: 0.00GB, Limit: 0GB' ,
715+ } )
716+
717+ const response = await postMothershipUpload ( )
718+
719+ expect ( response . status ) . toBe ( 413 )
720+ const data = await response . json ( )
721+ expect ( data . error ) . toContain ( 'Storage limit exceeded' )
722+ expect ( storageServiceMockFns . mockUploadFile ) . not . toHaveBeenCalled ( )
723+ } )
724+
725+ it ( 'allows mothership uploads for a write-permission workspace member' , async ( ) => {
726+ permissionsMockFns . mockGetUserEntityPermissions . mockResolvedValue ( 'write' )
727+
728+ const response = await postMothershipUpload ( )
729+
730+ expect ( response . status ) . toBe ( 200 )
731+ expect ( permissionsMockFns . mockGetUserEntityPermissions ) . toHaveBeenCalledWith (
732+ 'test-user-id' ,
733+ 'workspace' ,
734+ 'test-workspace-id'
735+ )
736+ expect ( storageServiceMockFns . mockUploadFile ) . toHaveBeenCalled ( )
737+ } )
738+
739+ it ( 'allows mothership uploads for an admin-permission workspace member' , async ( ) => {
740+ permissionsMockFns . mockGetUserEntityPermissions . mockResolvedValue ( 'admin' )
741+
742+ const response = await postMothershipUpload ( )
743+
744+ expect ( response . status ) . toBe ( 200 )
745+ expect ( storageServiceMockFns . mockUploadFile ) . toHaveBeenCalled ( )
746+ } )
747+
748+ it ( 'checks quota once against the combined size of a multi-file batch' , async ( ) => {
749+ permissionsMockFns . mockGetUserEntityPermissions . mockResolvedValue ( 'write' )
750+
751+ const formData = new FormData ( )
752+ const fileA = new File ( [ 'a' . repeat ( 10 ) ] , 'a.pdf' , { type : 'application/pdf' } )
753+ const fileB = new File ( [ 'b' . repeat ( 20 ) ] , 'b.pdf' , { type : 'application/pdf' } )
754+ formData . append ( 'file' , fileA )
755+ formData . append ( 'file' , fileB )
756+ formData . append ( 'context' , 'mothership' )
757+ formData . append ( 'workspaceId' , 'test-workspace-id' )
758+
759+ const req = new Request ( 'http://localhost/api/files/upload' , {
760+ method : 'POST' ,
761+ headers : { 'content-length' : '1024' } ,
762+ body : formData ,
763+ } )
764+
765+ const response = await POST ( req as unknown as NextRequest )
766+
767+ expect ( response . status ) . toBe ( 200 )
768+ expect ( mocks . mockCheckStorageQuota ) . toHaveBeenCalledTimes ( 1 )
769+ expect ( mocks . mockCheckStorageQuota ) . toHaveBeenCalledWith ( 'test-user-id' , 30 )
770+ expect ( permissionsMockFns . mockGetUserEntityPermissions ) . toHaveBeenCalledTimes ( 1 )
771+ } )
772+ } )
773+
643774 describe ( 'Authentication Requirements' , ( ) => {
644775 it ( 'should reject uploads without authentication' , async ( ) => {
645776 authMockFns . mockGetSession . mockResolvedValue ( null )
0 commit comments