Compare commits
No commits in common. "dd556e2f05c16951a5d6aa0c90955ec6152e88cb" and "36087c3d9eba40de6342a1e45d98c0ae702cb32b" have entirely different histories.
dd556e2f05
...
36087c3d9e
9 changed files with 24 additions and 415 deletions
|
|
@ -1,11 +0,0 @@
|
|||
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$'
|
||||
|
|
@ -22,7 +22,7 @@ WEBHOOK_ENABLED: bool = os.getenv("WEBHOOK_ENABLED", "true").lower() == "true"
|
|||
FRONTEND_ORIGIN: str = os.getenv("FRONTEND_ORIGIN", "http://localhost:3000")
|
||||
APP_URL: str | None = os.getenv("APP_URL") # Публичный URL приложения для keep-alive self-ping
|
||||
ADMIN_TOKEN: str = _require("ADMIN_TOKEN")
|
||||
ADMIN_CHAT_ID: str = _require("ADMIN_CHAT_ID")
|
||||
ADMIN_CHAT_ID: str = os.getenv("ADMIN_CHAT_ID", "5694335584")
|
||||
VAPID_PRIVATE_KEY: str = os.getenv("VAPID_PRIVATE_KEY", "")
|
||||
VAPID_PUBLIC_KEY: str = os.getenv("VAPID_PUBLIC_KEY", "")
|
||||
VAPID_CLAIMS_EMAIL: str = os.getenv("VAPID_CLAIMS_EMAIL", "")
|
||||
|
|
|
|||
|
|
@ -341,10 +341,9 @@ async def get_registration(reg_id: int) -> Optional[dict]:
|
|||
|
||||
|
||||
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 conn.execute(
|
||||
"UPDATE registrations SET status = ? WHERE id = ? AND status = 'pending'",
|
||||
"UPDATE registrations SET status = ? WHERE id = ?",
|
||||
(status, reg_id),
|
||||
) as cur:
|
||||
changed = cur.rowcount > 0
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ app = FastAPI(lifespan=lifespan)
|
|||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[config.FRONTEND_ORIGIN],
|
||||
allow_methods=["GET", "HEAD", "OPTIONS", "POST"],
|
||||
allow_methods=["POST"],
|
||||
allow_headers=["Content-Type", "Authorization"],
|
||||
)
|
||||
|
||||
|
|
@ -240,11 +240,7 @@ async def _handle_callback_query(cb: dict) -> None:
|
|||
return
|
||||
|
||||
if action == "approve":
|
||||
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
|
||||
await db.update_registration_status(reg_id, "approved")
|
||||
if chat_id and message_id:
|
||||
await telegram.edit_message_text(
|
||||
chat_id, message_id, f"✅ Пользователь {reg['login']} одобрен"
|
||||
|
|
@ -258,10 +254,7 @@ async def _handle_callback_query(cb: dict) -> None:
|
|||
)
|
||||
)
|
||||
elif action == "reject":
|
||||
updated = await db.update_registration_status(reg_id, "rejected")
|
||||
if not updated:
|
||||
await telegram.answer_callback_query(callback_query_id)
|
||||
return
|
||||
await db.update_registration_status(reg_id, "rejected")
|
||||
if chat_id and message_id:
|
||||
await telegram.edit_message_text(
|
||||
chat_id, message_id, f"❌ Пользователь {reg['login']} отклонён"
|
||||
|
|
|
|||
|
|
@ -106,8 +106,7 @@ async def send_registration_notification(
|
|||
resp.text,
|
||||
)
|
||||
except Exception as 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__)
|
||||
logger.error("send_registration_notification error: %s", exc)
|
||||
|
||||
|
||||
async def answer_callback_query(callback_query_id: str) -> None:
|
||||
|
|
@ -119,8 +118,7 @@ async def answer_callback_query(callback_query_id: str) -> None:
|
|||
if resp.status_code != 200:
|
||||
logger.error("answerCallbackQuery failed %s: %s", resp.status_code, resp.text)
|
||||
except Exception as exc:
|
||||
# Do not log exc directly — httpx exceptions embed the full API URL with BOT_TOKEN
|
||||
logger.error("answerCallbackQuery error: %s", type(exc).__name__)
|
||||
logger.error("answerCallbackQuery error: %s", exc)
|
||||
|
||||
|
||||
async def edit_message_text(chat_id: str | int, message_id: int, text: str) -> None:
|
||||
|
|
@ -134,8 +132,7 @@ async def edit_message_text(chat_id: str | int, message_id: int, text: str) -> N
|
|||
if resp.status_code != 200:
|
||||
logger.error("editMessageText failed %s: %s", resp.status_code, resp.text)
|
||||
except Exception as exc:
|
||||
# Do not log exc directly — httpx exceptions embed the full API URL with BOT_TOKEN
|
||||
logger.error("editMessageText error: %s", type(exc).__name__)
|
||||
logger.error("editMessageText error: %s", exc)
|
||||
|
||||
|
||||
async def set_webhook(url: str, secret: str) -> None:
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ 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")
|
||||
|
||||
# ── 2. aiosqlite monkey-patch ────────────────────────────────────────────────
|
||||
import aiosqlite
|
||||
|
|
@ -70,20 +69,14 @@ def temp_db():
|
|||
|
||||
# ── 5. App client factory ────────────────────────────────────────────────────
|
||||
|
||||
def make_app_client(capture_send_requests: list | None = None):
|
||||
def make_app_client():
|
||||
"""
|
||||
Async context manager that:
|
||||
1. Assigns a fresh temp-file DB path
|
||||
2. Mocks Telegram setWebhook, sendMessage, answerCallbackQuery, editMessageText
|
||||
3. Runs the FastAPI lifespan (startup → test → shutdown)
|
||||
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"
|
||||
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"
|
||||
|
|
@ -102,15 +95,6 @@ def make_app_client(capture_send_requests: list | None = None):
|
|||
mock_router.post(tg_set_url).mock(
|
||||
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(
|
||||
return_value=httpx.Response(200, json={"ok": True})
|
||||
)
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ 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")
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
|
|
@ -168,23 +167,20 @@ async def test_auth_register_422_short_password():
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_register_sends_notification_to_admin():
|
||||
"""Registration triggers real HTTP sendMessage to ADMIN_CHAT_ID with correct login/email."""
|
||||
from backend import config as _cfg
|
||||
"""Registration triggers send_registration_notification with correct data."""
|
||||
calls: list[dict] = []
|
||||
|
||||
captured: list[dict] = []
|
||||
async with make_app_client(capture_send_requests=captured) as client:
|
||||
resp = await client.post("/api/auth/register", json=_VALID_PAYLOAD)
|
||||
assert resp.status_code == 201
|
||||
async def _capture(reg_id, login, email, created_at):
|
||||
calls.append({"reg_id": reg_id, "login": login, "email": email})
|
||||
|
||||
async with make_app_client() as client:
|
||||
with patch("backend.telegram.send_registration_notification", side_effect=_capture):
|
||||
await client.post("/api/auth/register", json=_VALID_PAYLOAD)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
admin_chat_id = str(_cfg.ADMIN_CHAT_ID)
|
||||
admin_msgs = [r for r in captured if str(r.get("chat_id")) == admin_chat_id]
|
||||
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}"
|
||||
assert len(calls) == 1, f"Expected 1 notification call, got {len(calls)}"
|
||||
assert calls[0]["login"] == _VALID_PAYLOAD["login"]
|
||||
assert calls[0]["email"] == _VALID_PAYLOAD["email"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -1,155 +0,0 @@
|
|||
"""
|
||||
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}"
|
||||
)
|
||||
|
|
@ -1,194 +0,0 @@
|
|||
"""
|
||||
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"
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue