From 36087c3d9eba40de6342a1e45d98c0ae702cb32b Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Sat, 21 Mar 2026 09:29:27 +0200 Subject: [PATCH 01/26] =?UTF-8?q?kin:=20BATON-FIX-012=20=D0=9F=D0=BE=D1=87?= =?UTF-8?q?=D0=B8=D0=BD=D0=B8=D1=82=D1=8C=2025=20=D1=82=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=BE=D0=B2=20=D1=80=D0=B5=D0=B3=D1=80=D0=B5=D1=81=D1=81=D0=B8?= =?UTF-8?q?=D0=B8=20=D0=BE=D1=82=20BATON-SEC-005?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_fix_012.py | 173 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 tests/test_fix_012.py diff --git a/tests/test_fix_012.py b/tests/test_fix_012.py new file mode 100644 index 0000000..64356de --- /dev/null +++ b/tests/test_fix_012.py @@ -0,0 +1,173 @@ +""" +Tests for BATON-FIX-012: UUID v4 validation regression guard. + +BATON-SEC-005 added UUID v4 pattern validation to RegisterRequest.uuid and +SignalRequest.user_id. Tests in test_db.py / test_baton_005.py / test_telegram.py +previously used placeholder strings ('uuid-001', 'create-uuid-001', 'agg-uuid-001') +that are not valid UUID v4 — causing 25 regressions. + +This file locks down the behaviour so the same mistake cannot recur silently: + - Old-style placeholder strings are rejected by Pydantic + - All UUID constants used across the fixed test files are valid UUID v4 + - RegisterRequest and SignalRequest accept exactly-valid v4 UUIDs + - They reject strings that violate version (bit 3 of field-3 must be 4) or + variant (top bits of field-4 must be 10xx) requirements +""" +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") + +import pytest +from pydantic import ValidationError + +from backend.models import RegisterRequest, SignalRequest + +# --------------------------------------------------------------------------- +# UUID constants from fixed test files — all must be valid UUID v4 +# --------------------------------------------------------------------------- + +# test_db.py constants (_UUID_DB_1 .. _UUID_DB_6) +_DB_UUIDS = [ + "d0000001-0000-4000-8000-000000000001", + "d0000002-0000-4000-8000-000000000002", + "d0000003-0000-4000-8000-000000000003", + "d0000004-0000-4000-8000-000000000004", + "d0000005-0000-4000-8000-000000000005", + "d0000006-0000-4000-8000-000000000006", +] + +# test_baton_005.py constants (_UUID_ADM_*) +_ADM_UUIDS = [ + "e0000000-0000-4000-8000-000000000000", + "e0000001-0000-4000-8000-000000000001", + "e0000002-0000-4000-8000-000000000002", + "e0000003-0000-4000-8000-000000000003", + "e0000004-0000-4000-8000-000000000004", + "e0000005-0000-4000-8000-000000000005", + "e0000006-0000-4000-8000-000000000006", + "e0000007-0000-4000-8000-000000000007", + "e0000008-0000-4000-8000-000000000008", + "e0000009-0000-4000-8000-000000000009", + "e000000a-0000-4000-8000-000000000010", +] + +# test_telegram.py constants (aggregator UUIDs) +_AGG_UUIDS = [ + "a9900001-0000-4000-8000-000000000001", + "a9900099-0000-4000-8000-000000000099", +] + [f"a990000{i}-0000-4000-8000-00000000000{i}" for i in range(5)] + + +# --------------------------------------------------------------------------- +# Old-style placeholder UUIDs (pre-fix) must be rejected +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("bad_uuid", [ + "uuid-001", + "uuid-002", + "uuid-003", + "uuid-004", + "uuid-005", + "uuid-006", + "create-uuid-001", + "create-uuid-002", + "create-uuid-003", + "pass-uuid-001", + "pass-uuid-002", + "block-uuid-001", + "unblock-uuid-001", + "delete-uuid-001", + "delete-uuid-002", + "regress-admin-uuid-001", + "unauth-uuid-001", + "agg-uuid-001", + "agg-uuid-clr", +]) +def test_register_request_rejects_old_placeholder_uuid(bad_uuid: str) -> None: + """RegisterRequest.uuid must reject all pre-BATON-SEC-005 placeholder strings.""" + with pytest.raises(ValidationError): + RegisterRequest(uuid=bad_uuid, name="Test") + + +@pytest.mark.parametrize("bad_uuid", [ + "uuid-001", + "agg-uuid-001", + "create-uuid-001", +]) +def test_signal_request_rejects_old_placeholder_uuid(bad_uuid: str) -> None: + """SignalRequest.user_id must reject old-style placeholder strings.""" + with pytest.raises(ValidationError): + SignalRequest(user_id=bad_uuid, timestamp=1700000000000) + + +# --------------------------------------------------------------------------- +# All UUID constants from the fixed test files are valid UUID v4 +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("valid_uuid", _DB_UUIDS) +def test_register_request_accepts_db_uuid_constants(valid_uuid: str) -> None: + """RegisterRequest accepts all _UUID_DB_* constants from test_db.py.""" + req = RegisterRequest(uuid=valid_uuid, name="Test") + assert req.uuid == valid_uuid + + +@pytest.mark.parametrize("valid_uuid", _ADM_UUIDS) +def test_register_request_accepts_adm_uuid_constants(valid_uuid: str) -> None: + """RegisterRequest accepts all _UUID_ADM_* constants from test_baton_005.py.""" + req = RegisterRequest(uuid=valid_uuid, name="Test") + assert req.uuid == valid_uuid + + +@pytest.mark.parametrize("valid_uuid", _AGG_UUIDS) +def test_signal_request_accepts_agg_uuid_constants(valid_uuid: str) -> None: + """SignalRequest accepts all aggregator UUID constants from test_telegram.py.""" + req = SignalRequest(user_id=valid_uuid, timestamp=1700000000000) + assert req.user_id == valid_uuid + + +# --------------------------------------------------------------------------- +# UUID v4 structural requirements — version digit and variant bits +# --------------------------------------------------------------------------- + + +def test_register_request_rejects_uuid_v1_version_digit() -> None: + """UUID with version digit = 1 (not 4) must be rejected by RegisterRequest.""" + with pytest.raises(ValidationError): + # third group starts with '1' — version 1, not v4 + RegisterRequest(uuid="550e8400-e29b-11d4-a716-446655440000", name="Test") + + +def test_register_request_rejects_uuid_v3_version_digit() -> None: + """UUID with version digit = 3 must be rejected.""" + with pytest.raises(ValidationError): + RegisterRequest(uuid="550e8400-e29b-31d4-a716-446655440000", name="Test") + + +def test_signal_request_rejects_uuid_wrong_variant_bits() -> None: + """UUID with invalid variant bits (0xxx in fourth group) must be rejected.""" + with pytest.raises(ValidationError): + # fourth group starts with '0' — not 8/9/a/b variant + SignalRequest(user_id="550e8400-e29b-41d4-0716-446655440000", timestamp=1700000000000) + + +def test_signal_request_rejects_uuid_wrong_variant_c() -> None: + """UUID with variant 'c' (1100 bits) must be rejected — only 8/9/a/b allowed.""" + with pytest.raises(ValidationError): + SignalRequest(user_id="550e8400-e29b-41d4-c716-446655440000", timestamp=1700000000000) + + +def test_register_request_accepts_all_valid_v4_variants() -> None: + """RegisterRequest accepts UUIDs with variant nibbles 8, 9, a, b.""" + for variant in ("8", "9", "a", "b"): + uuid = f"550e8400-e29b-41d4-{variant}716-446655440000" + req = RegisterRequest(uuid=uuid, name="Test") + assert req.uuid == uuid From b2fecc5993383c98e40671b678f6a6cf0be580c9 Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Sat, 21 Mar 2026 09:30:44 +0200 Subject: [PATCH 02/26] 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 03/26] =?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 04/26] =?UTF-8?q?fix:=20sync=20allow=5Fmethods=20=D1=81=20?= =?UTF-8?q?main=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 05/26] 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 06/26] =?UTF-8?q?kin:=20BATON-FIX-013=20CORS=20allow=5Fmet?= =?UTF-8?q?hods:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82=D1=8C=20GET?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20/health=20=D1=8D=D0=BD=D0=B4=D0=BF?= =?UTF-8?q?=D0=BE=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 07/26] 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: From 635991078c18052f4548e8102b0d7f1bcff0d0c8 Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Sat, 21 Mar 2026 10:56:52 +0200 Subject: [PATCH 08/26] sec: suppress httpcore transport logger in main.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Дублирует аналогичный fix в telegram.py — httpcore тоже логирует URLs с BOT_TOKEN на transport уровне. Синхронизировано с ручным патчем на сервере. Refs: #1303, #1309 Co-Authored-By: Claude Sonnet 4.6 --- backend/main.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/main.py b/backend/main.py index 8d0d3d0..7f17501 100644 --- a/backend/main.py +++ b/backend/main.py @@ -33,6 +33,7 @@ _api_key_bearer = HTTPBearer(auto_error=False) logging.basicConfig(level=logging.INFO) logging.getLogger("httpx").setLevel(logging.WARNING) +logging.getLogger("httpcore").setLevel(logging.WARNING) logger = logging.getLogger(__name__) From 5fa3a35d2790c62ff86187bf290c66533489fcf3 Mon Sep 17 00:00:00 2001 From: Kin Agent Date: Sat, 21 Mar 2026 09:17:06 +0000 Subject: [PATCH 09/26] =?UTF-8?q?fix:=20add=20ExecStartPre=20pip=20install?= =?UTF-8?q?=20to=20baton.service=20=E2=80=94=20prevents=20manual=20package?= =?UTF-8?q?=20installs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes BATON-FIX-015: email-validator was installed manually as root because deploy process had no pip install step. Added ExecStartPre to run pip install -r requirements.txt on every service start/restart. --- deploy/baton.service | 1 + 1 file changed, 1 insertion(+) diff --git a/deploy/baton.service b/deploy/baton.service index 141d6b6..ec8be1f 100644 --- a/deploy/baton.service +++ b/deploy/baton.service @@ -8,6 +8,7 @@ Type=simple User=www-data WorkingDirectory=/opt/baton EnvironmentFile=/opt/baton/.env +ExecStartPre=/opt/baton/venv/bin/pip install -r /opt/baton/requirements.txt -q ExecStart=/opt/baton/venv/bin/uvicorn backend.main:app --host 127.0.0.1 --port 8000 Restart=on-failure RestartSec=5s From 5fe9a603f87a79fbb705ab204baf0b6c00d208fc Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Sat, 21 Mar 2026 11:19:09 +0200 Subject: [PATCH 10/26] kin: BATON-FIX-016-frontend_dev --- frontend/app.js | 57 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/frontend/app.js b/frontend/app.js index e457ee7..4a080e5 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -243,6 +243,58 @@ function _registerSW() { }); } +// ========== VAPID / Push subscription ========== + +async function _fetchVapidPublicKey() { + try { + const res = await fetch('/api/vapid-public-key'); + if (!res.ok) { + console.warn('[baton] /api/vapid-public-key returned', res.status); + return null; + } + const data = await res.json(); + return data.vapid_public_key || null; + } catch (err) { + console.warn('[baton] Failed to fetch VAPID public key:', err); + return null; + } +} + +function _urlBase64ToUint8Array(base64String) { + const padding = '='.repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); + const raw = atob(base64); + const output = new Uint8Array(raw.length); + for (let i = 0; i < raw.length; i++) { + output[i] = raw.charCodeAt(i); + } + return output; +} + +async function _initPushSubscription(vapidPublicKey) { + if (!vapidPublicKey) { + console.warn('[baton] VAPID public key not available — push subscription skipped'); + return; + } + if (!('serviceWorker' in navigator) || !('PushManager' in window)) { + return; + } + try { + const registration = await navigator.serviceWorker.ready; + const existing = await registration.pushManager.getSubscription(); + if (existing) return; + const applicationServerKey = _urlBase64ToUint8Array(vapidPublicKey); + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey, + }); + _storage.setItem('baton_push_subscription', JSON.stringify(subscription)); + console.info('[baton] Push subscription created'); + } catch (err) { + console.warn('[baton] Push subscription failed:', err); + } +} + // ========== Init ========== function _init() { @@ -269,6 +321,11 @@ function _init() { } else { _showOnboarding(); } + + // Fire-and-forget: fetch VAPID key from API and subscribe to push (non-blocking) + _fetchVapidPublicKey().then(_initPushSubscription).catch((err) => { + console.warn('[baton] Push init error:', err); + }); } document.addEventListener('DOMContentLoaded', () => { From 8c4c46ee9257cee3987dd02800d2acdb27212d62 Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Sat, 21 Mar 2026 12:23:05 +0200 Subject: [PATCH 11/26] =?UTF-8?q?kin:=20BATON-FIX-016=20[TECH=20DEBT]=20VA?= =?UTF-8?q?PID=20public=20key=20=D0=B6=D1=91=D1=81=D1=82=D0=BA=D0=BE=20?= =?UTF-8?q?=D0=B2=D1=88=D0=B8=D1=82=20=D0=BA=D0=B0=D0=BA=20=D0=BF=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=8F=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=B2=20-=D1=82=D0=B5=D0=B3=20=E2=80=94=20=D1=82?= =?UTF-8?q?=D1=80=D0=B5=D0=B1=D1=83=D0=B5=D1=82=20=D1=80=D1=83=D1=87=D0=BD?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=20=D0=B7=D0=B0=D0=BF=D0=BE=D0=BB=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=BF=D1=80=D0=B8=20=D0=B4=D0=B5=D0=BF?= =?UTF-8?q?=D0=BB=D0=BE=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 5 ++ backend/main.py | 5 ++ tests/test_fix_016.py | 163 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 173 insertions(+) create mode 100644 tests/test_fix_016.py diff --git a/.env.example b/.env.example index cf447e0..6d8ac36 100644 --- a/.env.example +++ b/.env.example @@ -11,3 +11,8 @@ DB_PATH=baton.db # CORS FRONTEND_ORIGIN=https://yourdomain.com + +# VAPID Push Notifications (generate with: python -c "from py_vapid import Vapid; v=Vapid(); v.generate_keys(); print(v.public_key, v.private_key)") +VAPID_PUBLIC_KEY= +VAPID_PRIVATE_KEY= +VAPID_CLAIMS_EMAIL= diff --git a/backend/main.py b/backend/main.py index 7f17501..ebd2f07 100644 --- a/backend/main.py +++ b/backend/main.py @@ -132,6 +132,11 @@ async def health() -> dict[str, Any]: return {"status": "ok"} +@app.get("/api/vapid-public-key") +async def vapid_public_key() -> dict[str, str]: + return {"vapid_public_key": config.VAPID_PUBLIC_KEY} + + @app.post("/api/register", response_model=RegisterResponse) async def register(body: RegisterRequest, _: None = Depends(rate_limit_register)) -> RegisterResponse: api_key = secrets.token_hex(32) diff --git a/tests/test_fix_016.py b/tests/test_fix_016.py new file mode 100644 index 0000000..f91ac3e --- /dev/null +++ b/tests/test_fix_016.py @@ -0,0 +1,163 @@ +""" +Tests for BATON-FIX-016: VAPID public key — убедиться, что ключ не вшит +как пустая строка в frontend-коде и читается через API. + +Acceptance criteria: +1. В frontend-коде нет хардкода пустой строки в качестве VAPID key в -теге. +2. frontend читает ключ через API /api/vapid-public-key (_fetchVapidPublicKey). +3. GET /api/vapid-public-key возвращает HTTP 200. +4. GET /api/vapid-public-key возвращает JSON с полем vapid_public_key. +5. При наличии конфигурации VAPID_PUBLIC_KEY — ответ содержит непустое значение. +""" +from __future__ import annotations + +import os +import re +from pathlib import Path +from unittest.mock import patch + +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 + +PROJECT_ROOT = Path(__file__).parent.parent +FRONTEND_DIR = PROJECT_ROOT / "frontend" +INDEX_HTML = FRONTEND_DIR / "index.html" +APP_JS = FRONTEND_DIR / "app.js" + +_TEST_VAPID_PUBLIC_KEY = "BFakeVapidPublicKeyForTestingPurposesOnlyBase64UrlEncoded" + + +# --------------------------------------------------------------------------- +# Criterion 1 — AST: no hardcoded empty VAPID key in tag (index.html) +# --------------------------------------------------------------------------- + + +def test_index_html_has_no_vapid_meta_tag_with_empty_content() -> None: + """index.html не должен содержать -тег с application-server-key и пустым content.""" + content = INDEX_HTML.read_text(encoding="utf-8") + match = re.search( + r']*(?:application-server-key|vapid)[^>]*content\s*=\s*["\']["\']', + content, + re.IGNORECASE, + ) + assert match is None, ( + f"index.html содержит -тег с пустым VAPID ключом: {match.group(0)!r}" + ) + + +def test_index_html_has_no_hardcoded_application_server_key_attribute() -> None: + """index.html не должен содержать атрибут application-server-key вообще.""" + content = INDEX_HTML.read_text(encoding="utf-8") + assert "application-server-key" not in content.lower(), ( + "index.html содержит атрибут 'application-server-key' — " + "VAPID ключ не должен быть вшит в HTML" + ) + + +# --------------------------------------------------------------------------- +# Criterion 2 — AST: frontend reads key through API (app.js) +# --------------------------------------------------------------------------- + + +def test_app_js_contains_fetch_vapid_public_key_function() -> None: + """app.js должен содержать функцию _fetchVapidPublicKey.""" + content = APP_JS.read_text(encoding="utf-8") + assert "_fetchVapidPublicKey" in content, ( + "app.js не содержит функцию _fetchVapidPublicKey — " + "чтение VAPID ключа через API не реализовано" + ) + + +def test_app_js_fetch_vapid_calls_api_endpoint() -> None: + """_fetchVapidPublicKey в app.js должна обращаться к /api/vapid-public-key.""" + content = APP_JS.read_text(encoding="utf-8") + assert "/api/vapid-public-key" in content, ( + "app.js не содержит URL '/api/vapid-public-key' — VAPID ключ не читается через API" + ) + + +def test_app_js_init_push_subscription_has_null_guard() -> None: + """_initPushSubscription в app.js должна содержать guard против null ключа.""" + content = APP_JS.read_text(encoding="utf-8") + assert re.search(r"if\s*\(\s*!\s*vapidPublicKey\s*\)", content), ( + "app.js: _initPushSubscription не содержит guard 'if (!vapidPublicKey)' — " + "подписка может быть создана без ключа" + ) + + +def test_app_js_init_chains_fetch_vapid_then_init_subscription() -> None: + """_init() в app.js должна вызывать _fetchVapidPublicKey().then(_initPushSubscription).""" + content = APP_JS.read_text(encoding="utf-8") + assert re.search( + r"_fetchVapidPublicKey\(\)\s*\.\s*then\s*\(\s*_initPushSubscription\s*\)", + content, + ), ( + "app.js: _init() не содержит цепочку _fetchVapidPublicKey().then(_initPushSubscription)" + ) + + +def test_app_js_has_no_empty_string_hardcoded_as_application_server_key() -> None: + """app.js не должен содержать хардкода пустой строки для applicationServerKey.""" + content = APP_JS.read_text(encoding="utf-8") + match = re.search(r"applicationServerKey\s*[=:]\s*[\"']{2}", content) + assert match is None, ( + f"app.js содержит хардкод пустой строки для applicationServerKey: {match.group(0)!r}" + ) + + +# --------------------------------------------------------------------------- +# Criterion 3 — HTTP: GET /api/vapid-public-key returns 200 +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_vapid_public_key_endpoint_returns_200() -> None: + """GET /api/vapid-public-key должен вернуть HTTP 200.""" + async with make_app_client() as client: + response = await client.get("/api/vapid-public-key") + assert response.status_code == 200, ( + f"GET /api/vapid-public-key вернул {response.status_code}, ожидался 200" + ) + + +# --------------------------------------------------------------------------- +# Criterion 4 — HTTP: response JSON contains vapid_public_key field +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_vapid_public_key_endpoint_returns_json_with_field() -> None: + """GET /api/vapid-public-key должен вернуть JSON с полем vapid_public_key.""" + async with make_app_client() as client: + response = await client.get("/api/vapid-public-key") + data = response.json() + assert "vapid_public_key" in data, ( + f"Ответ /api/vapid-public-key не содержит поле 'vapid_public_key': {data!r}" + ) + + +# --------------------------------------------------------------------------- +# Criterion 5 — HTTP: non-empty vapid_public_key when env var is configured +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_vapid_public_key_endpoint_returns_configured_value() -> None: + """GET /api/vapid-public-key возвращает непустой ключ, когда VAPID_PUBLIC_KEY задан.""" + with patch("backend.config.VAPID_PUBLIC_KEY", _TEST_VAPID_PUBLIC_KEY): + async with make_app_client() as client: + response = await client.get("/api/vapid-public-key") + data = response.json() + assert data.get("vapid_public_key") == _TEST_VAPID_PUBLIC_KEY, ( + f"vapid_public_key должен быть '{_TEST_VAPID_PUBLIC_KEY}', " + f"получили: {data.get('vapid_public_key')!r}" + ) From 40e1a9fa48863a7dce43447f6ee13ec80ab9ef9a Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Sat, 21 Mar 2026 12:36:07 +0200 Subject: [PATCH 12/26] =?UTF-8?q?kin:=20BATON-008=20=D0=9D=D0=B0=20=D0=B3?= =?UTF-8?q?=D0=BB=D0=B0=D0=B2=D0=BD=D0=BE=D0=B9=20=D1=81=D1=82=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=86=D0=B5=20=D0=BF=D0=BE=D0=B4=20=D0=BB=D0=BE?= =?UTF-8?q?=D0=B3=D0=B8=D0=BD=D0=BE=D0=BC=20=D1=81=D0=B4=D0=B5=D0=BB=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D1=83=20=D0=BC?= =?UTF-8?q?=D0=BE=D0=B4=D1=83=D0=BB=D0=B5=D0=BC=20=D1=80=D0=B5=D0=B3=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=B0=D1=86=D0=B8=D0=B8=20-=20=D1=83=D0=BA?= =?UTF-8?q?=D0=B0=D0=B7=D0=B0=D1=82=D1=8C=20=D0=BF=D0=BE=D1=87=D1=82=D1=83?= =?UTF-8?q?,=20=D0=BB=D0=BE=D0=B3=D0=B8=D0=BD=20=D0=B8=20=D0=BF=D0=B0?= =?UTF-8?q?=D1=80=D0=BE=D0=BB=D1=8C,=20=D0=BD=D0=B0=D0=B6=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20=D0=B7=D0=B0=D1=80=D0=B5=D0=B3=D0=B8=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=D1=81=D1=8F.=20?= =?UTF-8?q?=D0=9F=D0=BE=D1=81=D0=BB=D0=B5=20=D1=8D=D1=82=D0=BE=D0=B3=D0=BE?= =?UTF-8?q?=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=BE=20=D1=80=D0=B5=D0=B3=D0=B8=D1=81=D1=82=D1=80=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D0=B8=20=D0=BF=D1=80=D0=B8=D1=85=D0=BE=D0=B4=D0=B8=D1=82?= =?UTF-8?q?=20=D0=B2=20=D1=87=D0=B0=D1=82=20=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD?= =?UTF-8?q?=D0=B8=D1=81=D1=82=D1=80=D0=B0=D1=82=D0=BE=D1=80=D1=83=20569433?= =?UTF-8?q?5584=20=D0=B8=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D0=B0=20=D0=B0?= =?UTF-8?q?=D0=BF=D1=80=D1=83=D0=B2=20=D0=B8=D0=BB=D0=B8=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D0=B0=D0=BF=D1=80=D1=83=D0=B2,=20=D0=B5=D1=81=D0=BB=D0=B8=20?= =?UTF-8?q?=D0=B0=D0=BF=D1=80=D1=83=D0=B2=20=D1=82=D0=BE=20=D0=BE=D1=82?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D1=82=D0=B5=D0=BB=D1=8F=20=D1=83?= =?UTF-8?q?=D0=BB=D0=B5=D1=82=D0=B0=D0=B5=D1=82=20=D0=BF=D1=83=D1=88=20?= =?UTF-8?q?=D0=BD=D0=B0=20pwa=20=D1=87=D1=82=D0=BE=20=D0=BE=D0=BD=20=D0=B7?= =?UTF-8?q?=D0=B0=D1=80=D0=B5=D0=B3=D0=B8=D1=81=D1=82=D1=80=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD,=20=D0=B5=D1=81=D0=BB=D0=B8=20=D0=BE?= =?UTF-8?q?=D1=82=D0=BA=D0=B0=D0=B7=20=D1=82=D0=BE=20=D0=BD=D0=B8=D1=87?= =?UTF-8?q?=D0=B5=D0=B3=D0=BE=20=D0=BD=D0=B5=20=D0=BF=D1=80=D0=BE=D0=B8?= =?UTF-8?q?=D1=81=D1=85=D0=BE=D0=B4=D0=B8=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/main.py | 1 + tests/test_baton_008.py | 303 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 304 insertions(+) diff --git a/backend/main.py b/backend/main.py index ebd2f07..ce6f4ea 100644 --- a/backend/main.py +++ b/backend/main.py @@ -133,6 +133,7 @@ async def health() -> dict[str, Any]: @app.get("/api/vapid-public-key") +@app.get("/api/push/public-key") async def vapid_public_key() -> dict[str, str]: return {"vapid_public_key": config.VAPID_PUBLIC_KEY} diff --git a/tests/test_baton_008.py b/tests/test_baton_008.py index f572cd8..b1e0c03 100644 --- a/tests/test_baton_008.py +++ b/tests/test_baton_008.py @@ -583,3 +583,306 @@ async def test_password_hash_stored_in_pbkdf2_format(): assert len(dk_hex) == 64, f"Expected 64-char dk hex (SHA-256), got {len(dk_hex)}" int(salt_hex, 16) # raises ValueError if not valid hex int(dk_hex, 16) + + +# --------------------------------------------------------------------------- +# 15. State machine — повторное нажатие approve на уже approved +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_webhook_callback_double_approve_does_not_send_push(): + """Second approve on already-approved registration must NOT fire push.""" + push_sub = { + "endpoint": "https://fcm.googleapis.com/fcm/send/test2", + "keys": {"p256dh": "BQDEF", "auth": "abc"}, + } + async with make_app_client() as client: + with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock): + reg_resp = await client.post( + "/api/auth/register", + json={**_VALID_PAYLOAD, "email": "double@example.com", "login": "doubleuser", "push_subscription": push_sub}, + ) + assert reg_resp.status_code == 201 + + from backend import config as _cfg + import aiosqlite + async with aiosqlite.connect(_cfg.DB_PATH) as conn: + conn.row_factory = aiosqlite.Row + async with conn.execute("SELECT id FROM registrations LIMIT 1") as cur: + row = await cur.fetchone() + reg_id = row["id"] if row else None + assert reg_id is not None + + cb_payload = { + "callback_query": { + "id": "cq_d1", + "data": f"approve:{reg_id}", + "message": {"message_id": 60, "chat": {"id": 5694335584}}, + } + } + + # First approve — should succeed + await client.post("/api/webhook/telegram", json=cb_payload, headers=_WEBHOOK_HEADERS) + await asyncio.sleep(0) + + # Second approve — push must NOT be fired + push_calls: list = [] + + async def _capture_push(sub_json, title, body): + push_calls.append(sub_json) + + cb_payload2 = {**cb_payload, "callback_query": {**cb_payload["callback_query"], "id": "cq_d2"}} + with patch("backend.push.send_push", side_effect=_capture_push): + await client.post("/api/webhook/telegram", json=cb_payload2, headers=_WEBHOOK_HEADERS) + await asyncio.sleep(0) + + assert len(push_calls) == 0, f"Second approve must not fire push, got {len(push_calls)} calls" + + # Also verify status is still 'approved' + from backend import db as _db + # Can't check here as client context is closed; DB assertion was covered by state machine logic + + +@pytest.mark.asyncio +async def test_webhook_callback_double_approve_status_stays_approved(): + """Status remains 'approved' after a second approve callback.""" + from backend import db as _db + + async with make_app_client() as client: + with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock): + reg_resp = await client.post( + "/api/auth/register", + json={**_VALID_PAYLOAD, "email": "stay@example.com", "login": "stayuser"}, + ) + assert reg_resp.status_code == 201 + + from backend import config as _cfg + import aiosqlite + async with aiosqlite.connect(_cfg.DB_PATH) as conn: + conn.row_factory = aiosqlite.Row + async with conn.execute("SELECT id FROM registrations LIMIT 1") as cur: + row = await cur.fetchone() + reg_id = row["id"] if row else None + assert reg_id is not None + + cb = { + "callback_query": { + "id": "cq_s1", + "data": f"approve:{reg_id}", + "message": {"message_id": 70, "chat": {"id": 5694335584}}, + } + } + await client.post("/api/webhook/telegram", json=cb, headers=_WEBHOOK_HEADERS) + await asyncio.sleep(0) + + cb2 = {**cb, "callback_query": {**cb["callback_query"], "id": "cq_s2"}} + await client.post("/api/webhook/telegram", json=cb2, headers=_WEBHOOK_HEADERS) + await asyncio.sleep(0) + + reg = await _db.get_registration(reg_id) + assert reg["status"] == "approved", f"Expected 'approved', got {reg['status']!r}" + + +# --------------------------------------------------------------------------- +# 16. State machine — approve после reject +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_webhook_callback_approve_after_reject_status_stays_rejected(): + """Approve after reject must NOT change status — remains 'rejected'.""" + from backend import db as _db + + async with make_app_client() as client: + with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock): + reg_resp = await client.post( + "/api/auth/register", + json={**_VALID_PAYLOAD, "email": "artest@example.com", "login": "artestuser"}, + ) + assert reg_resp.status_code == 201 + + from backend import config as _cfg + import aiosqlite + async with aiosqlite.connect(_cfg.DB_PATH) as conn: + conn.row_factory = aiosqlite.Row + async with conn.execute("SELECT id FROM registrations LIMIT 1") as cur: + row = await cur.fetchone() + reg_id = row["id"] if row else None + assert reg_id is not None + + # First: reject + rej_cb = { + "callback_query": { + "id": "cq_ar1", + "data": f"reject:{reg_id}", + "message": {"message_id": 80, "chat": {"id": 5694335584}}, + } + } + await client.post("/api/webhook/telegram", json=rej_cb, headers=_WEBHOOK_HEADERS) + await asyncio.sleep(0) + + # Then: approve — must be ignored + push_calls: list = [] + + async def _capture_push(sub_json, title, body): + push_calls.append(sub_json) + + app_cb = { + "callback_query": { + "id": "cq_ar2", + "data": f"approve:{reg_id}", + "message": {"message_id": 81, "chat": {"id": 5694335584}}, + } + } + with patch("backend.push.send_push", side_effect=_capture_push): + await client.post("/api/webhook/telegram", json=app_cb, headers=_WEBHOOK_HEADERS) + await asyncio.sleep(0) + + reg = await _db.get_registration(reg_id) + assert reg["status"] == "rejected", f"Expected 'rejected', got {reg['status']!r}" + + assert len(push_calls) == 0, f"Approve after reject must not fire push, got {len(push_calls)}" + + +# --------------------------------------------------------------------------- +# 17. Rate limiting — 4th request returns 429 +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_auth_register_rate_limit_fourth_request_returns_429(): + """4th registration request from same IP within the window returns 429.""" + async with make_app_client() as client: + with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock): + for i in range(3): + r = await client.post( + "/api/auth/register", + json={ + "email": f"ratetest{i}@example.com", + "login": f"ratetest{i}", + "password": "strongpassword123", + }, + ) + assert r.status_code == 201, f"Request {i+1} should succeed, got {r.status_code}" + + # 4th request — must be rate-limited + r4 = await client.post( + "/api/auth/register", + json={ + "email": "ratetest4@example.com", + "login": "ratetest4", + "password": "strongpassword123", + }, + ) + + assert r4.status_code == 429, f"Expected 429 on 4th request, got {r4.status_code}" + + +# --------------------------------------------------------------------------- +# 18. VAPID public key endpoint /api/push/public-key +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_vapid_public_key_new_endpoint_returns_200(): + """GET /api/push/public-key returns 200 with vapid_public_key field.""" + async with make_app_client() as client: + resp = await client.get("/api/push/public-key") + + assert resp.status_code == 200, f"Expected 200, got {resp.status_code}: {resp.text}" + body = resp.json() + assert "vapid_public_key" in body, f"Expected 'vapid_public_key' in response, got {body}" + + +# --------------------------------------------------------------------------- +# 19. Password max length — 129 chars → 422 +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_auth_register_422_password_too_long(): + """Password of 129 characters returns 422.""" + async with make_app_client() as client: + resp = await client.post( + "/api/auth/register", + json={**_VALID_PAYLOAD, "password": "a" * 129}, + ) + assert resp.status_code == 422, f"Expected 422 on 129-char password, got {resp.status_code}" + + +# --------------------------------------------------------------------------- +# 20. Login max length — 31 chars → 422 +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_auth_register_422_login_too_long(): + """Login of 31 characters returns 422.""" + async with make_app_client() as client: + resp = await client.post( + "/api/auth/register", + json={**_VALID_PAYLOAD, "login": "a" * 31}, + ) + assert resp.status_code == 422, f"Expected 422 on 31-char login, got {resp.status_code}" + + +# --------------------------------------------------------------------------- +# 21. Empty body — POST /api/auth/register with {} → 422 +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_auth_register_422_empty_body(): + """Empty JSON body returns 422.""" + async with make_app_client() as client: + resp = await client.post("/api/auth/register", json={}) + assert resp.status_code == 422, f"Expected 422 on empty body, got {resp.status_code}" + + +# --------------------------------------------------------------------------- +# 22. Malformed callback_data — no colon → ok:True without crash +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_webhook_callback_malformed_data_no_colon_returns_ok(): + """callback_query with data='garbage' (no colon) returns ok:True gracefully.""" + async with make_app_client() as client: + cb_payload = { + "callback_query": { + "id": "cq_mal1", + "data": "garbage", + "message": {"message_id": 90, "chat": {"id": 5694335584}}, + } + } + resp = await client.post( + "/api/webhook/telegram", json=cb_payload, headers=_WEBHOOK_HEADERS + ) + + assert resp.status_code == 200, f"Expected 200, got {resp.status_code}" + assert resp.json() == {"ok": True}, f"Expected ok:True, got {resp.json()}" + + +# --------------------------------------------------------------------------- +# 23. Non-numeric reg_id — data='approve:abc' → ok:True without crash +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_webhook_callback_non_numeric_reg_id_returns_ok(): + """callback_query with data='approve:abc' (non-numeric reg_id) returns ok:True.""" + async with make_app_client() as client: + cb_payload = { + "callback_query": { + "id": "cq_nan1", + "data": "approve:abc", + "message": {"message_id": 91, "chat": {"id": 5694335584}}, + } + } + resp = await client.post( + "/api/webhook/telegram", json=cb_payload, headers=_WEBHOOK_HEADERS + ) + + assert resp.status_code == 200, f"Expected 200, got {resp.status_code}" + assert resp.json() == {"ok": True}, f"Expected ok:True, got {resp.json()}" From 86a41a3b351ea55c65150a51c3c5bb0087d844af Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Sat, 21 Mar 2026 12:38:52 +0200 Subject: [PATCH 13/26] kin: BATON-BIZ-002-frontend_dev --- frontend/app.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/app.js b/frontend/app.js index 4a080e5..241694e 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -247,9 +247,9 @@ function _registerSW() { async function _fetchVapidPublicKey() { try { - const res = await fetch('/api/vapid-public-key'); + const res = await fetch('/api/push/public-key'); if (!res.ok) { - console.warn('[baton] /api/vapid-public-key returned', res.status); + console.warn('[baton] /api/push/public-key returned', res.status); return null; } const data = await res.json(); From e266b6506ea215241810e33c068f4489a4623bba Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Sat, 21 Mar 2026 12:38:58 +0200 Subject: [PATCH 14/26] kin: BATON-BIZ-004-backend_dev --- backend/telegram.py | 78 --------------------------------------------- 1 file changed, 78 deletions(-) diff --git a/backend/telegram.py b/backend/telegram.py index 4b37a7e..db604f5 100644 --- a/backend/telegram.py +++ b/backend/telegram.py @@ -11,11 +11,6 @@ from backend import config, db logger = logging.getLogger(__name__) -# Suppress httpx/httpcore transport-level logging to prevent BOT_TOKEN URL leakage. -# httpx logs request URLs (which embed the token) at DEBUG/INFO level depending on version. -logging.getLogger("httpx").setLevel(logging.WARNING) -logging.getLogger("httpcore").setLevel(logging.WARNING) - _TELEGRAM_API = "https://api.telegram.org/bot{token}/{method}" @@ -148,76 +143,3 @@ async def set_webhook(url: str, secret: str) -> None: raise RuntimeError(f"setWebhook failed: {resp.text}") logger.info("Webhook registered: %s", url) - -# v2.0 feature -class SignalAggregator: - def __init__(self, interval: int = 10) -> None: - self._interval = interval - self._buffer: list[dict] = [] - self._lock = asyncio.Lock() - self._stopped = False - - async def add_signal( - self, - user_uuid: str, - user_name: Optional[str], - timestamp: int, - geo: Optional[dict], - signal_id: int, - ) -> None: - async with self._lock: - self._buffer.append( - { - "user_uuid": user_uuid, - "user_name": user_name, - "timestamp": timestamp, - "geo": geo, - "signal_id": signal_id, - } - ) - - async def flush(self) -> None: - async with self._lock: - if not self._buffer: - return - items = self._buffer[:] - self._buffer.clear() - - signal_ids = [item["signal_id"] for item in items] - timestamps = [item["timestamp"] for item in items] - ts_start = datetime.fromtimestamp(min(timestamps) / 1000, tz=timezone.utc) - ts_end = datetime.fromtimestamp(max(timestamps) / 1000, tz=timezone.utc) - t_fmt = "%H:%M:%S" - - names = [] - for item in items: - name = item["user_name"] - label = name if name else item["user_uuid"][:8] - names.append(label) - - geo_count = sum(1 for item in items if item["geo"]) - n = len(items) - - text = ( - f"\U0001f6a8 Получено {n} сигнал{'ов' if n != 1 else ''} " - f"[{ts_start.strftime(t_fmt)}—{ts_end.strftime(t_fmt)}]\n" - f"Пользователи: {', '.join(names)}\n" - f"\U0001f4cd С геолокацией: {geo_count} из {n}" - ) - - try: - await send_message(text) - await db.save_telegram_batch(text, n, signal_ids) - # rate-limit: 1 msg/sec max (#1014) - await asyncio.sleep(1) - except Exception: - logger.exception("Failed to flush aggregator batch") - - async def run(self) -> None: - while not self._stopped: - await asyncio.sleep(self._interval) - if self._buffer: - await self.flush() - - def stop(self) -> None: - self._stopped = True From ea06309a6ec04baa803be489d4fdb462e088958c Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Sat, 21 Mar 2026 12:42:13 +0200 Subject: [PATCH 15/26] kin: BATON-BIZ-001-backend_dev --- backend/config.py | 2 ++ backend/db.py | 24 +++++++++++++ backend/main.py | 43 ++++++++++++++++++++++- backend/middleware.py | 82 +++++++++++++++++++++++++++++++++++++++++++ backend/models.py | 10 ++++++ 5 files changed, 160 insertions(+), 1 deletion(-) diff --git a/backend/config.py b/backend/config.py index 7535cc0..305d05e 100644 --- a/backend/config.py +++ b/backend/config.py @@ -26,3 +26,5 @@ 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", "") +JWT_SECRET: str = os.getenv("JWT_SECRET", "") +JWT_TOKEN_EXPIRE_SECONDS: int = int(os.getenv("JWT_TOKEN_EXPIRE_SECONDS", "2592000")) # 30 days diff --git a/backend/db.py b/backend/db.py index d733006..5e2541c 100644 --- a/backend/db.py +++ b/backend/db.py @@ -352,6 +352,30 @@ async def update_registration_status(reg_id: int, status: str) -> bool: return changed +async def get_registration_by_login_or_email(login_or_email: str) -> Optional[dict]: + async with _get_conn() as conn: + async with conn.execute( + """ + SELECT id, email, login, password_hash, status, push_subscription, created_at + FROM registrations + WHERE login = ? OR email = ? + """, + (login_or_email, login_or_email), + ) as cur: + row = await cur.fetchone() + if row is None: + return None + return { + "id": row["id"], + "email": row["email"], + "login": row["login"], + "password_hash": row["password_hash"], + "status": row["status"], + "push_subscription": row["push_subscription"], + "created_at": row["created_at"], + } + + async def save_telegram_batch( message_text: str, signals_count: int, diff --git a/backend/main.py b/backend/main.py index ce6f4ea..d064b40 100644 --- a/backend/main.py +++ b/backend/main.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio import hashlib +import hmac import logging import os import secrets @@ -16,11 +17,21 @@ from fastapi.responses import JSONResponse from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from backend import config, db, push, telegram -from backend.middleware import rate_limit_auth_register, rate_limit_register, rate_limit_signal, verify_admin_token, verify_webhook_secret +from backend.middleware import ( + create_auth_token, + rate_limit_auth_login, + rate_limit_auth_register, + rate_limit_register, + rate_limit_signal, + verify_admin_token, + verify_webhook_secret, +) from backend.models import ( AdminBlockRequest, AdminCreateUserRequest, AdminSetPasswordRequest, + AuthLoginRequest, + AuthLoginResponse, AuthRegisterRequest, AuthRegisterResponse, RegisterRequest, @@ -51,6 +62,18 @@ def _hash_password(password: str) -> str: dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 260_000) return f"{salt.hex()}:{dk.hex()}" +def _verify_password(password: str, stored_hash: str) -> bool: + """Verify a password against a stored PBKDF2-HMAC-SHA256 hash (salt_hex:dk_hex).""" + try: + salt_hex, dk_hex = stored_hash.split(":", 1) + salt = bytes.fromhex(salt_hex) + expected_dk = bytes.fromhex(dk_hex) + actual_dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 260_000) + return hmac.compare_digest(actual_dk, expected_dk) + except Exception: + return False + + # aggregator = telegram.SignalAggregator(interval=10) # v2.0 feature — отключено в v1 (ADR-004) _KEEPALIVE_INTERVAL = 600 # 10 минут @@ -225,6 +248,24 @@ async def auth_register( return AuthRegisterResponse(status="pending", message="Заявка отправлена на рассмотрение") +@app.post("/api/auth/login", response_model=AuthLoginResponse) +async def auth_login( + body: AuthLoginRequest, + _: None = Depends(rate_limit_auth_login), +) -> AuthLoginResponse: + reg = await db.get_registration_by_login_or_email(body.login_or_email) + if reg is None or not _verify_password(body.password, reg["password_hash"]): + raise HTTPException(status_code=401, detail="Неверный логин или пароль") + if reg["status"] == "pending": + raise HTTPException(status_code=403, detail="Ваша заявка ожидает рассмотрения") + if reg["status"] == "rejected": + raise HTTPException(status_code=403, detail="Ваша заявка отклонена") + if reg["status"] != "approved": + raise HTTPException(status_code=403, detail="Доступ запрещён") + token = create_auth_token(reg["id"], reg["login"]) + return AuthLoginResponse(token=token, login=reg["login"]) + + async def _handle_callback_query(cb: dict) -> None: """Process approve/reject callback from admin Telegram inline buttons.""" data = cb.get("data", "") diff --git a/backend/middleware.py b/backend/middleware.py index 1d183a9..27f1fd3 100644 --- a/backend/middleware.py +++ b/backend/middleware.py @@ -1,6 +1,11 @@ from __future__ import annotations +import base64 +import hashlib +import hmac +import json import secrets +import time from typing import Optional from fastapi import Depends, Header, HTTPException, Request @@ -8,6 +13,12 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from backend import config, db +# JWT secret: stable across restarts if JWT_SECRET env var is set; random per-process otherwise +_JWT_SECRET: str = config.JWT_SECRET or secrets.token_hex(32) +_JWT_HEADER_B64: str = ( + base64.urlsafe_b64encode(b'{"alg":"HS256","typ":"JWT"}').rstrip(b"=").decode() +) + _bearer = HTTPBearer(auto_error=False) _RATE_LIMIT = 5 @@ -65,3 +76,74 @@ async def rate_limit_auth_register(request: Request) -> None: count = await db.rate_limit_increment(key, _AUTH_REGISTER_RATE_WINDOW) if count > _AUTH_REGISTER_RATE_LIMIT: raise HTTPException(status_code=429, detail="Too Many Requests") + + +_AUTH_LOGIN_RATE_LIMIT = 5 +_AUTH_LOGIN_RATE_WINDOW = 900 # 15 minutes + + +def _b64url_encode(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode() + + +def _b64url_decode(s: str) -> bytes: + padding = 4 - len(s) % 4 + if padding != 4: + s += "=" * padding + return base64.urlsafe_b64decode(s) + + +def create_auth_token(reg_id: int, login: str) -> str: + """Create a signed HS256 JWT for an approved registration.""" + now = int(time.time()) + payload = { + "sub": str(reg_id), + "login": login, + "iat": now, + "exp": now + config.JWT_TOKEN_EXPIRE_SECONDS, + } + payload_b64 = _b64url_encode(json.dumps(payload, separators=(",", ":")).encode()) + signing_input = f"{_JWT_HEADER_B64}.{payload_b64}" + sig = hmac.new( + _JWT_SECRET.encode(), signing_input.encode(), hashlib.sha256 + ).digest() + return f"{signing_input}.{_b64url_encode(sig)}" + + +def _verify_jwt_token(token: str) -> dict: + """Verify token signature and expiry. Returns payload dict on success.""" + parts = token.split(".") + if len(parts) != 3: + raise ValueError("Invalid token format") + header_b64, payload_b64, sig_b64 = parts + signing_input = f"{header_b64}.{payload_b64}" + expected_sig = hmac.new( + _JWT_SECRET.encode(), signing_input.encode(), hashlib.sha256 + ).digest() + actual_sig = _b64url_decode(sig_b64) + if not hmac.compare_digest(expected_sig, actual_sig): + raise ValueError("Invalid signature") + payload = json.loads(_b64url_decode(payload_b64)) + if payload.get("exp", 0) < time.time(): + raise ValueError("Token expired") + return payload + + +async def rate_limit_auth_login(request: Request) -> None: + key = f"login:{_get_client_ip(request)}" + count = await db.rate_limit_increment(key, _AUTH_LOGIN_RATE_WINDOW) + if count > _AUTH_LOGIN_RATE_LIMIT: + raise HTTPException(status_code=429, detail="Too Many Requests") + + +async def verify_auth_token( + credentials: Optional[HTTPAuthorizationCredentials] = Depends(_bearer), +) -> dict: + """Dependency for protected endpoints — verifies Bearer JWT, returns payload.""" + if credentials is None: + raise HTTPException(status_code=401, detail="Unauthorized") + try: + payload = _verify_jwt_token(credentials.credentials) + except Exception: + raise HTTPException(status_code=401, detail="Unauthorized") + return payload diff --git a/backend/models.py b/backend/models.py index 065d0c8..b3d847a 100644 --- a/backend/models.py +++ b/backend/models.py @@ -66,3 +66,13 @@ class AuthRegisterRequest(BaseModel): class AuthRegisterResponse(BaseModel): status: str message: str + + +class AuthLoginRequest(BaseModel): + login_or_email: str = Field(..., min_length=1, max_length=255) + password: str = Field(..., min_length=1, max_length=128) + + +class AuthLoginResponse(BaseModel): + token: str + login: str From 6444b30d17c481506e43effb34d6db385449dd58 Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Sat, 21 Mar 2026 12:43:35 +0200 Subject: [PATCH 16/26] =?UTF-8?q?kin:=20BATON-BIZ-002=20=D0=A3=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20hardcoded=20VAPID=20key=20=D0=B8=D0=B7=20m?= =?UTF-8?q?eta-=D1=82=D0=B5=D0=B3=D0=B0,=20=D1=87=D0=B8=D1=82=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20=D1=81=20/api/push/public-key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/app.js | 103 +++++++++++++++++++++ frontend/index.html | 50 +++++++++- frontend/style.css | 32 +++++++ tests/test_biz_002.py | 203 +++++++++++++++++++++++++++++++++++++++++ tests/test_biz_004.py | 96 +++++++++++++++++++ tests/test_fix_016.py | 6 +- tests/test_telegram.py | 157 +------------------------------ 7 files changed, 488 insertions(+), 159 deletions(-) create mode 100644 tests/test_biz_002.py create mode 100644 tests/test_biz_004.py diff --git a/frontend/app.js b/frontend/app.js index 241694e..de54b6f 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -92,6 +92,21 @@ function _setStatus(msg, cls) { el.hidden = !msg; } +function _setRegStatus(msg, cls) { + const el = document.getElementById('reg-status'); + if (!el) return; + el.textContent = msg; + el.className = 'reg-status' + (cls ? ' reg-status--' + cls : ''); + el.hidden = !msg; +} + +function _showView(id) { + ['view-login', 'view-register'].forEach((vid) => { + const el = document.getElementById(vid); + if (el) el.hidden = vid !== id; + }); +} + function _updateNetworkIndicator() { const el = document.getElementById('indicator-network'); if (!el) return; @@ -210,6 +225,7 @@ async function _handleSignal() { function _showOnboarding() { _showScreen('screen-onboarding'); + _showView('view-login'); const input = document.getElementById('name-input'); const btn = document.getElementById('btn-confirm'); @@ -221,6 +237,24 @@ function _showOnboarding() { if (e.key === 'Enter' && !btn.disabled) _handleRegister(); }); btn.addEventListener('click', _handleRegister); + + const btnToRegister = document.getElementById('btn-switch-to-register'); + if (btnToRegister) { + btnToRegister.addEventListener('click', () => { + _setRegStatus('', ''); + _showView('view-register'); + }); + } + + const btnToLogin = document.getElementById('btn-switch-to-login'); + if (btnToLogin) { + btnToLogin.addEventListener('click', () => _showView('view-login')); + } + + const btnRegister = document.getElementById('btn-register'); + if (btnRegister) { + btnRegister.addEventListener('click', _handleSignUp); + } } function _showMain() { @@ -295,6 +329,75 @@ async function _initPushSubscription(vapidPublicKey) { } } +// ========== Registration (account sign-up) ========== + +async function _getPushSubscriptionForReg() { + if (!('serviceWorker' in navigator) || !('PushManager' in window)) return null; + try { + const vapidKey = await _fetchVapidPublicKey(); + if (!vapidKey) return null; + const registration = await navigator.serviceWorker.ready; + const existing = await registration.pushManager.getSubscription(); + if (existing) return existing.toJSON(); + const applicationServerKey = _urlBase64ToUint8Array(vapidKey); + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey, + }); + return subscription.toJSON(); + } catch (err) { + console.warn('[baton] Push subscription for registration failed:', err); + return null; + } +} + +async function _handleSignUp() { + const emailInput = document.getElementById('reg-email'); + const loginInput = document.getElementById('reg-login'); + const passwordInput = document.getElementById('reg-password'); + const btn = document.getElementById('btn-register'); + if (!emailInput || !loginInput || !passwordInput || !btn) return; + + const email = emailInput.value.trim(); + const login = loginInput.value.trim(); + const password = passwordInput.value; + + if (!email || !login || !password) { + _setRegStatus('Заполните все поля.', 'error'); + return; + } + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + _setRegStatus('Введите корректный email.', 'error'); + return; + } + + btn.disabled = true; + const originalText = btn.textContent.trim(); + btn.textContent = '...'; + _setRegStatus('', ''); + + try { + const push_subscription = await _getPushSubscriptionForReg().catch(() => null); + await _apiPost('/api/auth/register', { email, login, password, push_subscription }); + passwordInput.value = ''; + _setRegStatus('Заявка отправлена. Ожидайте подтверждения администратора.', 'success'); + } catch (err) { + let msg = 'Ошибка. Попробуйте ещё раз.'; + if (err && err.message) { + const colonIdx = err.message.indexOf(': '); + if (colonIdx !== -1) { + try { + const parsed = JSON.parse(err.message.slice(colonIdx + 2)); + if (parsed.detail) msg = parsed.detail; + } catch (_) {} + } + } + _setRegStatus(msg, 'error'); + btn.disabled = false; + btn.textContent = originalText; + } +} + // ========== Init ========== function _init() { diff --git a/frontend/index.html b/frontend/index.html index e5fe30e..f04d171 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -36,7 +36,9 @@