@@ -578,3 +578,76 @@ describe('handleAttachmentPathRequest DELETE', () => {
578578 }
579579 } ) ;
580580} ) ;
581+
582+ // #1119 attachment presence pass: a client that cannot stop a response body early must be
583+ // able to ask whether a blob is still there without downloading it.
584+ describe ( 'handleAttachmentPathRequest HEAD' , ( ) => {
585+ const withSandbox = async (
586+ run : ( paths : { rootRealPath : string ; filePath : string } ) => Promise < void > ,
587+ ) : Promise < void > => {
588+ const sandbox = mkdtempSync ( join ( tmpdir ( ) , 'mindwtr-cloud-attachment-head-' ) ) ;
589+ try {
590+ const rootRealPath = join ( sandbox , 'attachments' ) ;
591+ mkdirSync ( rootRealPath , { recursive : true } ) ;
592+ await run ( { rootRealPath, filePath : join ( rootRealPath , 'file.bin' ) } ) ;
593+ } finally {
594+ rmSync ( sandbox , { recursive : true , force : true } ) ;
595+ }
596+ } ;
597+
598+ const head = ( paths : { rootRealPath : string ; filePath : string } ) => handleAttachmentPathRequest (
599+ new Request ( 'http://localhost/v1/attachments/file.bin' , { method : 'HEAD' } ) ,
600+ '/v1/attachments/file.bin' ,
601+ paths ,
602+ { maxAttachmentBytes : 1024 , abortSignal : new AbortController ( ) . signal } ,
603+ ) ;
604+
605+ test ( 'reports a stored attachment with its size and no body' , async ( ) => {
606+ await withSandbox ( async ( paths ) => {
607+ writeFileSync ( paths . filePath , 'attachment' ) ;
608+
609+ const response = await head ( paths ) ;
610+
611+ expect ( response . status ) . toBe ( 200 ) ;
612+ expect ( response . headers . get ( 'content-length' ) ) . toBe ( String ( 'attachment' . length ) ) ;
613+ expect ( response . headers . get ( 'content-type' ) ) . toBe ( 'application/octet-stream' ) ;
614+ expect ( await response . arrayBuffer ( ) ) . toHaveLength ( 0 ) ;
615+ } ) ;
616+ } ) ;
617+
618+ test ( 'answers 404 for an attachment that is not there' , async ( ) => {
619+ await withSandbox ( async ( paths ) => {
620+ const response = await head ( paths ) ;
621+ expect ( response . status ) . toBe ( 404 ) ;
622+ } ) ;
623+ } ) ;
624+
625+ test ( 'agrees with GET about status and size' , async ( ) => {
626+ await withSandbox ( async ( paths ) => {
627+ writeFileSync ( paths . filePath , 'attachment' ) ;
628+ const getResponse = await handleAttachmentPathRequest (
629+ new Request ( 'http://localhost/v1/attachments/file.bin' ) ,
630+ '/v1/attachments/file.bin' ,
631+ paths ,
632+ { maxAttachmentBytes : 1024 , abortSignal : new AbortController ( ) . signal } ,
633+ ) ;
634+ const headResponse = await head ( paths ) ;
635+
636+ expect ( headResponse . status ) . toBe ( getResponse . status ) ;
637+ expect ( headResponse . headers . get ( 'content-length' ) )
638+ . toBe ( String ( ( await getResponse . arrayBuffer ( ) ) . byteLength ) ) ;
639+ } ) ;
640+ } ) ;
641+
642+ test ( 'never leaves the attachment root, even through a symlink' , async ( ) => {
643+ await withSandbox ( async ( paths ) => {
644+ const outside = join ( paths . rootRealPath , '..' , 'outside.bin' ) ;
645+ writeFileSync ( outside , 'secret' ) ;
646+ symlinkSync ( outside , paths . filePath ) ;
647+
648+ const response = await head ( paths ) ;
649+
650+ expect ( response . status ) . toBe ( 400 ) ;
651+ } ) ;
652+ } ) ;
653+ } ) ;
0 commit comments