""" Tests for BATON-BIZ-002: Убрать hardcoded VAPID key из meta-тега, читать с /api/push/public-key Acceptance criteria: 1. Meta-тег vapid-public-key полностью отсутствует в frontend/index.html (decision #1333). 2. app.js использует canonical URL /api/push/public-key для получения VAPID ключа. 3. Graceful fallback: endpoint недоступен → функция возвращает null, не бросает исключение. 4. Graceful fallback: ключ пустой → _initPushSubscription не выполняется (guard на null). 5. GET /api/push/public-key возвращает HTTP 200 с полем vapid_public_key. 6. GET /api/push/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 INDEX_HTML = PROJECT_ROOT / "frontend" / "index.html" APP_JS = PROJECT_ROOT / "frontend" / "app.js" _TEST_VAPID_KEY = "BFakeVapidPublicKeyForBiz002TestingBase64UrlEncoded" # --------------------------------------------------------------------------- # Criterion 1 — AST: meta-тег vapid-public-key полностью отсутствует # --------------------------------------------------------------------------- def test_index_html_has_no_meta_tag_named_vapid_public_key() -> None: """index.html не должен содержать вообще (decision #1333).""" content = INDEX_HTML.read_text(encoding="utf-8") match = re.search( r']+name\s*=\s*["\']vapid-public-key["\']', content, re.IGNORECASE, ) assert match is None, ( f"index.html содержит удалённый тег : {match.group(0)!r}" ) def test_index_html_has_no_vapid_meta_tag_with_empty_or_any_content() -> None: """index.html не должен содержать ни пустой, ни непустой VAPID ключ в meta content.""" content = INDEX_HTML.read_text(encoding="utf-8") match = re.search( r']*(?:vapid|application-server-key)[^>]*content\s*=', content, re.IGNORECASE, ) assert match is None, ( f"index.html содержит -тег с VAPID-связанным атрибутом content: {match.group(0)!r}" ) # --------------------------------------------------------------------------- # Criterion 2 — AST: app.js использует canonical /api/push/public-key # --------------------------------------------------------------------------- def test_app_js_fetch_vapid_uses_canonical_push_public_key_url() -> None: """_fetchVapidPublicKey в app.js должна использовать /api/push/public-key (canonical URL).""" content = APP_JS.read_text(encoding="utf-8") assert "/api/push/public-key" in content, ( "app.js не содержит canonical URL '/api/push/public-key' — " "ключ не читается через правильный endpoint" ) def test_app_js_fetch_vapid_returns_vapid_public_key_field() -> None: """_fetchVapidPublicKey должна читать поле vapid_public_key из JSON-ответа.""" content = APP_JS.read_text(encoding="utf-8") assert re.search(r"data\.vapid_public_key", content), ( "app.js не читает поле 'data.vapid_public_key' из ответа API" ) # --------------------------------------------------------------------------- # Criterion 3 — AST: graceful fallback когда endpoint недоступен # --------------------------------------------------------------------------- def test_app_js_fetch_vapid_returns_null_on_http_error() -> None: """_fetchVapidPublicKey должна возвращать null при res.ok === false (HTTP-ошибка).""" content = APP_JS.read_text(encoding="utf-8") assert re.search(r"if\s*\(\s*!\s*res\.ok\s*\)", content), ( "app.js не содержит проверку 'if (!res.ok)' — " "HTTP-ошибки не обрабатываются gracefully в _fetchVapidPublicKey" ) def test_app_js_fetch_vapid_catches_network_errors() -> None: """_fetchVapidPublicKey должна оборачивать fetch в try/catch и возвращать null при сетевой ошибке.""" content = APP_JS.read_text(encoding="utf-8") # Проверяем паттерн try { fetch ... } catch (err) { return null; } внутри функции func_match = re.search( r"async function _fetchVapidPublicKey\(\).*?(?=^(?:async )?function |\Z)", content, re.DOTALL | re.MULTILINE, ) assert func_match, "Функция _fetchVapidPublicKey не найдена в app.js" func_body = func_match.group(0) assert "catch" in func_body, ( "app.js: _fetchVapidPublicKey не содержит блок catch — " "сетевые ошибки при fetch не обрабатываются" ) assert re.search(r"return\s+null", func_body), ( "app.js: _fetchVapidPublicKey не возвращает null при ошибке — " "upstream код получит исключение вместо null" ) # --------------------------------------------------------------------------- # Criterion 4 — AST: graceful fallback когда ключ пустой (decision #1332) # --------------------------------------------------------------------------- def test_app_js_fetch_vapid_returns_null_on_empty_key() -> None: """_fetchVapidPublicKey должна возвращать null когда vapid_public_key пустой.""" content = APP_JS.read_text(encoding="utf-8") assert re.search(r"data\.vapid_public_key\s*\|\|\s*null", content), ( "app.js не содержит 'data.vapid_public_key || null' — " "пустой ключ не преобразуется в null" ) def test_app_js_init_push_subscription_guard_skips_on_null_key() -> None: """_initPushSubscription должна ранним возвратом пропускать подписку при null ключе (decision #1332).""" 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)' — " "подписка может быть создана без ключа" ) # --------------------------------------------------------------------------- # Criterion 5 — HTTP: GET /api/push/public-key → 200 + vapid_public_key # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_push_public_key_endpoint_returns_200() -> None: """GET /api/push/public-key должен вернуть HTTP 200.""" async with make_app_client() as client: response = await client.get("/api/push/public-key") assert response.status_code == 200, ( f"GET /api/push/public-key вернул {response.status_code}, ожидался 200" ) @pytest.mark.asyncio async def test_push_public_key_endpoint_returns_json_with_vapid_field() -> None: """GET /api/push/public-key должен вернуть JSON с полем vapid_public_key.""" async with make_app_client() as client: response = await client.get("/api/push/public-key") data = response.json() assert "vapid_public_key" in data, ( f"Ответ /api/push/public-key не содержит поле 'vapid_public_key': {data!r}" ) # --------------------------------------------------------------------------- # Criterion 6 — HTTP: возвращает правильное значение из конфига # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_push_public_key_endpoint_returns_configured_value() -> None: """GET /api/push/public-key возвращает значение из VAPID_PUBLIC_KEY конфига.""" with patch("backend.config.VAPID_PUBLIC_KEY", _TEST_VAPID_KEY): async with make_app_client() as client: response = await client.get("/api/push/public-key") data = response.json() assert data.get("vapid_public_key") == _TEST_VAPID_KEY, ( f"vapid_public_key должен быть '{_TEST_VAPID_KEY}', " f"получили: {data.get('vapid_public_key')!r}" ) @pytest.mark.asyncio async def test_push_public_key_endpoint_returns_empty_string_when_not_configured() -> None: """GET /api/push/public-key возвращает пустую строку (не ошибку) если ключ не настроен.""" with patch("backend.config.VAPID_PUBLIC_KEY", ""): async with make_app_client() as client: response = await client.get("/api/push/public-key") assert response.status_code == 200, ( f"Endpoint вернул {response.status_code} при пустом ключе, ожидался 200" ) data = response.json() assert "vapid_public_key" in data, "Поле vapid_public_key отсутствует при пустом конфиге"