@@ -1242,3 +1242,219 @@ def hello():
12421242
12431243 # THEN it returns 404 (method mismatch is treated as not found)
12441244 assert captured ["status_code" ] == 404
1245+
1246+
1247+ # =============================================================================
1248+ # CORS Tests (issue #8267)
1249+ # =============================================================================
1250+
1251+
1252+ @pytest .mark .asyncio
1253+ async def test_cors_options_preflight_returns_204 ():
1254+ # GIVEN an app with CORSConfig and a POST route
1255+ from aws_lambda_powertools .event_handler .api_gateway import CORSConfig
1256+
1257+ app = HttpResolverLocal (cors = CORSConfig (allow_origin = "*" ))
1258+
1259+ @app .post ("/items" )
1260+ def create_item ():
1261+ return {"ok" : True }
1262+
1263+ # WHEN a browser sends a CORS preflight OPTIONS request
1264+ scope = {
1265+ "type" : "http" ,
1266+ "method" : "OPTIONS" ,
1267+ "path" : "/items" ,
1268+ "query_string" : b"" ,
1269+ "headers" : [
1270+ (b"origin" , b"http://localhost:3000" ),
1271+ (b"access-control-request-method" , b"POST" ),
1272+ ],
1273+ }
1274+
1275+ receive = make_asgi_receive ()
1276+ captured : dict [str , Any ] = {"status_code" : None , "headers" : []}
1277+
1278+ async def send (message : dict [str , Any ]) -> None :
1279+ await asyncio .sleep (0 )
1280+ if message ["type" ] == "http.response.start" :
1281+ captured ["status_code" ] = message ["status" ]
1282+ captured ["headers" ].extend (message .get ("headers" , []))
1283+
1284+ await app (scope , receive , send )
1285+
1286+ # THEN it returns 204 with CORS headers (not 500 or 404)
1287+ assert captured ["status_code" ] == 204
1288+
1289+ header_names = [name .lower () for name , _ in captured ["headers" ]]
1290+ assert b"access-control-allow-origin" in header_names
1291+ assert b"access-control-allow-methods" in header_names
1292+
1293+
1294+ @pytest .mark .asyncio
1295+ async def test_cors_options_preflight_with_exception_handler_does_not_return_500 ():
1296+ # GIVEN an app with CORSConfig and a generic exception handler that returns 500
1297+ import json
1298+
1299+ from aws_lambda_powertools .event_handler .api_gateway import CORSConfig
1300+
1301+ app = HttpResolverLocal (cors = CORSConfig (allow_origin = "*" ))
1302+
1303+ @app .post ("/items" )
1304+ def create_item ():
1305+ return {"ok" : True }
1306+
1307+ @app .exception_handler (Exception )
1308+ def handle_server_error (ex : Exception ):
1309+ return Response (
1310+ status_code = 500 ,
1311+ content_type = "application/json" ,
1312+ body = json .dumps ({"error" : "internal" }),
1313+ )
1314+
1315+ # WHEN a browser sends a CORS preflight OPTIONS request
1316+ scope = {
1317+ "type" : "http" ,
1318+ "method" : "OPTIONS" ,
1319+ "path" : "/items" ,
1320+ "query_string" : b"" ,
1321+ "headers" : [
1322+ (b"origin" , b"http://localhost:3000" ),
1323+ (b"access-control-request-method" , b"POST" ),
1324+ ],
1325+ }
1326+
1327+ receive = make_asgi_receive ()
1328+ captured : dict [str , Any ] = {"status_code" : None , "headers" : []}
1329+
1330+ async def send (message : dict [str , Any ]) -> None :
1331+ await asyncio .sleep (0 )
1332+ if message ["type" ] == "http.response.start" :
1333+ captured ["status_code" ] = message ["status" ]
1334+ captured ["headers" ].extend (message .get ("headers" , []))
1335+
1336+ await app (scope , receive , send )
1337+
1338+ # THEN the OPTIONS request returns 204, not 500
1339+ assert captured ["status_code" ] == 204
1340+ header_names = [name .lower () for name , _ in captured ["headers" ]]
1341+ assert b"access-control-allow-origin" in header_names
1342+
1343+
1344+ @pytest .mark .asyncio
1345+ async def test_no_cors_options_returns_404 ():
1346+ # GIVEN an app WITHOUT CORSConfig
1347+ app = HttpResolverLocal ()
1348+
1349+ @app .post ("/items" )
1350+ def create_item ():
1351+ return {"ok" : True }
1352+
1353+ # WHEN a browser sends an OPTIONS request (no CORS configured)
1354+ scope = {
1355+ "type" : "http" ,
1356+ "method" : "OPTIONS" ,
1357+ "path" : "/items" ,
1358+ "query_string" : b"" ,
1359+ "headers" : [],
1360+ }
1361+
1362+ receive = make_asgi_receive ()
1363+ send , captured = make_asgi_send ()
1364+
1365+ await app (scope , receive , send )
1366+
1367+ # THEN it returns 404 (no CORS config, no special handling)
1368+ assert captured ["status_code" ] == 404
1369+
1370+
1371+ @pytest .mark .asyncio
1372+ async def test_cors_options_includes_allowed_methods_header ():
1373+ # GIVEN an app with CORSConfig and multiple routes
1374+ from aws_lambda_powertools .event_handler .api_gateway import CORSConfig
1375+
1376+ app = HttpResolverLocal (cors = CORSConfig (allow_origin = "https://example.com" ))
1377+
1378+ @app .get ("/resource" )
1379+ def get_resource ():
1380+ return {"method" : "GET" }
1381+
1382+ @app .post ("/resource" )
1383+ def post_resource ():
1384+ return {"method" : "POST" }
1385+
1386+ # WHEN an OPTIONS preflight is sent
1387+ scope = {
1388+ "type" : "http" ,
1389+ "method" : "OPTIONS" ,
1390+ "path" : "/resource" ,
1391+ "query_string" : b"" ,
1392+ "headers" : [
1393+ (b"origin" , b"https://example.com" ),
1394+ (b"access-control-request-method" , b"GET" ),
1395+ ],
1396+ }
1397+
1398+ receive = make_asgi_receive ()
1399+ captured : dict [str , Any ] = {"status_code" : None , "headers" : []}
1400+
1401+ async def send (message : dict [str , Any ]) -> None :
1402+ await asyncio .sleep (0 )
1403+ if message ["type" ] == "http.response.start" :
1404+ captured ["status_code" ] = message ["status" ]
1405+ captured ["headers" ].extend (message .get ("headers" , []))
1406+
1407+ await app (scope , receive , send )
1408+
1409+ # THEN 204 is returned with Access-Control-Allow-Methods header
1410+ assert captured ["status_code" ] == 204
1411+ allow_methods_headers = [v for name , v in captured ["headers" ] if name .lower () == b"access-control-allow-methods" ]
1412+ assert len (allow_methods_headers ) == 1
1413+
1414+
1415+ @pytest .mark .asyncio
1416+ async def test_cors_disallowed_header_not_in_allow_headers ():
1417+ # GIVEN an app with CORSConfig that only allows specific headers
1418+ from aws_lambda_powertools .event_handler .api_gateway import CORSConfig
1419+
1420+ app = HttpResolverLocal (cors = CORSConfig (allow_origin = "*" , allow_headers = ["X-Custom-Allowed" ]))
1421+
1422+ @app .post ("/items" )
1423+ def create_item ():
1424+ return {"ok" : True }
1425+
1426+ # WHEN a preflight requests an unlisted header
1427+ scope = {
1428+ "type" : "http" ,
1429+ "method" : "OPTIONS" ,
1430+ "path" : "/items" ,
1431+ "query_string" : b"" ,
1432+ "headers" : [
1433+ (b"origin" , b"http://localhost:3000" ),
1434+ (b"access-control-request-method" , b"POST" ),
1435+ (b"access-control-request-headers" , b"X-Not-Allowed" ),
1436+ ],
1437+ }
1438+
1439+ receive = make_asgi_receive ()
1440+ captured : dict [str , Any ] = {"status_code" : None , "headers" : []}
1441+
1442+ async def send (message : dict [str , Any ]) -> None :
1443+ await asyncio .sleep (0 )
1444+ if message ["type" ] == "http.response.start" :
1445+ captured ["status_code" ] = message ["status" ]
1446+ captured ["headers" ].extend (message .get ("headers" , []))
1447+
1448+ await app (scope , receive , send )
1449+
1450+ # THEN the server still returns 204 (browser enforces the rejection, not the server)
1451+ assert captured ["status_code" ] == 204
1452+
1453+ # AND the unlisted header is absent from Access-Control-Allow-Headers
1454+ allow_headers_value = next (
1455+ (v .decode () for name , v in captured ["headers" ] if name .lower () == b"access-control-allow-headers" ),
1456+ "" ,
1457+ )
1458+ assert "X-Not-Allowed" not in allow_headers_value
1459+ # AND the explicitly allowed header IS present
1460+ assert "X-Custom-Allowed" in allow_headers_value
0 commit comments