From b2fecc5993383c98e40671b678f6a6cf0be580c9 Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Sat, 21 Mar 2026 09:30:44 +0200 Subject: [PATCH 1/6] kin: BATON-FIX-007-backend_dev --- backend/main.py | 2 +- tests/test_fix_007.py | 155 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 tests/test_fix_007.py 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}" + ) From 6d5d84a882c4b6c3870d3f19973a5c7ada3d384f Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Sat, 21 Mar 2026 09:33:09 +0200 Subject: [PATCH 2/6] =?UTF-8?q?fix:=20CORS=20allow=5Fmethods=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82=D1=8C=20GET=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20/health=20=D1=8D=D0=BD=D0=B4=D0=BF=D0=BE=D0=B8=D0=BD?= =?UTF-8?q?=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CORSMiddleware: allow_methods=['POST'] → ['GET', 'POST'] Позволяет браузерам делать GET-запросы к /health и /api/health без CORS-блокировки. BATON-FIX-013 --- backend/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/main.py b/backend/main.py index 63bb1dd..93b281f 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", "POST"], allow_headers=["Content-Type", "Authorization"], ) From 283ff61dc533283e374eed0b21f52149353a7366 Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Sat, 21 Mar 2026 09:33:53 +0200 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20sync=20allow=5Fmethods=20=D1=81=20ma?= =?UTF-8?q?in=20=E2=80=94=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20HEAD=20=D0=B8=20OPTIONS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/main.py b/backend/main.py index 93b281f..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=["GET", "POST"], + allow_methods=["GET", "HEAD", "OPTIONS", "POST"], allow_headers=["Content-Type", "Authorization"], ) From fde7f57a7a28b2b7898ea7886bd1c58f22ff00c6 Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Sat, 21 Mar 2026 09:34:21 +0200 Subject: [PATCH 4/6] kin: BATON-008-backend_dev --- backend/config.py | 2 +- backend/db.py | 3 ++- backend/main.py | 11 +++++++++-- tests/conftest.py | 24 ++++++++++++++++++++---- tests/test_baton_008.py | 28 ++++++++++++++++------------ 5 files changed, 48 insertions(+), 20 deletions(-) diff --git a/backend/config.py b/backend/config.py index d9f832e..7535cc0 100644 --- a/backend/config.py +++ b/backend/config.py @@ -22,7 +22,7 @@ WEBHOOK_ENABLED: bool = os.getenv("WEBHOOK_ENABLED", "true").lower() == "true" FRONTEND_ORIGIN: str = os.getenv("FRONTEND_ORIGIN", "http://localhost:3000") APP_URL: str | None = os.getenv("APP_URL") # Публичный URL приложения для keep-alive self-ping ADMIN_TOKEN: str = _require("ADMIN_TOKEN") -ADMIN_CHAT_ID: str = os.getenv("ADMIN_CHAT_ID", "5694335584") +ADMIN_CHAT_ID: str = _require("ADMIN_CHAT_ID") VAPID_PRIVATE_KEY: str = os.getenv("VAPID_PRIVATE_KEY", "") VAPID_PUBLIC_KEY: str = os.getenv("VAPID_PUBLIC_KEY", "") VAPID_CLAIMS_EMAIL: str = os.getenv("VAPID_CLAIMS_EMAIL", "") diff --git a/backend/db.py b/backend/db.py index e2e98b4..d733006 100644 --- a/backend/db.py +++ b/backend/db.py @@ -341,9 +341,10 @@ async def get_registration(reg_id: int) -> Optional[dict]: async def update_registration_status(reg_id: int, status: str) -> bool: + """Update registration status only if currently 'pending'. Returns False if already processed.""" async with _get_conn() as conn: async with conn.execute( - "UPDATE registrations SET status = ? WHERE id = ?", + "UPDATE registrations SET status = ? WHERE id = ? AND status = 'pending'", (status, reg_id), ) as cur: changed = cur.rowcount > 0 diff --git a/backend/main.py b/backend/main.py index 63bb1dd..5765f73 100644 --- a/backend/main.py +++ b/backend/main.py @@ -240,7 +240,11 @@ async def _handle_callback_query(cb: dict) -> None: return if action == "approve": - await db.update_registration_status(reg_id, "approved") + updated = await db.update_registration_status(reg_id, "approved") + if not updated: + # Already processed (not pending) — ack the callback and stop + await telegram.answer_callback_query(callback_query_id) + return if chat_id and message_id: await telegram.edit_message_text( chat_id, message_id, f"✅ Пользователь {reg['login']} одобрен" @@ -254,7 +258,10 @@ async def _handle_callback_query(cb: dict) -> None: ) ) elif action == "reject": - await db.update_registration_status(reg_id, "rejected") + updated = await db.update_registration_status(reg_id, "rejected") + if not updated: + await telegram.answer_callback_query(callback_query_id) + return if chat_id and message_id: await telegram.edit_message_text( chat_id, message_id, f"❌ Пользователь {reg['login']} отклонён" diff --git a/tests/conftest.py b/tests/conftest.py index 727bf75..7f47b12 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -22,6 +22,7 @@ os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret") os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram") os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000") os.environ.setdefault("ADMIN_TOKEN", "test-admin-token") +os.environ.setdefault("ADMIN_CHAT_ID", "5694335584") # ── 2. aiosqlite monkey-patch ──────────────────────────────────────────────── import aiosqlite @@ -69,14 +70,20 @@ def temp_db(): # ── 5. App client factory ──────────────────────────────────────────────────── -def make_app_client(): +def make_app_client(capture_send_requests: list | None = None): """ Async context manager that: 1. Assigns a fresh temp-file DB path 2. Mocks Telegram setWebhook, sendMessage, answerCallbackQuery, editMessageText 3. Runs the FastAPI lifespan (startup → test → shutdown) 4. Yields an httpx.AsyncClient wired to the app + + Args: + capture_send_requests: if provided, each sendMessage request body (dict) is + appended to this list, enabling HTTP-level assertions on chat_id, text, etc. """ + import json as _json + tg_set_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/setWebhook" send_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage" get_me_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/getMe" @@ -95,9 +102,18 @@ def make_app_client(): mock_router.post(tg_set_url).mock( return_value=httpx.Response(200, json={"ok": True, "result": True}) ) - mock_router.post(send_url).mock( - return_value=httpx.Response(200, json={"ok": True}) - ) + if capture_send_requests is not None: + def _capture_send(request: httpx.Request) -> httpx.Response: + try: + capture_send_requests.append(_json.loads(request.content)) + except Exception: + pass + return httpx.Response(200, json={"ok": True}) + mock_router.post(send_url).mock(side_effect=_capture_send) + else: + mock_router.post(send_url).mock( + return_value=httpx.Response(200, json={"ok": True}) + ) mock_router.post(answer_cb_url).mock( return_value=httpx.Response(200, json={"ok": True}) ) diff --git a/tests/test_baton_008.py b/tests/test_baton_008.py index 0a6a678..f572cd8 100644 --- a/tests/test_baton_008.py +++ b/tests/test_baton_008.py @@ -21,6 +21,7 @@ os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret") os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram") os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000") os.environ.setdefault("ADMIN_TOKEN", "test-admin-token") +os.environ.setdefault("ADMIN_CHAT_ID", "5694335584") from unittest.mock import AsyncMock, patch @@ -167,20 +168,23 @@ async def test_auth_register_422_short_password(): @pytest.mark.asyncio async def test_auth_register_sends_notification_to_admin(): - """Registration triggers send_registration_notification with correct data.""" - calls: list[dict] = [] + """Registration triggers real HTTP sendMessage to ADMIN_CHAT_ID with correct login/email.""" + from backend import config as _cfg - async def _capture(reg_id, login, email, created_at): - calls.append({"reg_id": reg_id, "login": login, "email": email}) + captured: list[dict] = [] + async with make_app_client(capture_send_requests=captured) as client: + resp = await client.post("/api/auth/register", json=_VALID_PAYLOAD) + assert resp.status_code == 201 + await asyncio.sleep(0) - async with make_app_client() as client: - with patch("backend.telegram.send_registration_notification", side_effect=_capture): - await client.post("/api/auth/register", json=_VALID_PAYLOAD) - await asyncio.sleep(0) - - assert len(calls) == 1, f"Expected 1 notification call, got {len(calls)}" - assert calls[0]["login"] == _VALID_PAYLOAD["login"] - assert calls[0]["email"] == _VALID_PAYLOAD["email"] + admin_chat_id = str(_cfg.ADMIN_CHAT_ID) + admin_msgs = [r for r in captured if str(r.get("chat_id")) == admin_chat_id] + assert len(admin_msgs) >= 1, ( + f"Expected sendMessage to ADMIN_CHAT_ID={admin_chat_id!r}, captured: {captured}" + ) + text = admin_msgs[0].get("text", "") + assert _VALID_PAYLOAD["login"] in text, f"Expected login in text: {text!r}" + assert _VALID_PAYLOAD["email"] in text, f"Expected email in text: {text!r}" # --------------------------------------------------------------------------- From 5401363ea9bf102cad055bb5a0046f277ba0e210 Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Sat, 21 Mar 2026 09:37:57 +0200 Subject: [PATCH 5/6] =?UTF-8?q?kin:=20BATON-FIX-013=20CORS=20allow=5Fmetho?= =?UTF-8?q?ds:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82=D1=8C=20GET=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20/health=20=D1=8D=D0=BD=D0=B4=D0=BF=D0=BE?= =?UTF-8?q?=D0=B8=D0=BD=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_fix_013.py | 194 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 tests/test_fix_013.py diff --git a/tests/test_fix_013.py b/tests/test_fix_013.py new file mode 100644 index 0000000..5445895 --- /dev/null +++ b/tests/test_fix_013.py @@ -0,0 +1,194 @@ +""" +Tests for BATON-FIX-013: CORS allow_methods — добавить GET для /health эндпоинтов. + +Acceptance criteria: +1. CORSMiddleware в main.py содержит "GET" в allow_methods. +2. OPTIONS preflight /health с Origin и Access-Control-Request-Method: GET + возвращает 200/204 и содержит GET в Access-Control-Allow-Methods. +3. OPTIONS preflight /api/health — аналогично. +4. GET /health возвращает 200 (regression guard vs. allow_methods=['POST'] only). +""" +from __future__ import annotations + +import os + +os.environ.setdefault("BOT_TOKEN", "test-bot-token") +os.environ.setdefault("CHAT_ID", "-1001234567890") +os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret") +os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram") +os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000") +os.environ.setdefault("ADMIN_TOKEN", "test-admin-token") +os.environ.setdefault("ADMIN_CHAT_ID", "5694335584") + +import pytest + +from tests.conftest import make_app_client + +_ORIGIN = "http://localhost:3000" +# allow_headers = ["Content-Type", "Authorization"] — X-Custom-Header не разрешён, +# поэтому preflight с X-Custom-Header вернёт 400. Используем Content-Type. +_PREFLIGHT_HEADER = "Content-Type" + + +# --------------------------------------------------------------------------- +# Criterion 1 — Static: CORSMiddleware.allow_methods must contain "GET" +# --------------------------------------------------------------------------- + + +def test_cors_middleware_allow_methods_contains_get() -> None: + """app.user_middleware CORSMiddleware должен содержать 'GET' в allow_methods.""" + from fastapi.middleware.cors import CORSMiddleware + + from backend.main import app + + cors_mw = next( + (m for m in app.user_middleware if m.cls is CORSMiddleware), None + ) + assert cors_mw is not None, "CORSMiddleware не найден в app.user_middleware" + allow_methods = cors_mw.kwargs.get("allow_methods", []) + assert "GET" in allow_methods, ( + f"CORSMiddleware.allow_methods={allow_methods!r} не содержит 'GET'" + ) + + +def test_cors_middleware_allow_methods_contains_head() -> None: + """allow_methods должен содержать 'HEAD' для корректной работы preflight.""" + from fastapi.middleware.cors import CORSMiddleware + + from backend.main import app + + cors_mw = next( + (m for m in app.user_middleware if m.cls is CORSMiddleware), None + ) + assert cors_mw is not None + allow_methods = cors_mw.kwargs.get("allow_methods", []) + assert "HEAD" in allow_methods, ( + f"CORSMiddleware.allow_methods={allow_methods!r} не содержит 'HEAD'" + ) + + +def test_cors_middleware_allow_methods_contains_options() -> None: + """allow_methods должен содержать 'OPTIONS' для корректной обработки preflight.""" + from fastapi.middleware.cors import CORSMiddleware + + from backend.main import app + + cors_mw = next( + (m for m in app.user_middleware if m.cls is CORSMiddleware), None + ) + assert cors_mw is not None + allow_methods = cors_mw.kwargs.get("allow_methods", []) + assert "OPTIONS" in allow_methods, ( + f"CORSMiddleware.allow_methods={allow_methods!r} не содержит 'OPTIONS'" + ) + + +# --------------------------------------------------------------------------- +# Criterion 2 — Preflight OPTIONS /health includes GET in Allow-Methods +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_health_preflight_options_returns_success_status() -> None: + """OPTIONS preflight /health должен вернуть 200 или 204.""" + async with make_app_client() as client: + response = await client.options( + "/health", + headers={ + "Origin": _ORIGIN, + "Access-Control-Request-Method": "GET", + "Access-Control-Request-Headers": _PREFLIGHT_HEADER, + }, + ) + assert response.status_code in (200, 204), ( + f"OPTIONS /health вернул {response.status_code}, ожидался 200 или 204" + ) + + +@pytest.mark.asyncio +async def test_health_preflight_options_allow_methods_contains_get() -> None: + """OPTIONS preflight /health: Access-Control-Allow-Methods должен содержать GET.""" + async with make_app_client() as client: + response = await client.options( + "/health", + headers={ + "Origin": _ORIGIN, + "Access-Control-Request-Method": "GET", + "Access-Control-Request-Headers": _PREFLIGHT_HEADER, + }, + ) + allow_methods_header = response.headers.get("access-control-allow-methods", "") + assert "GET" in allow_methods_header, ( + f"Access-Control-Allow-Methods={allow_methods_header!r} не содержит 'GET'" + ) + + +# --------------------------------------------------------------------------- +# Criterion 3 — Preflight OPTIONS /api/health includes GET in Allow-Methods +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_api_health_preflight_options_returns_success_status() -> None: + """OPTIONS preflight /api/health должен вернуть 200 или 204.""" + async with make_app_client() as client: + response = await client.options( + "/api/health", + headers={ + "Origin": _ORIGIN, + "Access-Control-Request-Method": "GET", + "Access-Control-Request-Headers": _PREFLIGHT_HEADER, + }, + ) + assert response.status_code in (200, 204), ( + f"OPTIONS /api/health вернул {response.status_code}, ожидался 200 или 204" + ) + + +@pytest.mark.asyncio +async def test_api_health_preflight_options_allow_methods_contains_get() -> None: + """OPTIONS preflight /api/health: Access-Control-Allow-Methods должен содержать GET.""" + async with make_app_client() as client: + response = await client.options( + "/api/health", + headers={ + "Origin": _ORIGIN, + "Access-Control-Request-Method": "GET", + "Access-Control-Request-Headers": _PREFLIGHT_HEADER, + }, + ) + allow_methods_header = response.headers.get("access-control-allow-methods", "") + assert "GET" in allow_methods_header, ( + f"Access-Control-Allow-Methods={allow_methods_header!r} не содержит 'GET'" + ) + + +# --------------------------------------------------------------------------- +# Criterion 4 — GET /health returns 200 (regression guard) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_health_get_returns_200_regression_guard() -> None: + """GET /health должен вернуть 200 — regression guard против allow_methods=['POST'] only.""" + async with make_app_client() as client: + response = await client.get( + "/health", + headers={"Origin": _ORIGIN}, + ) + assert response.status_code == 200, ( + f"GET /health вернул {response.status_code}, ожидался 200" + ) + + +@pytest.mark.asyncio +async def test_api_health_get_returns_200_regression_guard() -> None: + """GET /api/health должен вернуть 200 — regression guard против allow_methods=['POST'] only.""" + async with make_app_client() as client: + response = await client.get( + "/api/health", + headers={"Origin": _ORIGIN}, + ) + assert response.status_code == 200, ( + f"GET /api/health вернул {response.status_code}, ожидался 200" + ) From dd556e2f05c16951a5d6aa0c90955ec6152e88cb Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Sat, 21 Mar 2026 10:55:34 +0200 Subject: [PATCH 6/6] sec: pre-commit hook + httpx exception logging hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. .pre-commit-config.yaml — local pygrep hook блокирует коммиты с токенами формата \d{9,10}:AA[A-Za-z0-9_-]{35} (Telegram bot tokens). Проверено: срабатывает на токен, пропускает чистые файлы. 2. backend/telegram.py — три функции (send_registration_notification, answer_callback_query, edit_message_text) логировали exc напрямую, что раскрывало BOT_TOKEN в URL httpx-исключений в journalctl. Заменено на type(exc).__name__ — только тип ошибки, без URL. Refs: #1303, #1309, #1283 Co-Authored-By: Claude Sonnet 4.6 --- .pre-commit-config.yaml | 11 +++++++++++ backend/telegram.py | 9 ++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..7732a47 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,11 @@ +repos: + - repo: local + hooks: + - id: no-telegram-bot-token + name: Block Telegram bot tokens + # Matches tokens of format: 1234567890:AAFisjLS-yO_AmwqMjpBQgfV9qlHnexZlMs + # Pattern: 9-10 digits, colon, "AA", then 35 alphanumeric/dash/underscore chars + entry: '\d{9,10}:AA[A-Za-z0-9_-]{35}' + language: pygrep + types: [text] + exclude: '^\.pre-commit-config\.yaml$' diff --git a/backend/telegram.py b/backend/telegram.py index e8af507..4b37a7e 100644 --- a/backend/telegram.py +++ b/backend/telegram.py @@ -106,7 +106,8 @@ async def send_registration_notification( resp.text, ) except Exception as exc: - logger.error("send_registration_notification error: %s", exc) + # Do not log exc directly — httpx exceptions embed the full API URL with BOT_TOKEN + logger.error("send_registration_notification error: %s", type(exc).__name__) async def answer_callback_query(callback_query_id: str) -> None: @@ -118,7 +119,8 @@ async def answer_callback_query(callback_query_id: str) -> None: if resp.status_code != 200: logger.error("answerCallbackQuery failed %s: %s", resp.status_code, resp.text) except Exception as exc: - logger.error("answerCallbackQuery error: %s", exc) + # Do not log exc directly — httpx exceptions embed the full API URL with BOT_TOKEN + logger.error("answerCallbackQuery error: %s", type(exc).__name__) async def edit_message_text(chat_id: str | int, message_id: int, text: str) -> None: @@ -132,7 +134,8 @@ async def edit_message_text(chat_id: str | int, message_id: int, text: str) -> N if resp.status_code != 200: logger.error("editMessageText failed %s: %s", resp.status_code, resp.text) except Exception as exc: - logger.error("editMessageText error: %s", exc) + # Do not log exc directly — httpx exceptions embed the full API URL with BOT_TOKEN + logger.error("editMessageText error: %s", type(exc).__name__) async def set_webhook(url: str, secret: str) -> None: