kin: BATON-BIZ-002 Убрать hardcoded VAPID key из meta-тега, читать с /api/push/public-key
This commit is contained in:
parent
ea06309a6e
commit
6444b30d17
7 changed files with 488 additions and 159 deletions
203
tests/test_biz_002.py
Normal file
203
tests/test_biz_002.py
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
"""
|
||||
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 не должен содержать <meta name='vapid-public-key'> вообще (decision #1333)."""
|
||||
content = INDEX_HTML.read_text(encoding="utf-8")
|
||||
match = re.search(
|
||||
r'<meta[^>]+name\s*=\s*["\']vapid-public-key["\']',
|
||||
content,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
assert match is None, (
|
||||
f"index.html содержит удалённый тег <meta name='vapid-public-key'>: {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'<meta[^>]*(?:vapid|application-server-key)[^>]*content\s*=',
|
||||
content,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
assert match is None, (
|
||||
f"index.html содержит <meta>-тег с 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 отсутствует при пустом конфиге"
|
||||
Loading…
Add table
Add a link
Reference in a new issue