@@ -638,3 +638,187 @@ describe('attachFileAs for a member who cannot read sys_file: the verb vouches f
638638 expect ( await rows ( ql , 'sys_attachment' ) ) . toHaveLength ( 1 ) ;
639639 } ) ;
640640} ) ;
641+
642+ describe ( 'attachUploadAs: a member attaches their own pending browser upload, and the commit is the attach' , ( ) => {
643+ // The same member posture as above: the create grant on `sys_attachment`,
644+ // nothing on `sys_file` (a non-system read of it is refused outright), and
645+ // Bob cannot read the parent object. The upload is seeded as the presigned
646+ // door leaves one: a `pending` row owned by the uploader in the
647+ // `attachments` scope, and its bytes at its key.
648+ const ALICE = { userId : 'usr_alice' , tenantId : 'org_a' , positions : [ 'org_member' ] } ;
649+ const BOB = { userId : 'usr_bob' , tenantId : 'org_a' , positions : [ 'org_member' ] } ;
650+ const asCaller = ( a : IFileAttachmentService ) => a as IFileAttachmentService & ICallerFileAttachmentService ;
651+ const READS = new Set ( [ 'find' , 'findOne' , 'count' , 'aggregate' ] ) ;
652+ const readRefusal = ( object : string ) =>
653+ Object . assign ( new Error ( `[Security] Access denied: read on ${ object } ` ) , {
654+ name : 'PermissionDeniedError' , code : 'PERMISSION_DENIED' , status : 403 , statusCode : 403 ,
655+ } ) ;
656+
657+ async function bootMember ( opts : { sharing ?: unknown ; security ?: unknown } = { } ) {
658+ const booted = await boot ( opts ) ;
659+ booted . ql . registerMiddleware ( async ( ctx : any , next : ( ) => Promise < void > ) => {
660+ if ( READS . has ( ctx . operation ) && ctx . context ?. isSystem !== true ) {
661+ if ( ctx . object === 'sys_file' ) throw readRefusal ( 'sys_file' ) ;
662+ if ( ctx . object === 'att_case' && ctx . context ?. userId === BOB . userId ) throw readRefusal ( 'att_case' ) ;
663+ }
664+ await next ( ) ;
665+ } ) ;
666+ return booted ;
667+ }
668+
669+ const BYTES = Buffer . from ( 'hello' ) ;
670+ /** A pending upload, as the presigned door and the raw PUT leave one. */
671+ async function seedUpload (
672+ ql : ObjectQL ,
673+ storage : IStorageService ,
674+ over : { id ?: string ; owner ?: string ; scope ?: string ; status ?: string ; bytes ?: boolean } = { } ,
675+ ) {
676+ const id = over . id ?? 'upl_alice' ;
677+ const scope = over . scope ?? 'attachments' ;
678+ const key = `${ scope } /${ id } .txt` ;
679+ await ql . insert ( 'sys_file' , {
680+ id, key, name : 'notes.txt' , mime_type : 'text/plain' , size : BYTES . byteLength , scope, acl : 'private' ,
681+ status : over . status ?? 'pending' , owner_id : over . owner ?? ALICE . userId , organization_id : 'org_a' ,
682+ } , SYS ) ;
683+ if ( over . bytes !== false ) await storage . upload ( key , BYTES , { contentType : 'text/plain' } ) ;
684+ return id ;
685+ }
686+ const target = ( fileId : string , over : Record < string , unknown > = { } ) => ( { fileId, parentObject : 'att_case' , parentId : 'case_a' , ...over } ) ;
687+ const answer = ( err : { code ?: string ; status ?: number ; message ?: string } ) => ( { code : err . code , status : err . status } ) ;
688+ const fileRow = async ( ql : ObjectQL , id : string ) => ( await rows ( ql , 'sys_file' ) ) . find ( ( f ) => f . id === id ) ;
689+
690+ /** A refused attach leaves the upload as it was: pending, bytes stored, nothing attached. */
691+ async function expectUploadUntouched ( ql : ObjectQL , storage : IStorageService , id : string ) {
692+ const file = await fileRow ( ql , id ) ;
693+ expect ( file ?. status ) . toBe ( 'pending' ) ;
694+ expect ( await storedKeys ( storage ) ) . toContain ( file ! . key ) ;
695+ expect ( await rows ( ql , 'sys_attachment' ) ) . toEqual ( [ ] ) ;
696+ }
697+
698+ it ( 'attaches as the member: the upload is committed and attached in one call, uploaded_by them, and sys_file stays unreadable to them' , async ( ) => {
699+ const { ql, attachments, storage } = await bootMember ( ) ;
700+ const fileId = await seedUpload ( ql , storage ) ;
701+ const out = await asCaller ( attachments ) . attachUploadAs ( target ( fileId , { eTag : 'etag-1' } ) , ALICE as never ) ;
702+ expect ( out ) . toEqual ( { fileId, attachmentId : expect . any ( String ) , size : BYTES . byteLength } ) ;
703+ expect ( await fileRow ( ql , fileId ) ) . toMatchObject ( { status : 'committed' , etag : 'etag-1' , owner_id : ALICE . userId } ) ;
704+ const links = await rows ( ql , 'sys_attachment' ) ;
705+ expect ( links ) . toHaveLength ( 1 ) ;
706+ expect ( links [ 0 ] ) . toMatchObject ( {
707+ id : out . attachmentId , parent_object : 'att_case' , parent_id : 'case_a' , file_id : fileId ,
708+ file_name : 'notes.txt' , uploaded_by : ALICE . userId , organization_id : 'org_a' ,
709+ } ) ;
710+ const err = await refusalOf ( ql . findOne ( 'sys_file' , { where : { id : fileId } , context : ALICE } ) ) ;
711+ expect ( answer ( err ) ) . toEqual ( { code : 'PERMISSION_DENIED' , status : 403 } ) ;
712+ } ) ;
713+
714+ it ( 'control: another user’s pending upload is refused with the upload ownership rule’s one answer, and is left as it was' , async ( ) => {
715+ const { ql, attachments, storage } = await bootMember ( ) ;
716+ const fileId = await seedUpload ( ql , storage , { id : 'upl_bob' , owner : BOB . userId } ) ;
717+ const err = await refusalOf ( asCaller ( attachments ) . attachUploadAs ( target ( fileId ) , ALICE as never ) ) ;
718+ expect ( answer ( err ) ) . toEqual ( { code : 'PERMISSION_DENIED' , status : 403 } ) ;
719+ await expectUploadUntouched ( ql , storage , fileId ) ;
720+ } ) ;
721+
722+ it ( 'control: an id that names no file gets exactly the answer another user’s upload gets' , async ( ) => {
723+ const { ql, attachments, storage } = await bootMember ( ) ;
724+ const others = await seedUpload ( ql , storage , { id : 'upl_bob' , owner : BOB . userId } ) ;
725+ const a = await refusalOf ( asCaller ( attachments ) . attachUploadAs ( target ( others ) , ALICE as never ) ) ;
726+ const b = await refusalOf ( asCaller ( attachments ) . attachUploadAs ( target ( 'upl_nowhere' ) , ALICE as never ) ) ;
727+ expect ( [ b . code , b . status , b . message ] ) . toEqual ( [ a . code , a . status , a . message ] ) ;
728+ expect ( await rows ( ql , 'sys_attachment' ) ) . toEqual ( [ ] ) ;
729+ } ) ;
730+
731+ it ( 'control: a committed upload is refused RESOURCE_CONFLICT / 409 — only the upload this call commits is vouched for' , async ( ) => {
732+ const { ql, attachments, storage } = await bootMember ( ) ;
733+ const fileId = await seedUpload ( ql , storage , { status : 'committed' } ) ;
734+ const err = await refusalOf ( asCaller ( attachments ) . attachUploadAs ( target ( fileId ) , ALICE as never ) ) ;
735+ expect ( answer ( err ) ) . toEqual ( { code : 'RESOURCE_CONFLICT' , status : 409 } ) ;
736+ expect ( await rows ( ql , 'sys_attachment' ) ) . toEqual ( [ ] ) ;
737+ } ) ;
738+
739+ it ( 'control: a pending upload something already holds is refused RESOURCE_CONFLICT / 409, and no second attachment is written' , async ( ) => {
740+ const { ql, attachments, storage } = await bootMember ( ) ;
741+ const fileId = await seedUpload ( ql , storage ) ;
742+ await ql . insert ( 'sys_attachment' , { parent_object : 'att_case' , parent_id : 'case_global' , file_id : fileId , file_name : 'n.txt' } , SYS ) ;
743+ const err = await refusalOf ( asCaller ( attachments ) . attachUploadAs ( target ( fileId ) , ALICE as never ) ) ;
744+ expect ( answer ( err ) ) . toEqual ( { code : 'RESOURCE_CONFLICT' , status : 409 } ) ;
745+ expect ( await rows ( ql , 'sys_attachment' ) ) . toHaveLength ( 1 ) ;
746+ expect ( ( await fileRow ( ql , fileId ) ) ?. status ) . toBe ( 'pending' ) ;
747+ } ) ;
748+
749+ it ( 'control: an upload outside the attachments scope is refused INVALID_REQUEST / 400' , async ( ) => {
750+ const { ql, attachments, storage } = await bootMember ( ) ;
751+ const fileId = await seedUpload ( ql , storage , { scope : 'user' } ) ;
752+ const err = await refusalOf ( asCaller ( attachments ) . attachUploadAs ( target ( fileId ) , ALICE as never ) ) ;
753+ expect ( answer ( err ) ) . toEqual ( { code : 'INVALID_REQUEST' , status : 400 } ) ;
754+ expect ( await rows ( ql , 'sys_attachment' ) ) . toEqual ( [ ] ) ;
755+ } ) ;
756+
757+ it ( 'control: an upload whose bytes the backend does not hold is refused RESOURCE_CONFLICT / 409 and stays pending' , async ( ) => {
758+ const { ql, attachments, storage } = await bootMember ( ) ;
759+ const fileId = await seedUpload ( ql , storage , { bytes : false } ) ;
760+ const err = await refusalOf ( asCaller ( attachments ) . attachUploadAs ( target ( fileId ) , ALICE as never ) ) ;
761+ expect ( answer ( err ) ) . toEqual ( { code : 'RESOURCE_CONFLICT' , status : 409 } ) ;
762+ expect ( ( await fileRow ( ql , fileId ) ) ?. status ) . toBe ( 'pending' ) ;
763+ expect ( await rows ( ql , 'sys_attachment' ) ) . toEqual ( [ ] ) ;
764+ } ) ;
765+
766+ it ( 'control: a member the attach rule refuses is refused ATTACHMENT_PARENT_ACCESS / 403, and the upload is left as it was' , async ( ) => {
767+ const sharing = { checkEdit : vi . fn ( async ( ) => 'deny' ) } ;
768+ const { ql, attachments, storage } = await bootMember ( { sharing } ) ;
769+ const fileId = await seedUpload ( ql , storage ) ;
770+ const err = await refusalOf ( asCaller ( attachments ) . attachUploadAs ( target ( fileId ) , ALICE as never ) ) ;
771+ expect ( answer ( err ) ) . toEqual ( { code : 'ATTACHMENT_PARENT_ACCESS' , status : 403 } ) ;
772+ await expectUploadUntouched ( ql , storage , fileId ) ;
773+ } ) ;
774+
775+ it ( 'control: a member without the permission to create an attachment is refused PERMISSION_DENIED / 403, and the upload is left as it was' , async ( ) => {
776+ const security = { explain : vi . fn ( async ( ) => ( { allowed : false } ) ) } ;
777+ const { ql, attachments, storage } = await bootMember ( { security } ) ;
778+ const fileId = await seedUpload ( ql , storage ) ;
779+ const err = await refusalOf ( asCaller ( attachments ) . attachUploadAs ( target ( fileId ) , ALICE as never ) ) ;
780+ expect ( answer ( err ) ) . toEqual ( { code : 'PERMISSION_DENIED' , status : 403 } ) ;
781+ expect ( security . explain ) . toHaveBeenCalledWith ( { object : 'sys_attachment' , operation : 'create' } , ALICE ) ;
782+ await expectUploadUntouched ( ql , storage , fileId ) ;
783+ } ) ;
784+
785+ it ( 'control: a parent object without enable.files is refused FILES_DISABLED / 403 by the insert’s own gate, and the upload is left as it was' , async ( ) => {
786+ const { ql, attachments, storage } = await bootMember ( ) ;
787+ const fileId = await seedUpload ( ql , storage ) ;
788+ const err = await refusalOf ( asCaller ( attachments ) . attachUploadAs ( target ( fileId , { parentObject : 'att_memo' , parentId : 'memo_a' } ) , ALICE as never ) ) ;
789+ expect ( answer ( err ) ) . toEqual ( { code : 'FILES_DISABLED' , status : 403 } ) ;
790+ await expectUploadUntouched ( ql , storage , fileId ) ;
791+ } ) ;
792+
793+ it ( 'control: a parent the member cannot read is refused, and the upload is left as it was' , async ( ) => {
794+ const { ql, attachments, storage } = await bootMember ( ) ;
795+ const fileId = await seedUpload ( ql , storage , { id : 'upl_bob' , owner : BOB . userId } ) ;
796+ const err = await refusalOf ( asCaller ( attachments ) . attachUploadAs ( target ( fileId ) , BOB as never ) ) ;
797+ expect ( err . status ) . toBe ( err . code === 'RECORD_NOT_FOUND' ? 404 : 403 ) ;
798+ expect ( [ 'RECORD_NOT_FOUND' , 'ATTACHMENT_PARENT_ACCESS' ] ) . toContain ( err . code ) ;
799+ await expectUploadUntouched ( ql , storage , fileId ) ;
800+ } ) ;
801+
802+ it ( 'the vouch is the verb’s call alone: after attachUploadAs, the member’s own insert naming the same file is refused reference_not_found' , async ( ) => {
803+ const { ql, attachments, storage } = await bootMember ( ) ;
804+ const fileId = await seedUpload ( ql , storage ) ;
805+ await asCaller ( attachments ) . attachUploadAs ( target ( fileId ) , ALICE as never ) ;
806+ const err = await refusalOf (
807+ ql . insert ( 'sys_attachment' , { parent_object : 'att_case' , parent_id : 'case_a' , file_id : fileId , file_name : 'direct.txt' } , { context : ALICE } ) ,
808+ ) ;
809+ const envelope = resolveThrownHttpError ( err ) ;
810+ expect ( [ envelope . code , envelope . status ] ) . toEqual ( [ 'VALIDATION_FAILED' , 400 ] ) ;
811+ expect ( ( ( err as any ) . fields ?? [ ] ) . map ( ( f : any ) => [ f . field , f . code ] ) ) . toEqual ( [ [ 'file_id' , 'reference_not_found' ] ] ) ;
812+ expect ( await rows ( ql , 'sys_attachment' ) ) . toHaveLength ( 1 ) ;
813+ } ) ;
814+
815+ it ( 'control: the system context and a context without a user are refused PERMISSION_DENIED / 403' , async ( ) => {
816+ const { ql, attachments, storage } = await bootMember ( ) ;
817+ const fileId = await seedUpload ( ql , storage ) ;
818+ for ( const context of [ { isSystem : true } , { tenantId : 'org_a' } ] ) {
819+ const err = await refusalOf ( asCaller ( attachments ) . attachUploadAs ( target ( fileId ) , context as never ) ) ;
820+ expect ( answer ( err ) ) . toEqual ( { code : 'PERMISSION_DENIED' , status : 403 } ) ;
821+ }
822+ await expectUploadUntouched ( ql , storage , fileId ) ;
823+ } ) ;
824+ } ) ;
0 commit comments