From 370a2157b954ea3fa9f703d2d13401467689b908 Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Sat, 21 Mar 2026 09:25:08 +0200 Subject: [PATCH] =?UTF-8?q?kin:=20BATON-FIX-008=20[TECH=20DEBT]=20=D0=A1?= =?UTF-8?q?=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=BD=D1=8B=D0=B9=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=B4=20(backend/main.py,=20middleware.py)=20=D1=80=D0=B0?= =?UTF-8?q?=D1=81=D1=85=D0=BE=D0=B4=D0=B8=D1=82=D1=81=D1=8F=20=D1=81=20wor?= =?UTF-8?q?ktree=20=E2=80=94=20=D1=83=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=B0=20=D0=BD=D0=B5=D1=82=20rate=5Flimit=5Fsignal=20?= =?UTF-8?q?=D0=B2=20middleware,=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20main.py=20=D0=BF=D1=80=D0=BE=D0=BF=D0=B0?= =?UTF-8?q?=D1=82=D1=87=D0=B5=D0=BD=20=D0=B2=D1=80=D1=83=D1=87=D0=BD=D1=83?= =?UTF-8?q?=D1=8E=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20sed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_baton_008.py | 232 ++++++++++++++++++++++++++++++++++++++++ tests/test_fix_009.py | 229 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 461 insertions(+) create mode 100644 tests/test_fix_009.py diff --git a/tests/test_baton_008.py b/tests/test_baton_008.py index c8f4617..0a6a678 100644 --- a/tests/test_baton_008.py +++ b/tests/test_baton_008.py @@ -347,3 +347,235 @@ async def test_webhook_callback_unknown_reg_id_returns_ok(): assert resp.status_code == 200 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 ':' 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) diff --git a/tests/test_fix_009.py b/tests/test_fix_009.py new file mode 100644 index 0000000..399e4aa --- /dev/null +++ b/tests/test_fix_009.py @@ -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]) + )