diff --git a/backend/main.py b/backend/main.py index 63bb1dd..18df781 100644 --- a/backend/main.py +++ b/backend/main.py @@ -120,7 +120,7 @@ app = FastAPI(lifespan=lifespan) app.add_middleware( CORSMiddleware, allow_origins=[config.FRONTEND_ORIGIN], - allow_methods=["POST"], + allow_methods=["GET", "HEAD", "OPTIONS", "POST"], allow_headers=["Content-Type", "Authorization"], ) diff --git a/tests/test_fix_007.py b/tests/test_fix_007.py new file mode 100644 index 0000000..3779fe0 --- /dev/null +++ b/tests/test_fix_007.py @@ -0,0 +1,155 @@ +""" +Tests for BATON-FIX-007: CORS OPTIONS preflight verification. + +Acceptance criteria: +1. OPTIONS preflight to /api/signal returns 200. +2. Preflight response includes Access-Control-Allow-Methods containing GET. +3. Preflight response includes Access-Control-Allow-Origin matching the configured origin. +4. Preflight response includes Access-Control-Allow-Headers with Authorization. +5. allow_methods in CORSMiddleware configuration explicitly contains GET. +""" +from __future__ import annotations + +import ast +from pathlib import Path + +import pytest + +from tests.conftest import make_app_client + +_FRONTEND_ORIGIN = "http://localhost:3000" +_BACKEND_DIR = Path(__file__).parent.parent / "backend" + +# --------------------------------------------------------------------------- +# Static check — CORSMiddleware config contains GET in allow_methods +# --------------------------------------------------------------------------- + + +def test_main_py_cors_allow_methods_contains_get() -> None: + """allow_methods в CORSMiddleware должен содержать 'GET'.""" + source = (_BACKEND_DIR / "main.py").read_text(encoding="utf-8") + tree = ast.parse(source, filename="main.py") + + for node in ast.walk(tree): + if isinstance(node, ast.Call): + func = node.func + if isinstance(func, ast.Name) and func.id == "add_middleware": + continue + if not ( + isinstance(func, ast.Attribute) and func.attr == "add_middleware" + ): + continue + for kw in node.keywords: + if kw.arg == "allow_methods": + if isinstance(kw.value, ast.List): + methods = [ + elt.value + for elt in kw.value.elts + if isinstance(elt, ast.Constant) and isinstance(elt.value, str) + ] + assert "GET" in methods, ( + f"allow_methods в CORSMiddleware не содержит 'GET': {methods}" + ) + return + + pytest.fail("add_middleware с CORSMiddleware и allow_methods не найден в main.py") + + +def test_main_py_cors_allow_methods_contains_post() -> None: + """allow_methods в CORSMiddleware должен содержать 'POST' (регрессия).""" + source = (_BACKEND_DIR / "main.py").read_text(encoding="utf-8") + assert '"POST"' in source or "'POST'" in source, ( + "allow_methods в CORSMiddleware не содержит 'POST'" + ) + + +# --------------------------------------------------------------------------- +# Functional — OPTIONS preflight request +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_options_preflight_signal_returns_200() -> None: + """OPTIONS preflight к /api/signal должен возвращать 200.""" + async with make_app_client() as client: + resp = await client.options( + "/api/signal", + headers={ + "Origin": _FRONTEND_ORIGIN, + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "Content-Type, Authorization", + }, + ) + assert resp.status_code == 200, ( + f"Preflight OPTIONS /api/signal вернул {resp.status_code}, ожидался 200" + ) + + +@pytest.mark.asyncio +async def test_options_preflight_allow_origin_header() -> None: + """OPTIONS preflight должен вернуть Access-Control-Allow-Origin.""" + async with make_app_client() as client: + resp = await client.options( + "/api/signal", + headers={ + "Origin": _FRONTEND_ORIGIN, + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "Content-Type, Authorization", + }, + ) + acao = resp.headers.get("access-control-allow-origin", "") + assert acao == _FRONTEND_ORIGIN, ( + f"Ожидался Access-Control-Allow-Origin: {_FRONTEND_ORIGIN!r}, получен: {acao!r}" + ) + + +@pytest.mark.asyncio +async def test_options_preflight_allow_methods_contains_get() -> None: + """OPTIONS preflight должен вернуть Access-Control-Allow-Methods, включающий GET.""" + async with make_app_client() as client: + resp = await client.options( + "/api/signal", + headers={ + "Origin": _FRONTEND_ORIGIN, + "Access-Control-Request-Method": "GET", + "Access-Control-Request-Headers": "Authorization", + }, + ) + acam = resp.headers.get("access-control-allow-methods", "") + assert "GET" in acam, ( + f"Access-Control-Allow-Methods не содержит GET: {acam!r}\n" + "Decision #1268: allow_methods=['POST'] — GET отсутствует" + ) + + +@pytest.mark.asyncio +async def test_options_preflight_allow_headers_contains_authorization() -> None: + """OPTIONS preflight должен вернуть Access-Control-Allow-Headers, включающий Authorization.""" + async with make_app_client() as client: + resp = await client.options( + "/api/signal", + headers={ + "Origin": _FRONTEND_ORIGIN, + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "Authorization", + }, + ) + acah = resp.headers.get("access-control-allow-headers", "") + assert "authorization" in acah.lower(), ( + f"Access-Control-Allow-Headers не содержит Authorization: {acah!r}" + ) + + +@pytest.mark.asyncio +async def test_get_health_cors_header_present() -> None: + """GET /health с Origin должен вернуть Access-Control-Allow-Origin (simple request).""" + async with make_app_client() as client: + resp = await client.get( + "/health", + headers={"Origin": _FRONTEND_ORIGIN}, + ) + assert resp.status_code == 200 + acao = resp.headers.get("access-control-allow-origin", "") + assert acao == _FRONTEND_ORIGIN, ( + f"GET /health: ожидался CORS-заголовок {_FRONTEND_ORIGIN!r}, получен: {acao!r}" + )