From 8c4c46ee9257cee3987dd02800d2acdb27212d62 Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Sat, 21 Mar 2026 12:23:05 +0200 Subject: [PATCH] =?UTF-8?q?kin:=20BATON-FIX-016=20[TECH=20DEBT]=20VAPID=20?= =?UTF-8?q?public=20key=20=D0=B6=D1=91=D1=81=D1=82=D0=BA=D0=BE=20=D0=B2?= =?UTF-8?q?=D1=88=D0=B8=D1=82=20=D0=BA=D0=B0=D0=BA=20=D0=BF=D1=83=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D1=8F=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=B2=20-=D1=82=D0=B5=D0=B3=20=E2=80=94=20=D1=82=D1=80?= =?UTF-8?q?=D0=B5=D0=B1=D1=83=D0=B5=D1=82=20=D1=80=D1=83=D1=87=D0=BD=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=20=D0=B7=D0=B0=D0=BF=D0=BE=D0=BB=D0=BD=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=BF=D1=80=D0=B8=20=D0=B4=D0=B5=D0=BF=D0=BB?= =?UTF-8?q?=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}" + )