Compare commits

...

9 commits

Author SHA1 Message Date
Gros Frumos
dd556e2f05 sec: pre-commit hook + httpx exception logging hardening
1. .pre-commit-config.yaml — local pygrep hook блокирует коммиты
   с токенами формата \d{9,10}:AA[A-Za-z0-9_-]{35} (Telegram bot tokens).
   Проверено: срабатывает на токен, пропускает чистые файлы.

2. backend/telegram.py — три функции (send_registration_notification,
   answer_callback_query, edit_message_text) логировали exc напрямую,
   что раскрывало BOT_TOKEN в URL httpx-исключений в journalctl.
   Заменено на type(exc).__name__ — только тип ошибки, без URL.

Refs: #1303, #1309, #1283

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 10:55:34 +02:00
Gros Frumos
5401363ea9 kin: BATON-FIX-013 CORS allow_methods: добавить GET для /health эндпоинтов 2026-03-21 09:37:57 +02:00
Gros Frumos
c7661d7c1e Merge branch 'BATON-008-backend_dev' 2026-03-21 09:34:21 +02:00
Gros Frumos
fde7f57a7a kin: BATON-008-backend_dev 2026-03-21 09:34:21 +02:00
Gros Frumos
35eef641fd Merge branch 'BATON-FIX-013-backend_dev' 2026-03-21 09:34:18 +02:00
Gros Frumos
283ff61dc5 fix: sync allow_methods с main — добавить HEAD и OPTIONS 2026-03-21 09:33:53 +02:00
Gros Frumos
6d5d84a882 fix: CORS allow_methods добавить GET для /health эндпоинтов
CORSMiddleware: allow_methods=['POST'] → ['GET', 'POST']
Позволяет браузерам делать GET-запросы к /health и /api/health без CORS-блокировки.

BATON-FIX-013
2026-03-21 09:33:09 +02:00
Gros Frumos
257631436a Merge branch 'BATON-FIX-007-backend_dev' 2026-03-21 09:30:44 +02:00
Gros Frumos
b2fecc5993 kin: BATON-FIX-007-backend_dev 2026-03-21 09:30:44 +02:00
9 changed files with 415 additions and 24 deletions

11
.pre-commit-config.yaml Normal file
View file

@ -0,0 +1,11 @@
repos:
- repo: local
hooks:
- id: no-telegram-bot-token
name: Block Telegram bot tokens
# Matches tokens of format: 1234567890:AAFisjLS-yO_AmwqMjpBQgfV9qlHnexZlMs
# Pattern: 9-10 digits, colon, "AA", then 35 alphanumeric/dash/underscore chars
entry: '\d{9,10}:AA[A-Za-z0-9_-]{35}'
language: pygrep
types: [text]
exclude: '^\.pre-commit-config\.yaml$'

View file

@ -22,7 +22,7 @@ WEBHOOK_ENABLED: bool = os.getenv("WEBHOOK_ENABLED", "true").lower() == "true"
FRONTEND_ORIGIN: str = os.getenv("FRONTEND_ORIGIN", "http://localhost:3000") FRONTEND_ORIGIN: str = os.getenv("FRONTEND_ORIGIN", "http://localhost:3000")
APP_URL: str | None = os.getenv("APP_URL") # Публичный URL приложения для keep-alive self-ping APP_URL: str | None = os.getenv("APP_URL") # Публичный URL приложения для keep-alive self-ping
ADMIN_TOKEN: str = _require("ADMIN_TOKEN") ADMIN_TOKEN: str = _require("ADMIN_TOKEN")
ADMIN_CHAT_ID: str = os.getenv("ADMIN_CHAT_ID", "5694335584") ADMIN_CHAT_ID: str = _require("ADMIN_CHAT_ID")
VAPID_PRIVATE_KEY: str = os.getenv("VAPID_PRIVATE_KEY", "") VAPID_PRIVATE_KEY: str = os.getenv("VAPID_PRIVATE_KEY", "")
VAPID_PUBLIC_KEY: str = os.getenv("VAPID_PUBLIC_KEY", "") VAPID_PUBLIC_KEY: str = os.getenv("VAPID_PUBLIC_KEY", "")
VAPID_CLAIMS_EMAIL: str = os.getenv("VAPID_CLAIMS_EMAIL", "") VAPID_CLAIMS_EMAIL: str = os.getenv("VAPID_CLAIMS_EMAIL", "")

View file

@ -341,9 +341,10 @@ async def get_registration(reg_id: int) -> Optional[dict]:
async def update_registration_status(reg_id: int, status: str) -> bool: async def update_registration_status(reg_id: int, status: str) -> bool:
"""Update registration status only if currently 'pending'. Returns False if already processed."""
async with _get_conn() as conn: async with _get_conn() as conn:
async with conn.execute( async with conn.execute(
"UPDATE registrations SET status = ? WHERE id = ?", "UPDATE registrations SET status = ? WHERE id = ? AND status = 'pending'",
(status, reg_id), (status, reg_id),
) as cur: ) as cur:
changed = cur.rowcount > 0 changed = cur.rowcount > 0

View file

@ -120,7 +120,7 @@ app = FastAPI(lifespan=lifespan)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=[config.FRONTEND_ORIGIN], allow_origins=[config.FRONTEND_ORIGIN],
allow_methods=["POST"], allow_methods=["GET", "HEAD", "OPTIONS", "POST"],
allow_headers=["Content-Type", "Authorization"], allow_headers=["Content-Type", "Authorization"],
) )
@ -240,7 +240,11 @@ async def _handle_callback_query(cb: dict) -> None:
return return
if action == "approve": if action == "approve":
await db.update_registration_status(reg_id, "approved") updated = await db.update_registration_status(reg_id, "approved")
if not updated:
# Already processed (not pending) — ack the callback and stop
await telegram.answer_callback_query(callback_query_id)
return
if chat_id and message_id: if chat_id and message_id:
await telegram.edit_message_text( await telegram.edit_message_text(
chat_id, message_id, f"✅ Пользователь {reg['login']} одобрен" chat_id, message_id, f"✅ Пользователь {reg['login']} одобрен"
@ -254,7 +258,10 @@ async def _handle_callback_query(cb: dict) -> None:
) )
) )
elif action == "reject": elif action == "reject":
await db.update_registration_status(reg_id, "rejected") updated = await db.update_registration_status(reg_id, "rejected")
if not updated:
await telegram.answer_callback_query(callback_query_id)
return
if chat_id and message_id: if chat_id and message_id:
await telegram.edit_message_text( await telegram.edit_message_text(
chat_id, message_id, f"❌ Пользователь {reg['login']} отклонён" chat_id, message_id, f"❌ Пользователь {reg['login']} отклонён"

View file

@ -106,7 +106,8 @@ async def send_registration_notification(
resp.text, resp.text,
) )
except Exception as exc: except Exception as exc:
logger.error("send_registration_notification error: %s", exc) # Do not log exc directly — httpx exceptions embed the full API URL with BOT_TOKEN
logger.error("send_registration_notification error: %s", type(exc).__name__)
async def answer_callback_query(callback_query_id: str) -> None: async def answer_callback_query(callback_query_id: str) -> None:
@ -118,7 +119,8 @@ async def answer_callback_query(callback_query_id: str) -> None:
if resp.status_code != 200: if resp.status_code != 200:
logger.error("answerCallbackQuery failed %s: %s", resp.status_code, resp.text) logger.error("answerCallbackQuery failed %s: %s", resp.status_code, resp.text)
except Exception as exc: except Exception as exc:
logger.error("answerCallbackQuery error: %s", exc) # Do not log exc directly — httpx exceptions embed the full API URL with BOT_TOKEN
logger.error("answerCallbackQuery error: %s", type(exc).__name__)
async def edit_message_text(chat_id: str | int, message_id: int, text: str) -> None: async def edit_message_text(chat_id: str | int, message_id: int, text: str) -> None:
@ -132,7 +134,8 @@ async def edit_message_text(chat_id: str | int, message_id: int, text: str) -> N
if resp.status_code != 200: if resp.status_code != 200:
logger.error("editMessageText failed %s: %s", resp.status_code, resp.text) logger.error("editMessageText failed %s: %s", resp.status_code, resp.text)
except Exception as exc: except Exception as exc:
logger.error("editMessageText error: %s", exc) # Do not log exc directly — httpx exceptions embed the full API URL with BOT_TOKEN
logger.error("editMessageText error: %s", type(exc).__name__)
async def set_webhook(url: str, secret: str) -> None: async def set_webhook(url: str, secret: str) -> None:

View file

@ -22,6 +22,7 @@ os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret")
os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram") os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram")
os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000") os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000")
os.environ.setdefault("ADMIN_TOKEN", "test-admin-token") os.environ.setdefault("ADMIN_TOKEN", "test-admin-token")
os.environ.setdefault("ADMIN_CHAT_ID", "5694335584")
# ── 2. aiosqlite monkey-patch ──────────────────────────────────────────────── # ── 2. aiosqlite monkey-patch ────────────────────────────────────────────────
import aiosqlite import aiosqlite
@ -69,14 +70,20 @@ def temp_db():
# ── 5. App client factory ──────────────────────────────────────────────────── # ── 5. App client factory ────────────────────────────────────────────────────
def make_app_client(): def make_app_client(capture_send_requests: list | None = None):
""" """
Async context manager that: Async context manager that:
1. Assigns a fresh temp-file DB path 1. Assigns a fresh temp-file DB path
2. Mocks Telegram setWebhook, sendMessage, answerCallbackQuery, editMessageText 2. Mocks Telegram setWebhook, sendMessage, answerCallbackQuery, editMessageText
3. Runs the FastAPI lifespan (startup test shutdown) 3. Runs the FastAPI lifespan (startup test shutdown)
4. Yields an httpx.AsyncClient wired to the app 4. Yields an httpx.AsyncClient wired to the app
Args:
capture_send_requests: if provided, each sendMessage request body (dict) is
appended to this list, enabling HTTP-level assertions on chat_id, text, etc.
""" """
import json as _json
tg_set_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/setWebhook" tg_set_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/setWebhook"
send_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage" send_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage"
get_me_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/getMe" get_me_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/getMe"
@ -95,6 +102,15 @@ def make_app_client():
mock_router.post(tg_set_url).mock( mock_router.post(tg_set_url).mock(
return_value=httpx.Response(200, json={"ok": True, "result": True}) return_value=httpx.Response(200, json={"ok": True, "result": True})
) )
if capture_send_requests is not None:
def _capture_send(request: httpx.Request) -> httpx.Response:
try:
capture_send_requests.append(_json.loads(request.content))
except Exception:
pass
return httpx.Response(200, json={"ok": True})
mock_router.post(send_url).mock(side_effect=_capture_send)
else:
mock_router.post(send_url).mock( mock_router.post(send_url).mock(
return_value=httpx.Response(200, json={"ok": True}) return_value=httpx.Response(200, json={"ok": True})
) )

View file

@ -21,6 +21,7 @@ os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret")
os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram") os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram")
os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000") os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000")
os.environ.setdefault("ADMIN_TOKEN", "test-admin-token") os.environ.setdefault("ADMIN_TOKEN", "test-admin-token")
os.environ.setdefault("ADMIN_CHAT_ID", "5694335584")
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
@ -167,20 +168,23 @@ async def test_auth_register_422_short_password():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_auth_register_sends_notification_to_admin(): async def test_auth_register_sends_notification_to_admin():
"""Registration triggers send_registration_notification with correct data.""" """Registration triggers real HTTP sendMessage to ADMIN_CHAT_ID with correct login/email."""
calls: list[dict] = [] from backend import config as _cfg
async def _capture(reg_id, login, email, created_at): captured: list[dict] = []
calls.append({"reg_id": reg_id, "login": login, "email": email}) async with make_app_client(capture_send_requests=captured) as client:
resp = await client.post("/api/auth/register", json=_VALID_PAYLOAD)
async with make_app_client() as client: assert resp.status_code == 201
with patch("backend.telegram.send_registration_notification", side_effect=_capture):
await client.post("/api/auth/register", json=_VALID_PAYLOAD)
await asyncio.sleep(0) await asyncio.sleep(0)
assert len(calls) == 1, f"Expected 1 notification call, got {len(calls)}" admin_chat_id = str(_cfg.ADMIN_CHAT_ID)
assert calls[0]["login"] == _VALID_PAYLOAD["login"] admin_msgs = [r for r in captured if str(r.get("chat_id")) == admin_chat_id]
assert calls[0]["email"] == _VALID_PAYLOAD["email"] assert len(admin_msgs) >= 1, (
f"Expected sendMessage to ADMIN_CHAT_ID={admin_chat_id!r}, captured: {captured}"
)
text = admin_msgs[0].get("text", "")
assert _VALID_PAYLOAD["login"] in text, f"Expected login in text: {text!r}"
assert _VALID_PAYLOAD["email"] in text, f"Expected email in text: {text!r}"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

155
tests/test_fix_007.py Normal file
View file

@ -0,0 +1,155 @@
"""
Tests for BATON-FIX-007: CORS OPTIONS preflight verification.
Acceptance criteria:
1. OPTIONS preflight to /api/signal returns 200.
2. Preflight response includes Access-Control-Allow-Methods containing GET.
3. Preflight response includes Access-Control-Allow-Origin matching the configured origin.
4. Preflight response includes Access-Control-Allow-Headers with Authorization.
5. allow_methods in CORSMiddleware configuration explicitly contains GET.
"""
from __future__ import annotations
import ast
from pathlib import Path
import pytest
from tests.conftest import make_app_client
_FRONTEND_ORIGIN = "http://localhost:3000"
_BACKEND_DIR = Path(__file__).parent.parent / "backend"
# ---------------------------------------------------------------------------
# Static check — CORSMiddleware config contains GET in allow_methods
# ---------------------------------------------------------------------------
def test_main_py_cors_allow_methods_contains_get() -> None:
"""allow_methods в CORSMiddleware должен содержать 'GET'."""
source = (_BACKEND_DIR / "main.py").read_text(encoding="utf-8")
tree = ast.parse(source, filename="main.py")
for node in ast.walk(tree):
if isinstance(node, ast.Call):
func = node.func
if isinstance(func, ast.Name) and func.id == "add_middleware":
continue
if not (
isinstance(func, ast.Attribute) and func.attr == "add_middleware"
):
continue
for kw in node.keywords:
if kw.arg == "allow_methods":
if isinstance(kw.value, ast.List):
methods = [
elt.value
for elt in kw.value.elts
if isinstance(elt, ast.Constant) and isinstance(elt.value, str)
]
assert "GET" in methods, (
f"allow_methods в CORSMiddleware не содержит 'GET': {methods}"
)
return
pytest.fail("add_middleware с CORSMiddleware и allow_methods не найден в main.py")
def test_main_py_cors_allow_methods_contains_post() -> None:
"""allow_methods в CORSMiddleware должен содержать 'POST' (регрессия)."""
source = (_BACKEND_DIR / "main.py").read_text(encoding="utf-8")
assert '"POST"' in source or "'POST'" in source, (
"allow_methods в CORSMiddleware не содержит 'POST'"
)
# ---------------------------------------------------------------------------
# Functional — OPTIONS preflight request
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_options_preflight_signal_returns_200() -> None:
"""OPTIONS preflight к /api/signal должен возвращать 200."""
async with make_app_client() as client:
resp = await client.options(
"/api/signal",
headers={
"Origin": _FRONTEND_ORIGIN,
"Access-Control-Request-Method": "POST",
"Access-Control-Request-Headers": "Content-Type, Authorization",
},
)
assert resp.status_code == 200, (
f"Preflight OPTIONS /api/signal вернул {resp.status_code}, ожидался 200"
)
@pytest.mark.asyncio
async def test_options_preflight_allow_origin_header() -> None:
"""OPTIONS preflight должен вернуть Access-Control-Allow-Origin."""
async with make_app_client() as client:
resp = await client.options(
"/api/signal",
headers={
"Origin": _FRONTEND_ORIGIN,
"Access-Control-Request-Method": "POST",
"Access-Control-Request-Headers": "Content-Type, Authorization",
},
)
acao = resp.headers.get("access-control-allow-origin", "")
assert acao == _FRONTEND_ORIGIN, (
f"Ожидался Access-Control-Allow-Origin: {_FRONTEND_ORIGIN!r}, получен: {acao!r}"
)
@pytest.mark.asyncio
async def test_options_preflight_allow_methods_contains_get() -> None:
"""OPTIONS preflight должен вернуть Access-Control-Allow-Methods, включающий GET."""
async with make_app_client() as client:
resp = await client.options(
"/api/signal",
headers={
"Origin": _FRONTEND_ORIGIN,
"Access-Control-Request-Method": "GET",
"Access-Control-Request-Headers": "Authorization",
},
)
acam = resp.headers.get("access-control-allow-methods", "")
assert "GET" in acam, (
f"Access-Control-Allow-Methods не содержит GET: {acam!r}\n"
"Decision #1268: allow_methods=['POST'] — GET отсутствует"
)
@pytest.mark.asyncio
async def test_options_preflight_allow_headers_contains_authorization() -> None:
"""OPTIONS preflight должен вернуть Access-Control-Allow-Headers, включающий Authorization."""
async with make_app_client() as client:
resp = await client.options(
"/api/signal",
headers={
"Origin": _FRONTEND_ORIGIN,
"Access-Control-Request-Method": "POST",
"Access-Control-Request-Headers": "Authorization",
},
)
acah = resp.headers.get("access-control-allow-headers", "")
assert "authorization" in acah.lower(), (
f"Access-Control-Allow-Headers не содержит Authorization: {acah!r}"
)
@pytest.mark.asyncio
async def test_get_health_cors_header_present() -> None:
"""GET /health с Origin должен вернуть Access-Control-Allow-Origin (simple request)."""
async with make_app_client() as client:
resp = await client.get(
"/health",
headers={"Origin": _FRONTEND_ORIGIN},
)
assert resp.status_code == 200
acao = resp.headers.get("access-control-allow-origin", "")
assert acao == _FRONTEND_ORIGIN, (
f"GET /health: ожидался CORS-заголовок {_FRONTEND_ORIGIN!r}, получен: {acao!r}"
)

194
tests/test_fix_013.py Normal file
View file

@ -0,0 +1,194 @@
"""
Tests for BATON-FIX-013: CORS allow_methods добавить GET для /health эндпоинтов.
Acceptance criteria:
1. CORSMiddleware в main.py содержит "GET" в allow_methods.
2. OPTIONS preflight /health с Origin и Access-Control-Request-Method: GET
возвращает 200/204 и содержит GET в Access-Control-Allow-Methods.
3. OPTIONS preflight /api/health аналогично.
4. GET /health возвращает 200 (regression guard vs. allow_methods=['POST'] only).
"""
from __future__ import annotations
import os
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
_ORIGIN = "http://localhost:3000"
# allow_headers = ["Content-Type", "Authorization"] — X-Custom-Header не разрешён,
# поэтому preflight с X-Custom-Header вернёт 400. Используем Content-Type.
_PREFLIGHT_HEADER = "Content-Type"
# ---------------------------------------------------------------------------
# Criterion 1 — Static: CORSMiddleware.allow_methods must contain "GET"
# ---------------------------------------------------------------------------
def test_cors_middleware_allow_methods_contains_get() -> None:
"""app.user_middleware CORSMiddleware должен содержать 'GET' в allow_methods."""
from fastapi.middleware.cors import CORSMiddleware
from backend.main import app
cors_mw = next(
(m for m in app.user_middleware if m.cls is CORSMiddleware), None
)
assert cors_mw is not None, "CORSMiddleware не найден в app.user_middleware"
allow_methods = cors_mw.kwargs.get("allow_methods", [])
assert "GET" in allow_methods, (
f"CORSMiddleware.allow_methods={allow_methods!r} не содержит 'GET'"
)
def test_cors_middleware_allow_methods_contains_head() -> None:
"""allow_methods должен содержать 'HEAD' для корректной работы preflight."""
from fastapi.middleware.cors import CORSMiddleware
from backend.main import app
cors_mw = next(
(m for m in app.user_middleware if m.cls is CORSMiddleware), None
)
assert cors_mw is not None
allow_methods = cors_mw.kwargs.get("allow_methods", [])
assert "HEAD" in allow_methods, (
f"CORSMiddleware.allow_methods={allow_methods!r} не содержит 'HEAD'"
)
def test_cors_middleware_allow_methods_contains_options() -> None:
"""allow_methods должен содержать 'OPTIONS' для корректной обработки preflight."""
from fastapi.middleware.cors import CORSMiddleware
from backend.main import app
cors_mw = next(
(m for m in app.user_middleware if m.cls is CORSMiddleware), None
)
assert cors_mw is not None
allow_methods = cors_mw.kwargs.get("allow_methods", [])
assert "OPTIONS" in allow_methods, (
f"CORSMiddleware.allow_methods={allow_methods!r} не содержит 'OPTIONS'"
)
# ---------------------------------------------------------------------------
# Criterion 2 — Preflight OPTIONS /health includes GET in Allow-Methods
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_health_preflight_options_returns_success_status() -> None:
"""OPTIONS preflight /health должен вернуть 200 или 204."""
async with make_app_client() as client:
response = await client.options(
"/health",
headers={
"Origin": _ORIGIN,
"Access-Control-Request-Method": "GET",
"Access-Control-Request-Headers": _PREFLIGHT_HEADER,
},
)
assert response.status_code in (200, 204), (
f"OPTIONS /health вернул {response.status_code}, ожидался 200 или 204"
)
@pytest.mark.asyncio
async def test_health_preflight_options_allow_methods_contains_get() -> None:
"""OPTIONS preflight /health: Access-Control-Allow-Methods должен содержать GET."""
async with make_app_client() as client:
response = await client.options(
"/health",
headers={
"Origin": _ORIGIN,
"Access-Control-Request-Method": "GET",
"Access-Control-Request-Headers": _PREFLIGHT_HEADER,
},
)
allow_methods_header = response.headers.get("access-control-allow-methods", "")
assert "GET" in allow_methods_header, (
f"Access-Control-Allow-Methods={allow_methods_header!r} не содержит 'GET'"
)
# ---------------------------------------------------------------------------
# Criterion 3 — Preflight OPTIONS /api/health includes GET in Allow-Methods
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_api_health_preflight_options_returns_success_status() -> None:
"""OPTIONS preflight /api/health должен вернуть 200 или 204."""
async with make_app_client() as client:
response = await client.options(
"/api/health",
headers={
"Origin": _ORIGIN,
"Access-Control-Request-Method": "GET",
"Access-Control-Request-Headers": _PREFLIGHT_HEADER,
},
)
assert response.status_code in (200, 204), (
f"OPTIONS /api/health вернул {response.status_code}, ожидался 200 или 204"
)
@pytest.mark.asyncio
async def test_api_health_preflight_options_allow_methods_contains_get() -> None:
"""OPTIONS preflight /api/health: Access-Control-Allow-Methods должен содержать GET."""
async with make_app_client() as client:
response = await client.options(
"/api/health",
headers={
"Origin": _ORIGIN,
"Access-Control-Request-Method": "GET",
"Access-Control-Request-Headers": _PREFLIGHT_HEADER,
},
)
allow_methods_header = response.headers.get("access-control-allow-methods", "")
assert "GET" in allow_methods_header, (
f"Access-Control-Allow-Methods={allow_methods_header!r} не содержит 'GET'"
)
# ---------------------------------------------------------------------------
# Criterion 4 — GET /health returns 200 (regression guard)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_health_get_returns_200_regression_guard() -> None:
"""GET /health должен вернуть 200 — regression guard против allow_methods=['POST'] only."""
async with make_app_client() as client:
response = await client.get(
"/health",
headers={"Origin": _ORIGIN},
)
assert response.status_code == 200, (
f"GET /health вернул {response.status_code}, ожидался 200"
)
@pytest.mark.asyncio
async def test_api_health_get_returns_200_regression_guard() -> None:
"""GET /api/health должен вернуть 200 — regression guard против allow_methods=['POST'] only."""
async with make_app_client() as client:
response = await client.get(
"/api/health",
headers={"Origin": _ORIGIN},
)
assert response.status_code == 200, (
f"GET /api/health вернул {response.status_code}, ожидался 200"
)