@@ -18,6 +18,7 @@ import { withDeleteEnabled } from './utils/storage'
1818const { jwtSecret, serviceKeyAsync, tenantId } = getConfig ( )
1919const anonKey = process . env . ANON_KEY || ''
2020const S3Backend = backends . S3Backend
21+ const TEST_OWNER_ID = '317eadce-631a-4429-a0bb-f19a7a517b4a'
2122let appInstance : FastifyInstance
2223
2324let tnx : Knex . Transaction | undefined
@@ -35,6 +36,19 @@ async function getSuperuserPostgrestClient() {
3536 return tnx
3637}
3738
39+ async function seedObjectForRouteTest ( name : string , bucketId = 'bucket2' ) {
40+ const seedTx = await getSuperuserPostgrestClient ( )
41+ await seedTx . from < Obj > ( 'objects' ) . insert ( {
42+ bucket_id : bucketId ,
43+ name,
44+ owner : TEST_OWNER_ID ,
45+ version : `seed-version-${ randomUUID ( ) } ` ,
46+ metadata : { mimetype : 'image/png' , size : 1234 } ,
47+ } )
48+ await seedTx . commit ( )
49+ tnx = undefined
50+ }
51+
3852useMockObject ( )
3953useMockQueue ( )
4054
@@ -1455,6 +1469,56 @@ describe('testing copy object', () => {
14551469 expect ( response . statusCode ) . toBe ( 400 )
14561470 expect ( S3Backend . prototype . copyObject ) . not . toHaveBeenCalled ( )
14571471 } )
1472+
1473+ test ( 'can copy objects when keys include ASCII URL-reserved characters' , async ( ) => {
1474+ const sourceKey = `authenticated/copy-src-${ randomUUID ( ) } -q?foo=1&bar=%25+plus;semi:colon,.png`
1475+ const destinationKey = `authenticated/copy-dst-${ randomUUID ( ) } -q?foo=2&bar=%25+plus;semi:colon,.png`
1476+ await seedObjectForRouteTest ( sourceKey )
1477+
1478+ const response = await appInstance . inject ( {
1479+ method : 'POST' ,
1480+ url : '/object/copy' ,
1481+ headers : {
1482+ authorization : `Bearer ${ process . env . AUTHENTICATED_KEY } ` ,
1483+ } ,
1484+ payload : {
1485+ bucketId : 'bucket2' ,
1486+ sourceKey,
1487+ destinationKey,
1488+ } ,
1489+ } )
1490+
1491+ expect ( response . statusCode ) . toBe ( 200 )
1492+ expect ( S3Backend . prototype . copyObject ) . toBeCalled ( )
1493+ const jsonResponse = response . json ( )
1494+ expect ( jsonResponse . Key ) . toBe ( `bucket2/${ destinationKey } ` )
1495+ expect ( jsonResponse . name ) . toBe ( destinationKey )
1496+ } )
1497+
1498+ test ( 'can copy objects when keys include Unicode and URL-reserved characters' , async ( ) => {
1499+ const sourceKey = `authenticated/copy-src-${ randomUUID ( ) } -일이삼-🙂-q?foo=1&bar=%25+plus;semi:colon,.png`
1500+ const destinationKey = `authenticated/copy-dst-${ randomUUID ( ) } -éè-中文-🙂-q?foo=2&bar=%25+plus;semi:colon,.png`
1501+ await seedObjectForRouteTest ( sourceKey )
1502+
1503+ const response = await appInstance . inject ( {
1504+ method : 'POST' ,
1505+ url : '/object/copy' ,
1506+ headers : {
1507+ authorization : `Bearer ${ process . env . AUTHENTICATED_KEY } ` ,
1508+ } ,
1509+ payload : {
1510+ bucketId : 'bucket2' ,
1511+ sourceKey,
1512+ destinationKey,
1513+ } ,
1514+ } )
1515+
1516+ expect ( response . statusCode ) . toBe ( 200 )
1517+ expect ( S3Backend . prototype . copyObject ) . toBeCalled ( )
1518+ const jsonResponse = response . json ( )
1519+ expect ( jsonResponse . Key ) . toBe ( `bucket2/${ destinationKey } ` )
1520+ expect ( jsonResponse . name ) . toBe ( destinationKey )
1521+ } )
14581522} )
14591523
14601524/**
@@ -2458,6 +2522,72 @@ describe('testing move object', () => {
24582522 expect ( S3Backend . prototype . copyObject ) . not . toHaveBeenCalled ( )
24592523 expect ( S3Backend . prototype . deleteObject ) . not . toHaveBeenCalled ( )
24602524 } )
2525+
2526+ test ( 'can move objects when keys include ASCII URL-reserved characters' , async ( ) => {
2527+ const sourceKey = `authenticated/move-src-${ randomUUID ( ) } -q?foo=1&bar=%25+plus;semi:colon,.png`
2528+ const destinationKey = `authenticated/move-dst-${ randomUUID ( ) } -q?foo=2&bar=%25+plus;semi:colon,.png`
2529+ await seedObjectForRouteTest ( sourceKey )
2530+
2531+ const response = await appInstance . inject ( {
2532+ method : 'POST' ,
2533+ url : '/object/move' ,
2534+ payload : {
2535+ bucketId : 'bucket2' ,
2536+ sourceKey,
2537+ destinationKey,
2538+ } ,
2539+ headers : {
2540+ authorization : `Bearer ${ process . env . AUTHENTICATED_KEY } ` ,
2541+ } ,
2542+ } )
2543+
2544+ expect ( response . statusCode ) . toBe ( 200 )
2545+ expect ( S3Backend . prototype . copyObject ) . toHaveBeenCalled ( )
2546+ expect ( S3Backend . prototype . deleteObjects ) . toHaveBeenCalled ( )
2547+ expect ( response . json ( ) . message ) . toBe ( 'Successfully moved' )
2548+
2549+ const conn = await getSuperuserPostgrestClient ( )
2550+ const movedObject = await conn
2551+ . table ( 'objects' )
2552+ . select ( 'name' )
2553+ . where ( 'bucket_id' , 'bucket2' )
2554+ . where ( 'name' , destinationKey )
2555+ . first ( )
2556+ expect ( movedObject ?. name ) . toBe ( destinationKey )
2557+ } )
2558+
2559+ test ( 'can move objects when keys include Unicode and URL-reserved characters' , async ( ) => {
2560+ const sourceKey = `authenticated/move-src-${ randomUUID ( ) } -일이삼-🙂-q?foo=1&bar=%25+plus;semi:colon,.png`
2561+ const destinationKey = `authenticated/move-dst-${ randomUUID ( ) } -éè-中文-🙂-q?foo=2&bar=%25+plus;semi:colon,.png`
2562+ await seedObjectForRouteTest ( sourceKey )
2563+
2564+ const response = await appInstance . inject ( {
2565+ method : 'POST' ,
2566+ url : '/object/move' ,
2567+ payload : {
2568+ bucketId : 'bucket2' ,
2569+ sourceKey,
2570+ destinationKey,
2571+ } ,
2572+ headers : {
2573+ authorization : `Bearer ${ process . env . AUTHENTICATED_KEY } ` ,
2574+ } ,
2575+ } )
2576+
2577+ expect ( response . statusCode ) . toBe ( 200 )
2578+ expect ( S3Backend . prototype . copyObject ) . toHaveBeenCalled ( )
2579+ expect ( S3Backend . prototype . deleteObjects ) . toHaveBeenCalled ( )
2580+ expect ( response . json ( ) . message ) . toBe ( 'Successfully moved' )
2581+
2582+ const conn = await getSuperuserPostgrestClient ( )
2583+ const movedObject = await conn
2584+ . table ( 'objects' )
2585+ . select ( 'name' )
2586+ . where ( 'bucket_id' , 'bucket2' )
2587+ . where ( 'name' , destinationKey )
2588+ . first ( )
2589+ expect ( movedObject ?. name ) . toBe ( destinationKey )
2590+ } )
24612591} )
24622592
24632593describe ( 'testing list objects' , ( ) => {
@@ -3007,6 +3137,38 @@ describe('Object key names with Unicode characters', () => {
30073137 expect ( getResponse . headers [ 'etag' ] ) . toBe ( 'abc' )
30083138 } )
30093139
3140+ test ( 'can upload and read HEAD info with a Unicode and URL-reserved key' , async ( ) => {
3141+ const objectName = `head-${ randomUUID ( ) } -일이삼-🙂-q?foo=1&bar=%25+plus;semi:colon,#frag.jpg`
3142+ const authorization = `Bearer ${ await serviceKeyAsync } `
3143+ const path = './src/test/assets/sadcat.jpg'
3144+ const { size } = fs . statSync ( path )
3145+
3146+ const uploadResponse = await appInstance . inject ( {
3147+ method : 'PUT' ,
3148+ url : `/object/bucket2/${ encodeURIComponent ( objectName ) } ` ,
3149+ headers : {
3150+ authorization,
3151+ 'Content-Length' : size ,
3152+ 'Content-Type' : 'image/jpeg' ,
3153+ } ,
3154+ payload : fs . createReadStream ( path ) ,
3155+ } )
3156+ expect ( uploadResponse . statusCode ) . toBe ( 200 )
3157+
3158+ const headResponse = await appInstance . inject ( {
3159+ method : 'HEAD' ,
3160+ url : `/object/authenticated/bucket2/${ encodeURIComponent ( objectName ) } ` ,
3161+ headers : {
3162+ authorization,
3163+ } ,
3164+ } )
3165+ expect ( headResponse . statusCode ) . toBe ( 200 )
3166+ expect ( headResponse . headers [ 'etag' ] ) . toBe ( 'abc' )
3167+ expect ( headResponse . headers [ 'last-modified' ] ) . toBeTruthy ( )
3168+ expect ( headResponse . headers [ 'content-length' ] ) . toBeTruthy ( )
3169+ expect ( headResponse . headers [ 'cache-control' ] ) . toBe ( 'no-cache' )
3170+ } )
3171+
30103172 test ( 'should not upload if the name contains invalid characters' , async ( ) => {
30113173 const invalidObjectName = getInvalidObjectName ( )
30123174 const authorization = `Bearer ${ await serviceKeyAsync } `
@@ -3124,6 +3286,90 @@ describe('Object key names with Unicode characters', () => {
31243286 expect ( objectResponse ?. name ) . toBe ( objectName )
31253287 } )
31263288
3289+ test ( 'can generate and use a signed download URL with Unicode and URL-reserved characters' , async ( ) => {
3290+ const objectName = `signed-download-reserved-${ randomUUID ( ) } -일이삼-🙂-q?foo=1&bar=%25+plus;semi:colon,#frag.png`
3291+ const authorization = `Bearer ${ await serviceKeyAsync } `
3292+
3293+ const form = new FormData ( )
3294+ form . append ( 'file' , fs . createReadStream ( `./src/test/assets/sadcat.jpg` ) )
3295+ const uploadHeaders = Object . assign ( { } , form . getHeaders ( ) , {
3296+ authorization,
3297+ 'x-upsert' : 'true' ,
3298+ } )
3299+
3300+ const uploadResponse = await appInstance . inject ( {
3301+ method : 'POST' ,
3302+ url : `/object/bucket2/${ encodeURIComponent ( objectName ) } ` ,
3303+ headers : uploadHeaders ,
3304+ payload : form ,
3305+ } )
3306+ expect ( uploadResponse . statusCode ) . toBe ( 200 )
3307+
3308+ const signResponse = await appInstance . inject ( {
3309+ method : 'POST' ,
3310+ url : `/object/sign/bucket2/${ encodeURIComponent ( objectName ) } ` ,
3311+ headers : {
3312+ authorization,
3313+ } ,
3314+ payload : {
3315+ expiresIn : 60 ,
3316+ } ,
3317+ } )
3318+ expect ( signResponse . statusCode ) . toBe ( 200 )
3319+
3320+ const signedURL = signResponse . json < { signedURL : string } > ( ) . signedURL
3321+ const signedURLParsed = new URL ( signedURL , 'http://localhost' )
3322+ const token = signedURLParsed . searchParams . get ( 'token' )
3323+ expect ( token ) . toBeTruthy ( )
3324+
3325+ const getResponse = await appInstance . inject ( {
3326+ method : 'GET' ,
3327+ url : `${ signedURLParsed . pathname } ${ signedURLParsed . search } ` ,
3328+ } )
3329+ expect ( getResponse . statusCode ) . toBe ( 200 )
3330+ expect ( getResponse . headers [ 'etag' ] ) . toBe ( 'abc' )
3331+ } )
3332+
3333+ test ( 'can generate and use a signed upload URL with Unicode and URL-reserved characters' , async ( ) => {
3334+ const objectName = `signed-upload-reserved-${ randomUUID ( ) } -éè-中文-🙂-q?foo=1&bar=%25+plus;semi:colon,#frag.png`
3335+ const authorization = `Bearer ${ await serviceKeyAsync } `
3336+
3337+ const signedUploadResponse = await appInstance . inject ( {
3338+ method : 'POST' ,
3339+ url : `/object/upload/sign/bucket2/${ encodeURIComponent ( objectName ) } ` ,
3340+ headers : {
3341+ authorization,
3342+ } ,
3343+ } )
3344+ expect ( signedUploadResponse . statusCode ) . toBe ( 200 )
3345+
3346+ const token = signedUploadResponse . json < { token : string } > ( ) . token
3347+ expect ( token ) . toBeTruthy ( )
3348+
3349+ const form = new FormData ( )
3350+ form . append ( 'file' , fs . createReadStream ( `./src/test/assets/sadcat.jpg` ) )
3351+ const uploadResponse = await appInstance . inject ( {
3352+ method : 'PUT' ,
3353+ url : `/object/upload/sign/bucket2/${ encodeURIComponent ( objectName ) } ?token=${ token } ` ,
3354+ headers : {
3355+ ...form . getHeaders ( ) ,
3356+ } ,
3357+ payload : form ,
3358+ } )
3359+ expect ( uploadResponse . statusCode ) . toBe ( 200 )
3360+
3361+ const db = await getSuperuserPostgrestClient ( )
3362+ const objectResponse = await db
3363+ . from < Obj > ( 'objects' )
3364+ . select ( '*' )
3365+ . where ( {
3366+ name : objectName ,
3367+ bucket_id : 'bucket2' ,
3368+ } )
3369+ . first ( )
3370+ expect ( objectResponse ?. name ) . toBe ( objectName )
3371+ } )
3372+
31273373 test ( 'should not generate signed upload URL for invalid key' , async ( ) => {
31283374 const invalidObjectName = getInvalidObjectName ( )
31293375 const authorization = `Bearer ${ await serviceKeyAsync } `
0 commit comments