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/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 18df781..8d0d3d0 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/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: 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}" # --------------------------------------------------------------------------- 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" + )