diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml deleted file mode 100644 index 7732a47..0000000 --- a/.pre-commit-config.yaml +++ /dev/null @@ -1,11 +0,0 @@ -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 7535cc0..d9f832e 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 = _require("ADMIN_CHAT_ID") +ADMIN_CHAT_ID: str = os.getenv("ADMIN_CHAT_ID", "5694335584") 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 d733006..e2e98b4 100644 --- a/backend/db.py +++ b/backend/db.py @@ -341,10 +341,9 @@ 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 = ? AND status = 'pending'", + "UPDATE registrations SET status = ? WHERE id = ?", (status, reg_id), ) as cur: changed = cur.rowcount > 0 diff --git a/backend/main.py b/backend/main.py index 8d0d3d0..18df781 100644 --- a/backend/main.py +++ b/backend/main.py @@ -240,11 +240,7 @@ async def _handle_callback_query(cb: dict) -> None: return if action == "approve": - 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 + await db.update_registration_status(reg_id, "approved") if chat_id and message_id: await telegram.edit_message_text( chat_id, message_id, f"✅ Пользователь {reg['login']} одобрен" @@ -258,10 +254,7 @@ async def _handle_callback_query(cb: dict) -> None: ) ) elif action == "reject": - updated = await db.update_registration_status(reg_id, "rejected") - if not updated: - await telegram.answer_callback_query(callback_query_id) - return + await db.update_registration_status(reg_id, "rejected") 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 4b37a7e..e8af507 100644 --- a/backend/telegram.py +++ b/backend/telegram.py @@ -106,8 +106,7 @@ async def send_registration_notification( resp.text, ) except Exception as 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__) + logger.error("send_registration_notification error: %s", exc) async def answer_callback_query(callback_query_id: str) -> None: @@ -119,8 +118,7 @@ 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: - # Do not log exc directly — httpx exceptions embed the full API URL with BOT_TOKEN - logger.error("answerCallbackQuery error: %s", type(exc).__name__) + logger.error("answerCallbackQuery error: %s", exc) async def edit_message_text(chat_id: str | int, message_id: int, text: str) -> None: @@ -134,8 +132,7 @@ 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: - # Do not log exc directly — httpx exceptions embed the full API URL with BOT_TOKEN - logger.error("editMessageText error: %s", type(exc).__name__) + logger.error("editMessageText error: %s", exc) async def set_webhook(url: str, secret: str) -> None: diff --git a/tests/conftest.py b/tests/conftest.py index 7f47b12..727bf75 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -22,7 +22,6 @@ 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 @@ -70,20 +69,14 @@ def temp_db(): # ── 5. App client factory ──────────────────────────────────────────────────── -def make_app_client(capture_send_requests: list | None = None): +def make_app_client(): """ 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" @@ -102,18 +95,9 @@ def make_app_client(capture_send_requests: list | None = None): mock_router.post(tg_set_url).mock( return_value=httpx.Response(200, json={"ok": True, "result": 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(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 f572cd8..0a6a678 100644 --- a/tests/test_baton_008.py +++ b/tests/test_baton_008.py @@ -21,7 +21,6 @@ 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 @@ -168,23 +167,20 @@ async def test_auth_register_422_short_password(): @pytest.mark.asyncio async def test_auth_register_sends_notification_to_admin(): - """Registration triggers real HTTP sendMessage to ADMIN_CHAT_ID with correct login/email.""" - from backend import config as _cfg + """Registration triggers send_registration_notification with correct data.""" + calls: list[dict] = [] - 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 def _capture(reg_id, login, email, created_at): + calls.append({"reg_id": reg_id, "login": login, "email": 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}" + 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"] # --------------------------------------------------------------------------- diff --git a/tests/test_fix_013.py b/tests/test_fix_013.py deleted file mode 100644 index 5445895..0000000 --- a/tests/test_fix_013.py +++ /dev/null @@ -1,194 +0,0 @@ -""" -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" - )