@@ -4,6 +4,11 @@ import { vi } from 'vitest'
44import { S3ProtocolHandler } from '../../../storage/protocols/s3/s3-handler'
55import { Uploader } from '../../../storage/uploader'
66import CompleteMultipartUpload from './commands/complete-multipart-upload'
7+ import ListMultipartUploads from './commands/list-multipart-uploads'
8+ import ListObjects from './commands/list-objects'
9+ import ListParts from './commands/list-parts'
10+ import UploadPart from './commands/upload-part'
11+ import UploadPartCopy from './commands/upload-part-copy'
712import { getRouter , type RouteQuery , Router , type S3Router } from './router'
813
914afterEach ( ( ) => {
@@ -665,6 +670,36 @@ describe('S3ProtocolHandler.parseMetadataHeaders', () => {
665670} )
666671
667672describe ( 'CompleteMultipartUpload route mapping' , ( ) => {
673+ it . each ( [
674+ [ '' , false ] ,
675+ [ 0 , false ] ,
676+ [ 1 , true ] ,
677+ [ 10_000 , true ] ,
678+ [ 10_001 , false ] ,
679+ ] ) ( 'validates body PartNumber %s against the S3 range' , ( partNumber , expected ) => {
680+ const router = new Router ( )
681+ CompleteMultipartUpload ( router as unknown as S3Router )
682+
683+ const route = router
684+ . routes ( )
685+ . get ( '/:Bucket/*' )
686+ ?. find ( ( candidate ) => candidate . method === 'post' && candidate . type === undefined )
687+
688+ expect ( route ) . toBeDefined ( )
689+ expect (
690+ route ! . validate ( {
691+ Params : { Bucket : 'bucket' , '*' : 'object' } ,
692+ Querystring : { uploadId : 'upload-id' } ,
693+ Headers : { authorization : 'authorization' } ,
694+ Body : {
695+ CompleteMultipartUpload : {
696+ Part : [ { PartNumber : partNumber , ETag : 'etag' } ] ,
697+ } ,
698+ } ,
699+ } )
700+ ) . toBe ( expected )
701+ } )
702+
668703 it ( 'maps ChecksumCRC32C from the backend response on iceberg routes' , async ( ) => {
669704 const router = new Router ( )
670705 const completeMultipartUpload = vi . fn ( ) . mockResolvedValue ( {
@@ -732,6 +767,148 @@ describe('CompleteMultipartUpload route mapping', () => {
732767 } )
733768} )
734769
770+ describe ( 'UploadPart route validation' , ( ) => {
771+ it . each ( [
772+ [ 0 , false ] ,
773+ [ 1 , true ] ,
774+ [ '1' , true ] ,
775+ [ 1.5 , false ] ,
776+ [ '1.5' , false ] ,
777+ [ 10_000 , true ] ,
778+ [ 10_001 , false ] ,
779+ ] ) ( 'validates query partNumber %s as an integer within the S3 range' , ( partNumber , expected ) => {
780+ const router = new Router ( )
781+ UploadPart ( router as unknown as S3Router )
782+
783+ const route = router
784+ . routes ( )
785+ . get ( '/:Bucket/*' )
786+ ?. find ( ( candidate ) => candidate . method === 'put' && candidate . type === undefined )
787+
788+ expect ( route ) . toBeDefined ( )
789+ expect (
790+ route ! . validate ( {
791+ Params : { Bucket : 'bucket' , '*' : 'object' } ,
792+ Querystring : { uploadId : 'upload-id' , partNumber } ,
793+ } )
794+ ) . toBe ( expected )
795+ } )
796+ } )
797+
798+ describe ( 'UploadPartCopy route validation' , ( ) => {
799+ it . each ( [
800+ [ 0 , false ] ,
801+ [ 1 , true ] ,
802+ [ '1' , true ] ,
803+ [ 1.5 , false ] ,
804+ [ '1.5' , false ] ,
805+ [ 10_000 , true ] ,
806+ [ 10_001 , false ] ,
807+ ] ) ( 'validates query partNumber %s as an integer within the S3 range' , ( partNumber , expected ) => {
808+ const router = new Router ( )
809+ UploadPartCopy ( router as unknown as S3Router )
810+
811+ const route = router . routes ( ) . get ( '/:Bucket/*' ) ?. [ 0 ]
812+
813+ expect ( route ) . toBeDefined ( )
814+ expect (
815+ route ! . validate ( {
816+ Params : { Bucket : 'bucket' , '*' : 'object' } ,
817+ Querystring : { uploadId : 'upload-id' , partNumber } ,
818+ Headers : { 'x-amz-copy-source' : '/source-bucket/source-key' } ,
819+ } )
820+ ) . toBe ( expected )
821+ } )
822+ } )
823+
824+ describe ( 'ListParts route validation' , ( ) => {
825+ it . each ( [
826+ [ 0 , false ] ,
827+ [ 1 , true ] ,
828+ [ '1' , true ] ,
829+ [ 1.5 , false ] ,
830+ [ '1.5' , false ] ,
831+ [ 1_000 , true ] ,
832+ [ 1_001 , false ] ,
833+ ] ) ( 'validates max-parts %s as an integer within the S3 range' , ( maxParts , expected ) => {
834+ const router = new Router ( )
835+ ListParts ( router as unknown as S3Router )
836+
837+ const route = router
838+ . routes ( )
839+ . get ( '/:Bucket/*' )
840+ ?. find ( ( candidate ) => candidate . method === 'get' && candidate . type === undefined )
841+
842+ expect ( route ) . toBeDefined ( )
843+ expect (
844+ route ! . validate ( {
845+ Params : { Bucket : 'bucket' , '*' : 'object' } ,
846+ Querystring : { uploadId : 'upload-id' , 'max-parts' : maxParts } ,
847+ } )
848+ ) . toBe ( expected )
849+ } )
850+ } )
851+
852+ describe ( 'ListMultipartUploads route validation' , ( ) => {
853+ it . each ( [
854+ [ 0 , false ] ,
855+ [ 1 , true ] ,
856+ [ 1.5 , false ] ,
857+ [ 1_000 , true ] ,
858+ [ 1_001 , false ] ,
859+ ] ) ( 'validates max-uploads %s against the S3 range' , ( maxUploads , expected ) => {
860+ const router = new Router ( )
861+ ListMultipartUploads ( router as unknown as S3Router )
862+
863+ const route = router . routes ( ) . get ( '/:Bucket' ) ?. [ 0 ]
864+
865+ expect ( route ) . toBeDefined ( )
866+ expect (
867+ route ! . validate ( {
868+ Params : { Bucket : 'bucket' } ,
869+ Querystring : { uploads : '' , 'max-uploads' : maxUploads } ,
870+ } )
871+ ) . toBe ( expected )
872+ } )
873+ } )
874+
875+ describe . each ( [
876+ [ 'V1' , false ] ,
877+ [ 'V2' , true ] ,
878+ ] ) ( 'ListObjects %s route validation' , ( _version , isV2 ) => {
879+ it . each ( [
880+ [ - 1 , false ] ,
881+ [ 0 , true ] ,
882+ [ 1 , true ] ,
883+ [ '1' , true ] ,
884+ [ 1.5 , false ] ,
885+ [ '1.5' , false ] ,
886+ ] ) ( 'validates max-keys %s as a non-negative integer' , ( maxKeys , expected ) => {
887+ const router = new Router ( )
888+ ListObjects ( router as unknown as S3Router )
889+
890+ const route = router
891+ . routes ( )
892+ . get ( '/:Bucket' )
893+ ?. find ( ( candidate ) =>
894+ isV2
895+ ? candidate . querystringMatches . some ( ( match ) => match . key === 'list-type' )
896+ : candidate . querystringMatches . some ( ( match ) => match . key === '*' )
897+ )
898+
899+ expect ( route ) . toBeDefined ( )
900+ expect (
901+ route ! . validate ( {
902+ Params : { Bucket : 'bucket' } ,
903+ Querystring : {
904+ ...( isV2 ? { 'list-type' : '2' } : { } ) ,
905+ 'max-keys' : maxKeys ,
906+ } ,
907+ } )
908+ ) . toBe ( expected )
909+ } )
910+ } )
911+
735912describe ( 'DeleteObject route mapping' , ( ) => {
736913 it ( 'accepts DeleteObjects payloads at the object request cap in router validation' , async ( ) => {
737914 const { default : DeleteObject } = await import ( './commands/delete-object' )
0 commit comments