204 lines
9.6 KiB
Python
204 lines
9.6 KiB
Python
|
|
"""
|
|||
|
|
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 отсутствует при пустом конфиге"
|