@@ -3682,3 +3682,253 @@ def upload(file_data: Annotated[UploadFile, File(description="A file")]):
36823682 props = schema_dict ["components" ]["schemas" ][schema_name ]["properties" ]
36833683 assert props ["file_data" ]["type" ] == "string"
36843684 assert props ["file_data" ]["format" ] == "binary"
3685+
3686+
3687+ def test_multipart_missing_boundary (gw_event ):
3688+ """Test that missing boundary in content-type raises ValueError."""
3689+ from aws_lambda_powertools .event_handler .openapi .params import File
3690+
3691+ app = APIGatewayRestResolver (enable_validation = True )
3692+
3693+ @app .post ("/upload" )
3694+ def upload (file_data : Annotated [bytes , File ()]):
3695+ return {"size" : len (file_data )}
3696+
3697+ gw_event ["httpMethod" ] = "POST"
3698+ gw_event ["path" ] = "/upload"
3699+ gw_event ["headers" ]["content-type" ] = "multipart/form-data" # no boundary
3700+ gw_event ["body" ] = base64 .b64encode (b"some data" ).decode ()
3701+ gw_event ["isBase64Encoded" ] = True
3702+
3703+ with pytest .raises (ValueError , match = "Missing boundary" ):
3704+ app (gw_event , {})
3705+
3706+
3707+ def test_multipart_quoted_boundary (gw_event ):
3708+ """Test that boundary with quotes is parsed correctly."""
3709+ from aws_lambda_powertools .event_handler .openapi .params import File
3710+
3711+ app = APIGatewayRestResolver (enable_validation = True )
3712+
3713+ @app .post ("/upload" )
3714+ def upload (file_data : Annotated [bytes , File ()]):
3715+ return {"size" : len (file_data )}
3716+
3717+ boundary = "----TestBoundary"
3718+ body , _ = _build_multipart_body (
3719+ [
3720+ {"name" : "file_data" , "value" : b"hello" , "filename" : "test.txt" },
3721+ ],
3722+ boundary = boundary ,
3723+ )
3724+
3725+ gw_event ["httpMethod" ] = "POST"
3726+ gw_event ["path" ] = "/upload"
3727+ # Use quoted boundary
3728+ gw_event ["headers" ]["content-type" ] = f'multipart/form-data; boundary="{ boundary } "'
3729+ gw_event ["body" ] = body
3730+ gw_event ["isBase64Encoded" ] = True
3731+
3732+ result = app (gw_event , {})
3733+ assert result ["statusCode" ] == 200
3734+ assert json .loads (result ["body" ]) == {"size" : 5 }
3735+
3736+
3737+ def test_multipart_multiple_values_same_field (gw_event ):
3738+ """Test multiple values for the same field name are collected as list."""
3739+ from aws_lambda_powertools .event_handler .openapi .params import File , UploadFile
3740+
3741+ app = APIGatewayRestResolver (enable_validation = True )
3742+
3743+ @app .post ("/upload" )
3744+ def upload (file_data : Annotated [List [UploadFile ], File ()]):
3745+ return {"count" : len (file_data ), "filenames" : [f .filename for f in file_data ]}
3746+
3747+ # Build body with two parts having the same field name
3748+ boundary = "----TestBoundary"
3749+ raw = (
3750+ f"--{ boundary } \r \n "
3751+ f'Content-Disposition: form-data; name="file_data"; filename="a.txt"\r \n '
3752+ f"\r \n "
3753+ f"content a\r \n "
3754+ f"--{ boundary } \r \n "
3755+ f'Content-Disposition: form-data; name="file_data"; filename="b.txt"\r \n '
3756+ f"\r \n "
3757+ f"content b\r \n "
3758+ f"--{ boundary } --\r \n "
3759+ ).encode ()
3760+
3761+ gw_event ["httpMethod" ] = "POST"
3762+ gw_event ["path" ] = "/upload"
3763+ gw_event ["headers" ]["content-type" ] = f"multipart/form-data; boundary={ boundary } "
3764+ gw_event ["body" ] = base64 .b64encode (raw ).decode ()
3765+ gw_event ["isBase64Encoded" ] = True
3766+
3767+ result = app (gw_event , {})
3768+ assert result ["statusCode" ] == 200
3769+ parsed = json .loads (result ["body" ])
3770+ assert parsed ["count" ] == 2
3771+ assert parsed ["filenames" ] == ["a.txt" , "b.txt" ]
3772+
3773+
3774+ def test_multipart_three_values_same_field (gw_event ):
3775+ """Test three or more values for same field name builds onto existing list."""
3776+ from aws_lambda_powertools .event_handler .openapi .params import File , UploadFile
3777+
3778+ app = APIGatewayRestResolver (enable_validation = True )
3779+
3780+ @app .post ("/upload" )
3781+ def upload (file_data : Annotated [List [UploadFile ], File ()]):
3782+ return {"count" : len (file_data ), "filenames" : [f .filename for f in file_data ]}
3783+
3784+ boundary = "----TestBoundary"
3785+ raw = (
3786+ f"--{ boundary } \r \n "
3787+ f'Content-Disposition: form-data; name="file_data"; filename="a.txt"\r \n '
3788+ f"\r \n "
3789+ f"aaa\r \n "
3790+ f"--{ boundary } \r \n "
3791+ f'Content-Disposition: form-data; name="file_data"; filename="b.txt"\r \n '
3792+ f"\r \n "
3793+ f"bbb\r \n "
3794+ f"--{ boundary } \r \n "
3795+ f'Content-Disposition: form-data; name="file_data"; filename="c.txt"\r \n '
3796+ f"\r \n "
3797+ f"ccc\r \n "
3798+ f"--{ boundary } --\r \n "
3799+ ).encode ()
3800+
3801+ gw_event ["httpMethod" ] = "POST"
3802+ gw_event ["path" ] = "/upload"
3803+ gw_event ["headers" ]["content-type" ] = f"multipart/form-data; boundary={ boundary } "
3804+ gw_event ["body" ] = base64 .b64encode (raw ).decode ()
3805+ gw_event ["isBase64Encoded" ] = True
3806+
3807+ result = app (gw_event , {})
3808+ assert result ["statusCode" ] == 200
3809+ parsed = json .loads (result ["body" ])
3810+ assert parsed ["count" ] == 3
3811+ assert parsed ["filenames" ] == ["a.txt" , "b.txt" , "c.txt" ]
3812+
3813+
3814+ def test_multipart_part_without_headers_separator (gw_event ):
3815+ """Test that a malformed part missing the header/body separator is skipped."""
3816+ from aws_lambda_powertools .event_handler .openapi .params import File , UploadFile
3817+
3818+ app = APIGatewayRestResolver (enable_validation = True )
3819+
3820+ @app .post ("/upload" )
3821+ def upload (file_data : Annotated [UploadFile , File ()]):
3822+ return {"filename" : file_data .filename }
3823+
3824+ # Build a body with one malformed part (no \r\n\r\n) and one valid part
3825+ boundary = "----TestBoundary"
3826+ raw = (
3827+ f"--{ boundary } \r \n "
3828+ f"This part has no header separator at all\r \n "
3829+ f"--{ boundary } \r \n "
3830+ f'Content-Disposition: form-data; name="file_data"; filename="good.txt"\r \n '
3831+ f"\r \n "
3832+ f"good content\r \n "
3833+ f"--{ boundary } --\r \n "
3834+ ).encode ()
3835+
3836+ gw_event ["httpMethod" ] = "POST"
3837+ gw_event ["path" ] = "/upload"
3838+ gw_event ["headers" ]["content-type" ] = f"multipart/form-data; boundary={ boundary } "
3839+ gw_event ["body" ] = base64 .b64encode (raw ).decode ()
3840+ gw_event ["isBase64Encoded" ] = True
3841+
3842+ result = app (gw_event , {})
3843+ assert result ["statusCode" ] == 200
3844+ parsed = json .loads (result ["body" ])
3845+ assert parsed ["filename" ] == "good.txt"
3846+
3847+
3848+ def test_multipart_part_without_field_name (gw_event ):
3849+ """Test that a part missing the name parameter in Content-Disposition is skipped."""
3850+ from aws_lambda_powertools .event_handler .openapi .params import File , UploadFile
3851+
3852+ app = APIGatewayRestResolver (enable_validation = True )
3853+
3854+ @app .post ("/upload" )
3855+ def upload (file_data : Annotated [UploadFile , File ()]):
3856+ return {"filename" : file_data .filename }
3857+
3858+ # Build a body with one part that has no name= param and one valid part
3859+ boundary = "----TestBoundary"
3860+ raw = (
3861+ f"--{ boundary } \r \n "
3862+ f"Content-Disposition: form-data\r \n "
3863+ f"\r \n "
3864+ f"orphan content\r \n "
3865+ f"--{ boundary } \r \n "
3866+ f'Content-Disposition: form-data; name="file_data"; filename="valid.txt"\r \n '
3867+ f"\r \n "
3868+ f"valid content\r \n "
3869+ f"--{ boundary } --\r \n "
3870+ ).encode ()
3871+
3872+ gw_event ["httpMethod" ] = "POST"
3873+ gw_event ["path" ] = "/upload"
3874+ gw_event ["headers" ]["content-type" ] = f"multipart/form-data; boundary={ boundary } "
3875+ gw_event ["body" ] = base64 .b64encode (raw ).decode ()
3876+ gw_event ["isBase64Encoded" ] = True
3877+
3878+ result = app (gw_event , {})
3879+ assert result ["statusCode" ] == 200
3880+ parsed = json .loads (result ["body" ])
3881+ assert parsed ["filename" ] == "valid.txt"
3882+
3883+
3884+ def test_upload_file_validate_error ():
3885+ """Test UploadFile._validate raises ValueError for non-UploadFile values."""
3886+ from aws_lambda_powertools .event_handler .openapi .params import UploadFile
3887+
3888+ with pytest .raises (ValueError , match = "Expected UploadFile, got str" ):
3889+ UploadFile ._validate ("not an upload file" )
3890+
3891+ with pytest .raises (ValueError , match = "Expected UploadFile, got int" ):
3892+ UploadFile ._validate (42 )
3893+
3894+
3895+ def test_multipart_unclosed_quote_in_header ():
3896+ """Test that _extract_header_param returns None when quote is unclosed."""
3897+ from aws_lambda_powertools .event_handler .middlewares .openapi_validation import _extract_header_param
3898+
3899+ # name=" is present but closing quote is missing
3900+ result = _extract_header_param ('Content-Disposition: form-data; name="broken' , "name" )
3901+ assert result is None
3902+
3903+
3904+ def test_multipart_generic_parse_error (gw_event ):
3905+ """Test that non-ValueError exceptions during multipart parsing produce 422."""
3906+ from unittest .mock import patch
3907+
3908+ from aws_lambda_powertools .event_handler .openapi .params import File , UploadFile
3909+
3910+ app = APIGatewayRestResolver (enable_validation = True )
3911+
3912+ @app .post ("/upload" )
3913+ def upload (file_data : Annotated [UploadFile , File ()]):
3914+ return {"filename" : file_data .filename }
3915+
3916+ body_b64 , content_type = _build_multipart_body (
3917+ [{"name" : "file_data" , "value" : b"data" , "filename" : "test.txt" }],
3918+ )
3919+
3920+ gw_event ["httpMethod" ] = "POST"
3921+ gw_event ["path" ] = "/upload"
3922+ gw_event ["headers" ]["content-type" ] = content_type
3923+ gw_event ["body" ] = body_b64
3924+ gw_event ["isBase64Encoded" ] = True
3925+
3926+ # Patch _parse_multipart_body to raise a non-ValueError (e.g. TypeError)
3927+ with patch (
3928+ "aws_lambda_powertools.event_handler.middlewares.openapi_validation._parse_multipart_body" ,
3929+ side_effect = TypeError ("unexpected type" ),
3930+ ):
3931+ result = app (gw_event , {})
3932+ assert result ["statusCode" ] == 422
3933+ body = json .loads (result ["body" ])
3934+ assert body ["detail" ][0 ]["type" ] == "multipart_invalid"
0 commit comments