244 lines
10 KiB
Python
244 lines
10 KiB
Python
"""
|
||
Tests for BATON-006: не работает фронт — {"detail":"Not Found"}
|
||
|
||
Acceptance criteria:
|
||
1. nginx/baton.conf содержит location /api/ (prefix match), проксирует на FastAPI.
|
||
2. nginx/baton.conf содержит location /health, проксирует на FastAPI.
|
||
3. nginx/baton.conf содержит location / с root и try_files (SPA-поведение).
|
||
4. GET / на FastAPI возвращает 404 (маршрут / не зарегистрирован в main.py —
|
||
статику должен отдавать nginx, а не FastAPI).
|
||
5. GET /health возвращает 200 (FastAPI-маршрут работает).
|
||
6. POST /api/register возвращает 200 (FastAPI-маршрут не сломан).
|
||
7. POST /api/signal возвращает 200 (FastAPI-маршрут не сломан).
|
||
8. POST /api/webhook/telegram возвращает 200 с корректным секретом.
|
||
|
||
BATON-SEC-003: POST /api/signal now requires Authorization: Bearer <api_key>.
|
||
UUID constants satisfy the UUID v4 pattern.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import re
|
||
from pathlib import Path
|
||
|
||
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 tests.conftest import make_app_client
|
||
|
||
PROJECT_ROOT = Path(__file__).parent.parent
|
||
NGINX_CONF = PROJECT_ROOT / "nginx" / "baton.conf"
|
||
|
||
# Valid UUID v4 constants
|
||
_UUID_REG = "e0000001-0000-4000-8000-000000000001"
|
||
_UUID_SIG = "e0000002-0000-4000-8000-000000000002"
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Criterion 1 — location /api/ proxies to FastAPI
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_nginx_conf_exists() -> None:
|
||
"""nginx/baton.conf должен существовать в репозитории."""
|
||
assert NGINX_CONF.is_file(), f"nginx/baton.conf не найден: {NGINX_CONF}"
|
||
|
||
|
||
def test_nginx_conf_has_api_location_block() -> None:
|
||
"""nginx/baton.conf должен содержать location /api/ (prefix match)."""
|
||
content = NGINX_CONF.read_text(encoding="utf-8")
|
||
assert re.search(r"location\s+/api/", content), (
|
||
"nginx/baton.conf не содержит блок location /api/"
|
||
)
|
||
|
||
|
||
def test_nginx_conf_api_location_proxies_to_fastapi() -> None:
|
||
"""Блок location /api/ должен делать proxy_pass на 127.0.0.1:8000."""
|
||
content = NGINX_CONF.read_text(encoding="utf-8")
|
||
api_block = re.search(
|
||
r"location\s+/api/\s*\{([^}]+)\}", content, re.DOTALL
|
||
)
|
||
assert api_block is not None, "Блок location /api/ { ... } не найден"
|
||
assert re.search(r"proxy_pass\s+http://127\.0\.0\.1:8000", api_block.group(1)), (
|
||
"Блок location /api/ не содержит proxy_pass http://127.0.0.1:8000"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Criterion 2 — location /health proxies to FastAPI
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_nginx_conf_has_health_location_block() -> None:
|
||
"""nginx/baton.conf должен содержать отдельный location /health."""
|
||
content = NGINX_CONF.read_text(encoding="utf-8")
|
||
assert re.search(r"location\s+/health\b", content), (
|
||
"nginx/baton.conf не содержит блок location /health"
|
||
)
|
||
|
||
|
||
def test_nginx_conf_health_location_proxies_to_fastapi() -> None:
|
||
"""Блок location /health должен делать proxy_pass на 127.0.0.1:8000."""
|
||
content = NGINX_CONF.read_text(encoding="utf-8")
|
||
health_block = re.search(
|
||
r"location\s+/health\s*\{([^}]+)\}", content, re.DOTALL
|
||
)
|
||
assert health_block is not None, "Блок location /health { ... } не найден"
|
||
assert re.search(r"proxy_pass\s+http://127\.0\.0\.1:8000", health_block.group(1)), (
|
||
"Блок location /health не содержит proxy_pass http://127.0.0.1:8000"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Criterion 3 — location / serves static files (SPA)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_nginx_conf_root_location_has_root_directive() -> None:
|
||
"""location / в nginx.conf должен содержать директиву root (статика)."""
|
||
content = NGINX_CONF.read_text(encoding="utf-8")
|
||
root_block = re.search(
|
||
r"location\s+/\s*\{([^}]+)\}", content, re.DOTALL
|
||
)
|
||
assert root_block is not None, "Блок location / { ... } не найден"
|
||
assert re.search(r"root\s+", root_block.group(1)), (
|
||
"Блок location / не содержит директиву root — SPA статика не настроена"
|
||
)
|
||
|
||
|
||
def test_nginx_conf_root_location_has_try_files_for_spa() -> None:
|
||
"""location / должен содержать try_files с fallback на /index.html (SPA)."""
|
||
content = NGINX_CONF.read_text(encoding="utf-8")
|
||
root_block = re.search(
|
||
r"location\s+/\s*\{([^}]+)\}", content, re.DOTALL
|
||
)
|
||
assert root_block is not None, "Блок location / { ... } не найден"
|
||
assert re.search(r"try_files\s+\$uri\s+/index\.html", root_block.group(1)), (
|
||
"Блок location / не содержит try_files $uri /index.html — "
|
||
"SPA-роутинг не работает"
|
||
)
|
||
|
||
|
||
def test_nginx_conf_root_location_does_not_proxy_to_fastapi() -> None:
|
||
"""location / НЕ должен делать proxy_pass на FastAPI (только статика)."""
|
||
content = NGINX_CONF.read_text(encoding="utf-8")
|
||
root_block = re.search(
|
||
r"location\s+/\s*\{([^}]+)\}", content, re.DOTALL
|
||
)
|
||
assert root_block is not None, "Блок location / { ... } не найден"
|
||
assert not re.search(r"proxy_pass", root_block.group(1)), (
|
||
"Блок location / содержит proxy_pass — GET / будет проксирован в FastAPI, "
|
||
"что вернёт 404 {'detail':'Not Found'} (исходная ошибка BATON-006)"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Criterion 4 — FastAPI не имеет маршрута GET /
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_fastapi_root_returns_404() -> None:
|
||
"""GET / должен возвращать 404 от FastAPI — маршрут не зарегистрирован.
|
||
|
||
Это ожидаемое поведение: статику отдаёт nginx (location / с root + try_files),
|
||
а не FastAPI. Регрессия: если когда-нибудь GET / начнёт возвращать 200 от FastAPI,
|
||
это нарушит архитектуру (FastAPI не должен отдавать статику).
|
||
"""
|
||
async with make_app_client() as client:
|
||
response = await client.get("/")
|
||
|
||
assert response.status_code == 404, (
|
||
f"GET / должен возвращать 404 от FastAPI (статику отдаёт nginx). "
|
||
f"Получено: {response.status_code}"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Criterion 5 — GET /health работает
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_health_endpoint_returns_200() -> None:
|
||
"""GET /health должен возвращать 200 после изменений nginx-конфига."""
|
||
async with make_app_client() as client:
|
||
response = await client.get("/health")
|
||
|
||
assert response.status_code == 200
|
||
assert response.json().get("status") == "ok"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Criterion 6 — POST /api/register не сломан
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_api_register_not_broken_after_nginx_change() -> None:
|
||
"""POST /api/register должен вернуть 200 — функция не сломана изменением nginx."""
|
||
async with make_app_client() as client:
|
||
response = await client.post(
|
||
"/api/register",
|
||
json={"uuid": _UUID_REG, "name": "TestUser"},
|
||
)
|
||
|
||
assert response.status_code == 200
|
||
data = response.json()
|
||
assert data["user_id"] > 0
|
||
assert data["uuid"] == _UUID_REG
|
||
assert "api_key" in data
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Criterion 7 — POST /api/signal не сломан
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_api_signal_not_broken_after_nginx_change() -> None:
|
||
"""POST /api/signal должен вернуть 200 — функция не сломана изменением nginx."""
|
||
async with make_app_client() as client:
|
||
reg_resp = await client.post(
|
||
"/api/register",
|
||
json={"uuid": _UUID_SIG, "name": "SignalUser"},
|
||
)
|
||
assert reg_resp.status_code == 200
|
||
api_key = reg_resp.json()["api_key"]
|
||
|
||
response = await client.post(
|
||
"/api/signal",
|
||
json={
|
||
"user_id": _UUID_SIG,
|
||
"timestamp": 1700000000000,
|
||
"geo": None,
|
||
},
|
||
headers={"Authorization": f"Bearer {api_key}"},
|
||
)
|
||
|
||
assert response.status_code == 200
|
||
assert response.json().get("status") == "ok"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Criterion 8 — POST /api/webhook/telegram не сломан
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_api_webhook_telegram_not_broken_after_nginx_change() -> None:
|
||
"""POST /api/webhook/telegram с корректным секретом должен вернуть 200."""
|
||
async with make_app_client() as client:
|
||
response = await client.post(
|
||
"/api/webhook/telegram",
|
||
json={"update_id": 200, "message": {"text": "hello"}},
|
||
headers={"X-Telegram-Bot-Api-Secret-Token": "test-webhook-secret"},
|
||
)
|
||
|
||
assert response.status_code == 200
|
||
assert response.json() == {"ok": True}
|