kin: BATON-FIX-008 [TECH DEBT] Серверный код (backend/main.py, middleware.py) расходится с worktree — у сервера нет rate_limit_signal в middleware, серверный main.py пропатчен вручную через sed

This commit is contained in:
Gros Frumos 2026-03-21 09:25:08 +02:00
parent 177a0d80dd
commit 370a2157b9
2 changed files with 461 additions and 0 deletions

View file

@ -347,3 +347,235 @@ async def test_webhook_callback_unknown_reg_id_returns_ok():
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json() == {"ok": True} assert resp.json() == {"ok": True}
# ---------------------------------------------------------------------------
# 8. Registration without push_subscription
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_register_without_push_subscription():
"""Registration with push_subscription=null returns 201."""
async with make_app_client() as client:
with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock):
resp = await client.post(
"/api/auth/register",
json={**_VALID_PAYLOAD, "email": "nopush@example.com", "login": "nopushuser"},
)
assert resp.status_code == 201
assert resp.json()["status"] == "pending"
# ---------------------------------------------------------------------------
# 9. reject does NOT trigger Web Push
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_webhook_callback_reject_does_not_send_push():
"""reject callback does NOT call send_push."""
async with make_app_client() as client:
with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock):
reg_resp = await client.post("/api/auth/register", json=_VALID_PAYLOAD)
assert reg_resp.status_code == 201
from backend import config as _cfg
import aiosqlite
async with aiosqlite.connect(_cfg.DB_PATH) as conn:
conn.row_factory = aiosqlite.Row
async with conn.execute("SELECT id FROM registrations LIMIT 1") as cur:
row = await cur.fetchone()
reg_id = row["id"] if row else None
assert reg_id is not None
cb_payload = {
"callback_query": {
"id": "cq_r001",
"data": f"reject:{reg_id}",
"message": {"message_id": 50, "chat": {"id": 5694335584}},
}
}
push_calls: list = []
async def _capture_push(sub_json, title, body):
push_calls.append(sub_json)
with patch("backend.push.send_push", side_effect=_capture_push):
await client.post("/api/webhook/telegram", json=cb_payload, headers=_WEBHOOK_HEADERS)
await asyncio.sleep(0)
assert len(push_calls) == 0, f"Expected 0 push calls on reject, got {len(push_calls)}"
# ---------------------------------------------------------------------------
# 10. approve calls editMessageText with ✅ text
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_webhook_callback_approve_edits_message():
"""approve callback calls editMessageText with '✅ Пользователь ... одобрен'."""
async with make_app_client() as client:
with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock):
reg_resp = await client.post(
"/api/auth/register",
json={**_VALID_PAYLOAD, "email": "edit@example.com", "login": "edituser"},
)
assert reg_resp.status_code == 201
from backend import config as _cfg
import aiosqlite
async with aiosqlite.connect(_cfg.DB_PATH) as conn:
conn.row_factory = aiosqlite.Row
async with conn.execute("SELECT id FROM registrations LIMIT 1") as cur:
row = await cur.fetchone()
reg_id = row["id"] if row else None
assert reg_id is not None
cb_payload = {
"callback_query": {
"id": "cq_e001",
"data": f"approve:{reg_id}",
"message": {"message_id": 51, "chat": {"id": 5694335584}},
}
}
edit_calls: list[str] = []
async def _capture_edit(chat_id, message_id, text):
edit_calls.append(text)
with patch("backend.telegram.edit_message_text", side_effect=_capture_edit):
await client.post("/api/webhook/telegram", json=cb_payload, headers=_WEBHOOK_HEADERS)
assert len(edit_calls) == 1, f"Expected 1 editMessageText call, got {len(edit_calls)}"
assert "" in edit_calls[0], f"Expected ✅ in edit text, got: {edit_calls[0]!r}"
assert "edituser" in edit_calls[0], f"Expected login in edit text, got: {edit_calls[0]!r}"
# ---------------------------------------------------------------------------
# 11. answerCallbackQuery is called after callback processing
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_webhook_callback_answer_sent():
"""answerCallbackQuery is called with the callback_query_id after processing."""
async with make_app_client() as client:
with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock):
reg_resp = await client.post(
"/api/auth/register",
json={**_VALID_PAYLOAD, "email": "answer@example.com", "login": "answeruser"},
)
assert reg_resp.status_code == 201
from backend import config as _cfg
import aiosqlite
async with aiosqlite.connect(_cfg.DB_PATH) as conn:
conn.row_factory = aiosqlite.Row
async with conn.execute("SELECT id FROM registrations LIMIT 1") as cur:
row = await cur.fetchone()
reg_id = row["id"] if row else None
assert reg_id is not None
cb_payload = {
"callback_query": {
"id": "cq_a001",
"data": f"approve:{reg_id}",
"message": {"message_id": 52, "chat": {"id": 5694335584}},
}
}
answer_calls: list[str] = []
async def _capture_answer(callback_query_id):
answer_calls.append(callback_query_id)
with patch("backend.telegram.answer_callback_query", side_effect=_capture_answer):
await client.post("/api/webhook/telegram", json=cb_payload, headers=_WEBHOOK_HEADERS)
assert len(answer_calls) == 1, f"Expected 1 answerCallbackQuery call, got {len(answer_calls)}"
assert answer_calls[0] == "cq_a001"
# ---------------------------------------------------------------------------
# 12. CORS — Authorization header is allowed
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_cors_authorization_header_allowed():
"""CORS preflight request allows Authorization header."""
async with make_app_client() as client:
resp = await client.options(
"/api/auth/register",
headers={
"Origin": "http://localhost:3000",
"Access-Control-Request-Method": "POST",
"Access-Control-Request-Headers": "Authorization",
},
)
assert resp.status_code in (200, 204), f"CORS preflight returned {resp.status_code}"
allow_headers = resp.headers.get("access-control-allow-headers", "")
assert "authorization" in allow_headers.lower(), (
f"Authorization not in Access-Control-Allow-Headers: {allow_headers!r}"
)
# ---------------------------------------------------------------------------
# 13. DB — registrations table exists after init_db
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_registrations_table_created():
"""init_db creates the registrations table with correct schema."""
from tests.conftest import temp_db
from backend import db as _db, config as _cfg
import aiosqlite
with temp_db():
await _db.init_db()
async with aiosqlite.connect(_cfg.DB_PATH) as conn:
async with conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='registrations'"
) as cur:
row = await cur.fetchone()
assert row is not None, "Table 'registrations' not found after init_db()"
# ---------------------------------------------------------------------------
# 14. DB — password_hash uses PBKDF2 '{salt_hex}:{dk_hex}' format
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_password_hash_stored_in_pbkdf2_format():
"""Stored password_hash uses '<salt_hex>:<dk_hex>' PBKDF2 format."""
from backend import config as _cfg
import aiosqlite
async with make_app_client() as client:
with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock):
await client.post(
"/api/auth/register",
json={**_VALID_PAYLOAD, "email": "pbkdf2@example.com", "login": "pbkdf2user"},
)
async with aiosqlite.connect(_cfg.DB_PATH) as conn:
conn.row_factory = aiosqlite.Row
async with conn.execute(
"SELECT password_hash FROM registrations WHERE login = 'pbkdf2user'"
) as cur:
row = await cur.fetchone()
assert row is not None, "Registration not found in DB"
password_hash = row["password_hash"]
assert ":" in password_hash, f"Expected 'salt:hash' format, got {password_hash!r}"
parts = password_hash.split(":")
assert len(parts) == 2, f"Expected exactly one colon separator, got {password_hash!r}"
salt_hex, dk_hex = parts
# salt = os.urandom(16) → 32 hex chars; dk = SHA-256 output (32 bytes) → 64 hex chars
assert len(salt_hex) == 32, f"Expected 32-char salt hex, got {len(salt_hex)}"
assert len(dk_hex) == 64, f"Expected 64-char dk hex (SHA-256), got {len(dk_hex)}"
int(salt_hex, 16) # raises ValueError if not valid hex
int(dk_hex, 16)

229
tests/test_fix_009.py Normal file
View file

@ -0,0 +1,229 @@
"""
Tests for BATON-FIX-009: Live delivery verification automated regression guards.
Acceptance criteria mapped to unit tests:
AC#3 — BOT_TOKEN validates on startup via validate_bot_token() (getMe call)
AC#4 — CHAT_ID is negative (regression guard for decision #1212)
AC#1 — POST /api/signal returns 200 with valid auth
Physical production checks (AC#2 Telegram group message, AC#5 systemd status)
are outside unit test scope and require live production verification.
"""
from __future__ import annotations
import logging
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")
import json
from unittest.mock import AsyncMock, patch
import httpx
import pytest
import respx
from tests.conftest import make_app_client, temp_db
# ---------------------------------------------------------------------------
# AC#3 — validate_bot_token called at startup (decision #1211)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_validate_bot_token_called_once_during_startup():
"""AC#3: validate_bot_token() must be called exactly once during app startup.
Maps to production check: curl getMe must be executed to detect invalid token
before the service starts accepting signals (decision #1211).
"""
from backend.main import app
with temp_db():
with patch("backend.telegram.validate_bot_token", new_callable=AsyncMock) as mock_validate:
mock_validate.return_value = True
with patch("backend.telegram.set_webhook", new_callable=AsyncMock):
async with app.router.lifespan_context(app):
pass
assert mock_validate.call_count == 1, (
f"Expected validate_bot_token to be called exactly once at startup, "
f"got {mock_validate.call_count}"
)
@pytest.mark.asyncio
async def test_invalid_bot_token_logs_critical_error_on_startup(caplog):
"""AC#3: When BOT_TOKEN is invalid (validate_bot_token returns False),
a CRITICAL/ERROR is logged but lifespan continues service must not crash.
Maps to: 'Check BOT_TOKEN valid via getMe — status OK/FAIL' (decision #1211).
"""
from backend.main import app
with temp_db():
with patch("backend.telegram.validate_bot_token", new_callable=AsyncMock) as mock_validate:
mock_validate.return_value = False
with patch("backend.telegram.set_webhook", new_callable=AsyncMock):
with caplog.at_level(logging.ERROR, logger="backend.main"):
async with app.router.lifespan_context(app):
pass # lifespan must complete without raising
critical_msgs = [r.message for r in caplog.records if r.levelno >= logging.ERROR]
assert len(critical_msgs) >= 1, (
"Expected at least one ERROR/CRITICAL log when BOT_TOKEN is invalid. "
"Operator must be alerted on startup if Telegram delivery is broken."
)
assert any("BOT_TOKEN" in m for m in critical_msgs), (
f"Expected log mentioning 'BOT_TOKEN', got: {critical_msgs}"
)
@pytest.mark.asyncio
async def test_invalid_bot_token_lifespan_does_not_raise():
"""AC#3: Invalid BOT_TOKEN must not crash the service — lifespan completes normally."""
from backend.main import app
with temp_db():
with patch("backend.telegram.validate_bot_token", new_callable=AsyncMock) as mock_validate:
mock_validate.return_value = False
with patch("backend.telegram.set_webhook", new_callable=AsyncMock):
# Must not raise — service stays alive even with broken Telegram token
async with app.router.lifespan_context(app):
pass
# ---------------------------------------------------------------------------
# AC#4 — CHAT_ID is negative (decision #1212 regression guard)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_send_message_chat_id_in_request_is_negative():
"""AC#4: The chat_id sent to Telegram API must be negative (group ID).
Root cause of BATON-007: CHAT_ID=5190015988 (positive) was set in .env
instead of -5190015988 (negative). Negative ID = Telegram group/supergroup.
Decision #1212: CHAT_ID=-5190015988 отрицательный.
"""
from backend import config
from backend.telegram import send_message
send_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage"
with respx.mock(assert_all_called=False) as mock:
route = mock.post(send_url).mock(
return_value=httpx.Response(200, json={"ok": True})
)
await send_message("AC#4 regression guard")
assert route.called
body = json.loads(route.calls[0].request.content)
chat_id = body["chat_id"]
assert str(chat_id).startswith("-"), (
f"Regression #1212: chat_id must be negative (group ID), got {chat_id!r}. "
"Positive chat_id is a user ID — messages go to private DM, not the group."
)
# ---------------------------------------------------------------------------
# AC#1 — POST /api/signal returns 200 (decision #1211)
# ---------------------------------------------------------------------------
_UUID_FIX009 = "f0090001-0000-4000-8000-000000000001"
@pytest.mark.asyncio
async def test_signal_endpoint_returns_200_with_valid_auth():
"""AC#1: POST /api/signal with valid Bearer token must return HTTP 200.
Maps to production check: 'SSH на сервер, отправить POST /api/signal,
зафиксировать raw ответ API' (decision #1211).
"""
async with make_app_client() as client:
reg = await client.post(
"/api/register",
json={"uuid": _UUID_FIX009, "name": "Fix009User"},
)
assert reg.status_code == 200, f"Registration failed: {reg.text}"
api_key = reg.json()["api_key"]
resp = await client.post(
"/api/signal",
json={"user_id": _UUID_FIX009, "timestamp": 1742478000000},
headers={"Authorization": f"Bearer {api_key}"},
)
assert resp.status_code == 200, (
f"Expected /api/signal to return 200, got {resp.status_code}: {resp.text}"
)
body = resp.json()
assert body.get("status") == "ok", f"Expected status='ok', got: {body}"
assert "signal_id" in body, f"Expected signal_id in response, got: {body}"
@pytest.mark.asyncio
async def test_signal_endpoint_returns_200_even_when_telegram_returns_400(caplog):
"""AC#1 + decision #1230: POST /api/signal must return 200 even if Telegram returns 400.
Decision #1230: 'Если Telegram возвращает 400 — зафиксировать и сообщить'.
The HTTP 400 from Telegram must be logged as ERROR (captured/reported),
but /api/signal must still return 200 signal was saved to DB.
"""
from backend import config
from backend.main import app
send_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage"
set_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/setWebhook"
get_me_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/getMe"
_UUID_400 = "f0090002-0000-4000-8000-000000000002"
with temp_db():
with respx.mock(assert_all_called=False) as mock_tg:
mock_tg.get(get_me_url).mock(
return_value=httpx.Response(200, json={"ok": True, "result": {"username": "testbot"}})
)
mock_tg.post(set_url).mock(
return_value=httpx.Response(200, json={"ok": True, "result": True})
)
mock_tg.post(send_url).mock(
return_value=httpx.Response(
400,
json={"ok": False, "error_code": 400, "description": "Bad Request: chat not found"},
)
)
async with app.router.lifespan_context(app):
import asyncio
from httpx import AsyncClient, ASGITransport
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://testserver") as client:
reg = await client.post("/api/register", json={"uuid": _UUID_400, "name": "TgErrUser"})
assert reg.status_code == 200
api_key = reg.json()["api_key"]
with caplog.at_level(logging.ERROR, logger="backend.telegram"):
resp = await client.post(
"/api/signal",
json={"user_id": _UUID_400, "timestamp": 1742478000000},
headers={"Authorization": f"Bearer {api_key}"},
)
await asyncio.sleep(0)
assert resp.status_code == 200, (
f"Decision #1230: /api/signal must return 200 even on Telegram 400, "
f"got {resp.status_code}"
)
assert any("400" in r.message for r in caplog.records), (
"Decision #1230: Telegram 400 error must be logged (captured and reported). "
"Got logs: " + str([r.message for r in caplog.records])
)