@@ -563,6 +563,155 @@ describe('Storage REST Routes', () => {
563563 } ) ;
564564 } ) ;
565565
566+ // ── [#22431] The unclaimed class: neither an attachments scope nor a field
567+ // owner. It has no parent record to derive access from, so the download
568+ // asks for one thing only — a signed-in caller — unless the file declares
569+ // `acl: 'public_read'` (ADR-0104's one anonymous download).
570+ describe ( 'a file with neither an attachments scope nor a field owner needs a signed-in caller (#22431)' , ( ) => {
571+ const DOORS = [ '/api/v1/storage/files/:fileId/url' , '/api/v1/storage/files/:fileId' ] as const ;
572+
573+ const commit = async ( s : StorageMetadataStore , rec : Partial < import ( './metadata-store.js' ) . FileRecord > ) =>
574+ s . createFile ( {
575+ id : rec . id ?? 'u-dl' ,
576+ key : rec . key ?? `user/${ rec . id ?? 'u-dl' } .png` ,
577+ name : 'x.png' ,
578+ status : 'committed' ,
579+ acl : 'private' ,
580+ scope : 'user' ,
581+ ...rec ,
582+ } as any ) ;
583+
584+ function serverWith ( resolveSession : any , extra : any = { } ) {
585+ const server = createMockHttpServer ( ) ;
586+ const s = new StorageMetadataStore ( null ) ;
587+ const authorizeFileRead = vi . fn ( async ( ) => 'deny' as const ) ;
588+ const logger = { info : vi . fn ( ) , warn : vi . fn ( ) } ;
589+ registerStorageRoutes ( server as any , adapter , s , {
590+ basePath : '/api/v1/storage' ,
591+ resolveSession,
592+ authorizeFileRead,
593+ logger,
594+ downloadTtl : 120 ,
595+ ...extra ,
596+ } ) ;
597+ return { server, store : s , authorizeFileRead, logger } ;
598+ }
599+
600+ const hit = async ( server : any , path : string , fileId : string ) => {
601+ const res = createMockRes ( ) ;
602+ await server . _getHandler ( 'GET' , path ) ! ( createMockReq ( { params : { fileId } } ) , res ) ;
603+ return res ;
604+ } ;
605+
606+ /** Seconds of life the minted local capability URL carries. */
607+ const ttlOf = ( url : string ) =>
608+ adapter . verifyToken ( url . split ( '/' ) . pop ( ) ! , 'get' ) . exp - Math . floor ( Date . now ( ) / 1000 ) ;
609+
610+ it ( 'refuses an anonymous caller 401 AUTH_REQUIRED at both download doors, minting no URL' , async ( ) => {
611+ const resolveSession = vi . fn ( async ( ) => null ) ;
612+ const { server, store : s , authorizeFileRead } = serverWith ( resolveSession ) ;
613+ await commit ( s , { id : 'u1' } ) ;
614+ const minted = vi . spyOn ( adapter , 'getPresignedDownload' ) ;
615+ for ( const door of DOORS ) {
616+ const res = await hit ( server , door , 'u1' ) ;
617+ expect ( res . _status , door ) . toBe ( 401 ) ;
618+ expect ( res . _json ?. success , door ) . toBe ( false ) ;
619+ expect ( res . _json ?. error ?. code , door ) . toBe ( 'AUTH_REQUIRED' ) ;
620+ expect ( res . _headers . Location , door ) . toBeUndefined ( ) ;
621+ }
622+ expect ( minted ) . not . toHaveBeenCalled ( ) ;
623+ expect ( resolveSession ) . toHaveBeenCalledTimes ( 2 ) ;
624+ // Not the parent-governed authorizer's file: there is no parent record.
625+ expect ( authorizeFileRead ) . not . toHaveBeenCalled ( ) ;
626+ minted . mockRestore ( ) ;
627+ } ) ;
628+
629+ it ( 'answers the same code and status the upload doors give an anonymous caller' , async ( ) => {
630+ const { server, store : s } = serverWith ( async ( ) => null ) ;
631+ await commit ( s , { id : 'u2' } ) ;
632+ const download = await hit ( server , DOORS [ 0 ] , 'u2' ) ;
633+ const upload = createMockRes ( ) ;
634+ await server . _getHandler ( 'POST' , '/api/v1/storage/upload/presigned' ) ! (
635+ createMockReq ( { body : { filename : 'a.png' , mimeType : 'image/png' , size : 3 } } ) ,
636+ upload ,
637+ ) ;
638+ expect ( [ download . _status , download . _json ?. error ?. code ] ) . toEqual ( [ upload . _status , upload . _json ?. error ?. code ] ) ;
639+ } ) ;
640+
641+ it ( 'fails closed when the session resolver throws, or resolves a session with no user' , async ( ) => {
642+ for ( const resolveSession of [
643+ async ( ) => { throw new Error ( 'auth backend down' ) ; } ,
644+ async ( ) => ( { organizationId : 'org-1' } ) ,
645+ ] ) {
646+ const { server, store : s } = serverWith ( resolveSession ) ;
647+ await commit ( s , { id : 'u3' } ) ;
648+ for ( const door of DOORS ) {
649+ const res = await hit ( server , door , 'u3' ) ;
650+ expect ( res . _status , door ) . toBe ( 401 ) ;
651+ expect ( res . _json ?. error ?. code , door ) . toBe ( 'AUTH_REQUIRED' ) ;
652+ }
653+ }
654+ } ) ;
655+
656+ it ( 'serves a signed-in caller as before: a 302 and a URL carrying the presigned TTL' , async ( ) => {
657+ const { server, store : s , authorizeFileRead } = serverWith ( async ( ) => ( { userId : 'user-7' } ) , { presignedTtl : 1800 } ) ;
658+ await commit ( s , { id : 'u4' } ) ;
659+ const url = await hit ( server , DOORS [ 0 ] , 'u4' ) ;
660+ expect ( url . _status ) . toBe ( 200 ) ;
661+ expect ( url . _json . data . url ) . toContain ( '/_local/raw/' ) ;
662+ const ttl = ttlOf ( url . _json . data . url ) ;
663+ expect ( ttl ) . toBeGreaterThan ( 1700 ) ;
664+ expect ( ttl ) . toBeLessThanOrEqual ( 1800 ) ;
665+ const redirect = await hit ( server , DOORS [ 1 ] , 'u4' ) ;
666+ expect ( redirect . _status ) . toBe ( 302 ) ;
667+ expect ( redirect . _headers . Location ) . toContain ( '/_local/raw/' ) ;
668+ expect ( authorizeFileRead ) . not . toHaveBeenCalled ( ) ;
669+ } ) ;
670+
671+ it ( "keeps an acl: 'public_read' file anonymous without asking for a session" , async ( ) => {
672+ const resolveSession = vi . fn ( async ( ) => null ) ;
673+ const { server, store : s } = serverWith ( resolveSession ) ;
674+ await commit ( s , { id : 'u5' , acl : 'public_read' } ) ;
675+ expect ( ( await hit ( server , DOORS [ 0 ] , 'u5' ) ) . _status ) . toBe ( 200 ) ;
676+ expect ( ( await hit ( server , DOORS [ 1 ] , 'u5' ) ) . _status ) . toBe ( 302 ) ;
677+ expect ( resolveSession ) . not . toHaveBeenCalled ( ) ;
678+ } ) ;
679+
680+ it ( 'leaves the parent-governed classes to the authorizer: attachments-scope and field-owned verdicts are unchanged' , async ( ) => {
681+ const resolveSession = vi . fn ( async ( ) => ( { userId : 'user-7' } ) ) ;
682+ const { server, store : s , authorizeFileRead } = serverWith ( resolveSession ) ;
683+ await commit ( s , { id : 'g1' , scope : 'attachments' , key : 'attachments/g1.bin' } ) ;
684+ await commit ( s , { id : 'g2' , ref_object : 'product' , ref_id : 'p1' , ref_field : 'image' } ) ;
685+ const attached = await hit ( server , DOORS [ 0 ] , 'g1' ) ;
686+ expect ( attached . _status ) . toBe ( 403 ) ;
687+ expect ( attached . _json ?. error ?. code ) . toBe ( 'ATTACHMENT_DOWNLOAD_DENIED' ) ;
688+ const owned = await hit ( server , DOORS [ 0 ] , 'g2' ) ;
689+ expect ( owned . _status ) . toBe ( 403 ) ;
690+ expect ( owned . _json ?. error ?. code ) . toBe ( 'FILE_DOWNLOAD_DENIED' ) ;
691+ expect ( authorizeFileRead ) . toHaveBeenCalledTimes ( 2 ) ;
692+ expect ( resolveSession ) . not . toHaveBeenCalled ( ) ;
693+ } ) ;
694+
695+ it ( 'answers a missing file 404 before the session is asked' , async ( ) => {
696+ const resolveSession = vi . fn ( async ( ) => null ) ;
697+ const { server } = serverWith ( resolveSession ) ;
698+ const res = await hit ( server , DOORS [ 0 ] , 'missing' ) ;
699+ expect ( res . _status ) . toBe ( 404 ) ;
700+ expect ( res . _json ?. error ?. code ) . toBe ( 'FILE_NOT_FOUND' ) ;
701+ expect ( resolveSession ) . not . toHaveBeenCalled ( ) ;
702+ } ) ;
703+
704+ it ( 'keeps a kernel with no session resolver open, and says so once' , async ( ) => {
705+ const { server, store : s , logger } = serverWith ( undefined ) ;
706+ await commit ( s , { id : 'u6' } ) ;
707+ expect ( ( await hit ( server , DOORS [ 1 ] , 'u6' ) ) . _status ) . toBe ( 302 ) ;
708+ expect ( ( await hit ( server , DOORS [ 0 ] , 'u6' ) ) . _status ) . toBe ( 200 ) ;
709+ const notices = logger . info . mock . calls . map ( ( c ) => String ( c [ 0 ] ) ) . filter ( ( m ) => m . includes ( 'no session resolver wired' ) ) ;
710+ expect ( notices ) . toHaveLength ( 1 ) ;
711+ expect ( notices [ 0 ] ) . toContain ( 'download' ) ;
712+ } ) ;
713+ } ) ;
714+
566715 describe ( 'PUT/GET /_local/raw/:token' , ( ) => {
567716 it ( 'should accept raw upload with valid token and serve download' , async ( ) => {
568717 // Generate a presigned upload
@@ -644,14 +793,16 @@ describe('Storage REST Routes', () => {
644793 expect ( file ?. owner_id ) . toBe ( 'user-42' ) ;
645794 } ) ;
646795
647- it ( 'download routes stay open even with a resolver wired (capability URLs) ' , async ( ) => {
796+ it ( 'download routes are not behind the upload gate: an anonymous read of a missing file is a 404 ' , async ( ) => {
648797 const server = registerWithResolver ( async ( ) => null ) ;
649798 const res = createMockRes ( ) ;
650799 await server . _getHandler ( 'GET' , '/api/v1/storage/files/:fileId/url' ) ! (
651800 createMockReq ( { params : { fileId : 'missing' } } ) ,
652801 res ,
653802 ) ;
654- expect ( res . _status ) . toBe ( 404 ) ; // not 401 — anonymous reads reach the handler
803+ // Not 401: the download doors look the file up first and judge the
804+ // caller per file class (#22431 — see the unclaimed-class block above).
805+ expect ( res . _status ) . toBe ( 404 ) ;
655806 } ) ;
656807
657808 it ( 'stays open (back-compat) when no resolver is wired' , async ( ) => {
0 commit comments