From 097b7af949952c97ba24b06a30da49053cf59dda Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Sat, 21 Mar 2026 07:43:25 +0200 Subject: [PATCH] =?UTF-8?q?kin:=20BATON-SEC-005=20UUID-=D0=B2=D0=B0=D0=BB?= =?UTF-8?q?=D0=B8=D0=B4=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B2=20models.py=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20uuid=20=D0=B8=20user=5Fid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_sec_002.py | 259 ++++++++++++++++++++++++++++++++++++++++++ tests/test_sec_007.py | 229 +++++++++++++++++++++++++++++++++++++ 2 files changed, 488 insertions(+) create mode 100644 tests/test_sec_002.py create mode 100644 tests/test_sec_007.py diff --git a/tests/test_sec_002.py b/tests/test_sec_002.py new file mode 100644 index 0000000..f620088 --- /dev/null +++ b/tests/test_sec_002.py @@ -0,0 +1,259 @@ +""" +Tests for BATON-SEC-002: +1. _get_client_ip() extracts real IP from X-Real-IP / X-Forwarded-For headers. +2. POST /api/signal returns 429 when the per-IP rate limit is exceeded. +3. Rate counters for register and signal are independent (separate key namespaces). + +UUID notes: RegisterRequest.uuid and SignalRequest.user_id both require a valid +UUID v4 pattern (^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$). +All constants below satisfy this constraint. +""" +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") + +import pytest +from starlette.requests import Request + +from backend.middleware import _get_client_ip +from tests.conftest import make_app_client + +# ── Valid UUID v4 constants ────────────────────────────────────────────────── +# Pattern: xxxxxxxx-xxxx-4xxx-[89ab]xxx-xxxxxxxxxxxx (all hex chars) + +_UUID_SIG_RL = "a0000001-0000-4000-8000-000000000001" # rate-limit 429 test +_UUID_SIG_OK = "a0000002-0000-4000-8000-000000000002" # first-10-allowed test +_UUID_IND_SIG = "a0000003-0000-4000-8000-000000000003" # independence (exhaust signal) +_UUID_IND_SIG2 = "a0000033-0000-4000-8000-000000000033" # second register after exhaust +_UUID_IND_REG = "a0000004-0000-4000-8000-000000000004" # independence (exhaust register) +_UUID_IP_A = "a0000005-0000-4000-8000-000000000005" # per-IP isolation, user A +_UUID_IP_B = "a0000006-0000-4000-8000-000000000006" # per-IP isolation, user B + + +# ── Helpers ───────────────────────────────────────────────────────────────── + + +def _make_request(headers: dict | None = None, client_host: str = "127.0.0.1") -> Request: + """Build a minimal Starlette Request with given headers and remote address.""" + scope = { + "type": "http", + "method": "POST", + "path": "/", + "headers": [ + (k.lower().encode(), v.encode()) + for k, v in (headers or {}).items() + ], + "client": (client_host, 12345), + } + return Request(scope) + + +# ── Unit: _get_client_ip ──────────────────────────────────────────────────── + + +def test_get_client_ip_returns_x_real_ip_when_present(): + """X-Real-IP header is returned as-is (highest priority).""" + req = _make_request({"X-Real-IP": "203.0.113.10"}, client_host="127.0.0.1") + assert _get_client_ip(req) == "203.0.113.10" + + +def test_get_client_ip_ignores_client_host_when_x_real_ip_set(): + """When X-Real-IP is present, client.host (127.0.0.1) must NOT be returned.""" + req = _make_request({"X-Real-IP": "10.20.30.40"}, client_host="127.0.0.1") + assert _get_client_ip(req) != "127.0.0.1" + + +def test_get_client_ip_uses_x_forwarded_for_when_no_x_real_ip(): + """X-Forwarded-For is used when X-Real-IP is absent.""" + req = _make_request({"X-Forwarded-For": "198.51.100.5"}, client_host="127.0.0.1") + assert _get_client_ip(req) == "198.51.100.5" + + +def test_get_client_ip_x_forwarded_for_returns_first_ip_in_chain(): + """When X-Forwarded-For contains a chain, only the first (original) IP is returned.""" + req = _make_request( + {"X-Forwarded-For": "192.0.2.1, 10.0.0.1, 172.16.0.1"}, + client_host="127.0.0.1", + ) + assert _get_client_ip(req) == "192.0.2.1" + + +def test_get_client_ip_x_real_ip_takes_priority_over_x_forwarded_for(): + """X-Real-IP beats X-Forwarded-For when both headers are present.""" + req = _make_request( + {"X-Real-IP": "1.1.1.1", "X-Forwarded-For": "2.2.2.2"}, + client_host="127.0.0.1", + ) + assert _get_client_ip(req) == "1.1.1.1" + + +def test_get_client_ip_falls_back_to_client_host_when_no_proxy_headers(): + """Without proxy headers, client.host is returned.""" + req = _make_request(client_host="203.0.113.99") + assert _get_client_ip(req) == "203.0.113.99" + + +def test_get_client_ip_returns_unknown_when_no_client_and_no_headers(): + """If no proxy headers and client is None, 'unknown' is returned.""" + scope = { + "type": "http", + "method": "POST", + "path": "/", + "headers": [], + "client": None, + } + req = Request(scope) + assert _get_client_ip(req) == "unknown" + + +# ── Integration: signal rate limit (429) ──────────────────────────────────── + + +@pytest.mark.asyncio +async def test_signal_rate_limit_returns_429_after_10_requests(): + """POST /api/signal returns 429 on the 11th request from the same IP.""" + async with make_app_client() as client: + await client.post("/api/register", json={"uuid": _UUID_SIG_RL, "name": "RL"}) + + payload = {"user_id": _UUID_SIG_RL, "timestamp": 1742478000000} + ip_hdrs = {"X-Real-IP": "5.5.5.5"} + + statuses = [] + for _ in range(11): + r = await client.post("/api/signal", json=payload, headers=ip_hdrs) + statuses.append(r.status_code) + + assert statuses[-1] == 429, f"Expected 429 on 11th request, got {statuses}" + + +@pytest.mark.asyncio +async def test_signal_first_10_requests_are_allowed(): + """First 10 POST /api/signal requests from the same IP must all return 200.""" + async with make_app_client() as client: + await client.post("/api/register", json={"uuid": _UUID_SIG_OK, "name": "OK"}) + + payload = {"user_id": _UUID_SIG_OK, "timestamp": 1742478000000} + ip_hdrs = {"X-Real-IP": "6.6.6.6"} + + statuses = [] + for _ in range(10): + r = await client.post("/api/signal", json=payload, headers=ip_hdrs) + statuses.append(r.status_code) + + assert all(s == 200 for s in statuses), ( + f"Some request(s) before limit returned non-200: {statuses}" + ) + + +# ── Integration: independence of register and signal rate limits ───────────── + + +@pytest.mark.asyncio +async def test_signal_rate_limit_does_not_affect_register_counter(): + """ + Exhausting the signal rate limit (11 requests) must NOT cause /api/register + to return 429 — the counters use different keys ('sig:IP' vs 'IP'). + """ + async with make_app_client() as client: + ip_hdrs = {"X-Real-IP": "7.7.7.7"} + + # Register a user (increments register counter, key='7.7.7.7', count=1) + r_reg = await client.post( + "/api/register", + json={"uuid": _UUID_IND_SIG, "name": "Ind"}, + headers=ip_hdrs, + ) + assert r_reg.status_code == 200 + + # Exhaust signal rate limit for same IP (11 requests, key='sig:7.7.7.7') + payload = {"user_id": _UUID_IND_SIG, "timestamp": 1742478000000} + for _ in range(11): + await client.post("/api/signal", json=payload, headers=ip_hdrs) + + # Register counter is still at 1 — must allow another registration + r_reg2 = await client.post( + "/api/register", + json={"uuid": _UUID_IND_SIG2, "name": "Ind2"}, + headers=ip_hdrs, + ) + + assert r_reg2.status_code == 200, ( + f"Register returned {r_reg2.status_code} — " + "signal exhaustion incorrectly bled into register counter" + ) + + +@pytest.mark.asyncio +async def test_register_rate_limit_does_not_affect_signal_counter(): + """ + Exhausting the register rate limit (6 requests → 6th returns 429) must NOT + prevent subsequent /api/signal requests from the same IP. + """ + async with make_app_client() as client: + ip_hdrs = {"X-Real-IP": "8.8.8.8"} + + # First register succeeds and creates the user we'll signal later + r0 = await client.post( + "/api/register", + json={"uuid": _UUID_IND_REG, "name": "Reg"}, + headers=ip_hdrs, + ) + assert r0.status_code == 200 + + # Send 5 more register requests from the same IP to exhaust the limit + # (register limit = 5/600s, so request #6 → 429) + for _ in range(5): + await client.post( + "/api/register", + json={"uuid": _UUID_IND_REG, "name": "Reg"}, + headers=ip_hdrs, + ) + + # Signal must still succeed — signal counter (key='sig:8.8.8.8') is still 0 + r_sig = await client.post( + "/api/signal", + json={"user_id": _UUID_IND_REG, "timestamp": 1742478000000}, + headers=ip_hdrs, + ) + + assert r_sig.status_code == 200, ( + f"Signal returned {r_sig.status_code} — " + "register exhaustion incorrectly bled into signal counter" + ) + + +# ── Integration: signal rate limit is per-IP ───────────────────────────────── + + +@pytest.mark.asyncio +async def test_signal_rate_limit_is_per_ip_different_ips_are_independent(): + """ + Rate limit counters are per-IP — exhausting for IP A must not block IP B. + """ + async with make_app_client() as client: + await client.post("/api/register", json={"uuid": _UUID_IP_A, "name": "IPA"}) + await client.post("/api/register", json={"uuid": _UUID_IP_B, "name": "IPB"}) + + # Exhaust rate limit for IP A (11 requests → 11th is 429) + for _ in range(11): + await client.post( + "/api/signal", + json={"user_id": _UUID_IP_A, "timestamp": 1742478000000}, + headers={"X-Real-IP": "11.11.11.11"}, + ) + + # IP B should still be allowed (independent counter) + r = await client.post( + "/api/signal", + json={"user_id": _UUID_IP_B, "timestamp": 1742478000000}, + headers={"X-Real-IP": "22.22.22.22"}, + ) + + assert r.status_code == 200, f"IP B was incorrectly blocked: {r.status_code}" diff --git a/tests/test_sec_007.py b/tests/test_sec_007.py new file mode 100644 index 0000000..4719c0f --- /dev/null +++ b/tests/test_sec_007.py @@ -0,0 +1,229 @@ +""" +Regression tests for BATON-SEC-007: + +1. Retry loop in telegram.py is bounded to exactly 3 attempts. +2. Exponential backoff applies correctly: sleep = retry_after * (attempt + 1). +3. POST /api/signal uses asyncio.create_task — HTTP response is not blocked + by Telegram rate-limit pauses. +4. GET /health returns only {"status": "ok"} — no timestamp field. +""" +from __future__ import annotations + +import asyncio +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") + +from unittest.mock import AsyncMock, patch + +import httpx +import pytest +import respx + +from backend import config +from backend.telegram import send_message +from tests.conftest import make_app_client + +SEND_URL = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage" + + +# --------------------------------------------------------------------------- +# Criterion 1 — retry loop is bounded to max 3 attempts +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_retry_loop_stops_after_3_attempts_on_all_429(): + """When all 3 responses are 429, send_message makes exactly 3 HTTP requests and stops.""" + responses = [ + httpx.Response(429, json={"ok": False, "parameters": {"retry_after": 1}}), + httpx.Response(429, json={"ok": False, "parameters": {"retry_after": 1}}), + httpx.Response(429, json={"ok": False, "parameters": {"retry_after": 1}}), + ] + with respx.mock(assert_all_called=False) as mock: + route = mock.post(SEND_URL).mock(side_effect=responses) + with patch("backend.telegram.asyncio.sleep", new_callable=AsyncMock): + await send_message("test max 3 attempts") + + assert route.call_count == 3 + + +@pytest.mark.asyncio +async def test_retry_loop_does_not_make_4th_attempt_on_all_429(): + """send_message must never attempt a 4th request when the first 3 all return 429.""" + call_count = 0 + + async def _count_and_return_429(_request): + nonlocal call_count + call_count += 1 + return httpx.Response(429, json={"ok": False, "parameters": {"retry_after": 1}}) + + with respx.mock(assert_all_called=False) as mock: + mock.post(SEND_URL).mock(side_effect=_count_and_return_429) + with patch("backend.telegram.asyncio.sleep", new_callable=AsyncMock): + await send_message("test no 4th attempt") + + assert call_count == 3 + + +# --------------------------------------------------------------------------- +# Criterion 2 — exponential backoff: sleep = retry_after * (attempt + 1) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_retry_429_first_attempt_sleeps_retry_after_times_1(): + """First 429 (attempt 0): sleep duration must be retry_after * 1.""" + retry_after = 7 + responses = [ + httpx.Response(429, json={"ok": False, "parameters": {"retry_after": retry_after}}), + httpx.Response(200, json={"ok": True}), + ] + with respx.mock(assert_all_called=False) as mock: + mock.post(SEND_URL).mock(side_effect=responses) + with patch("backend.telegram.asyncio.sleep", new_callable=AsyncMock) as mock_sleep: + await send_message("test attempt 0 backoff") + + mock_sleep.assert_called_once_with(retry_after * 1) + + +@pytest.mark.asyncio +async def test_retry_429_exponential_backoff_sleep_sequence(): + """Two consecutive 429 responses produce sleep = retry_after*1 then retry_after*2.""" + retry_after = 10 + responses = [ + httpx.Response(429, json={"ok": False, "parameters": {"retry_after": retry_after}}), + httpx.Response(429, json={"ok": False, "parameters": {"retry_after": retry_after}}), + httpx.Response(200, json={"ok": True}), + ] + with respx.mock(assert_all_called=False) as mock: + mock.post(SEND_URL).mock(side_effect=responses) + with patch("backend.telegram.asyncio.sleep", new_callable=AsyncMock) as mock_sleep: + await send_message("test backoff sequence") + + sleep_args = [c.args[0] for c in mock_sleep.call_args_list] + assert retry_after * 1 in sleep_args, f"Expected sleep({retry_after}) not found in {sleep_args}" + assert retry_after * 2 in sleep_args, f"Expected sleep({retry_after * 2}) not found in {sleep_args}" + + +@pytest.mark.asyncio +async def test_retry_429_third_attempt_sleeps_retry_after_times_3(): + """Third 429 (attempt 2): sleep duration must be retry_after * 3.""" + retry_after = 5 + responses = [ + httpx.Response(429, json={"ok": False, "parameters": {"retry_after": retry_after}}), + httpx.Response(429, json={"ok": False, "parameters": {"retry_after": retry_after}}), + httpx.Response(429, json={"ok": False, "parameters": {"retry_after": retry_after}}), + ] + with respx.mock(assert_all_called=False) as mock: + mock.post(SEND_URL).mock(side_effect=responses) + with patch("backend.telegram.asyncio.sleep", new_callable=AsyncMock) as mock_sleep: + await send_message("test attempt 2 backoff") + + sleep_args = [c.args[0] for c in mock_sleep.call_args_list] + assert retry_after * 3 in sleep_args, f"Expected sleep({retry_after * 3}) not found in {sleep_args}" + + +# --------------------------------------------------------------------------- +# After exhausting all 3 attempts — error is logged +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_send_message_all_attempts_exhausted_logs_error(caplog): + """After 3 failed 429 attempts, an ERROR containing 'all 3 attempts' is logged.""" + responses = [ + httpx.Response(429, json={"ok": False, "parameters": {"retry_after": 1}}), + httpx.Response(429, json={"ok": False, "parameters": {"retry_after": 1}}), + httpx.Response(429, json={"ok": False, "parameters": {"retry_after": 1}}), + ] + with respx.mock(assert_all_called=False) as mock: + mock.post(SEND_URL).mock(side_effect=responses) + with patch("backend.telegram.asyncio.sleep", new_callable=AsyncMock): + with caplog.at_level(logging.ERROR, logger="backend.telegram"): + await send_message("test exhausted log") + + error_messages = [r.message for r in caplog.records if r.levelno == logging.ERROR] + assert any("all 3 attempts" in m.lower() for m in error_messages), ( + f"Expected 'all 3 attempts' in error logs, got: {error_messages}" + ) + + +# --------------------------------------------------------------------------- +# Criterion 3 — POST /api/signal uses asyncio.create_task (non-blocking) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_signal_uses_create_task_for_telegram_send_message(): + """POST /api/signal must wrap telegram.send_message in asyncio.create_task.""" + with patch("backend.main.asyncio.create_task", wraps=asyncio.create_task) as mock_ct: + async with make_app_client() as client: + await client.post("/api/register", json={"uuid": "d4e5f6a7-b8c9-4d0e-a1b2-c3d4e5f6a7b8", "name": "CT"}) + resp = await client.post( + "/api/signal", + json={"user_id": "d4e5f6a7-b8c9-4d0e-a1b2-c3d4e5f6a7b8", "timestamp": 1742478000000}, + ) + + assert resp.status_code == 200 + assert mock_ct.called, "asyncio.create_task was never called — send_message may have been awaited directly" + + +@pytest.mark.asyncio +async def test_signal_response_returns_before_telegram_completes(): + """POST /api/signal returns 200 even when Telegram send_message is delayed.""" + # Simulate a slow Telegram response. If send_message is awaited directly, + # the HTTP response would be delayed until sleep completes. + slow_sleep_called = False + + async def slow_send_message(_text: str) -> None: + nonlocal slow_sleep_called + slow_sleep_called = True + await asyncio.sleep(9999) # would block forever if awaited + + with patch("backend.main.telegram.send_message", side_effect=slow_send_message): + with patch("backend.main.asyncio.create_task", wraps=asyncio.create_task): + async with make_app_client() as client: + await client.post( + "/api/register", + json={"uuid": "e5f6a7b8-c9d0-4e1f-a2b3-c4d5e6f7a8b9", "name": "Slow"}, + ) + resp = await client.post( + "/api/signal", + json={ + "user_id": "e5f6a7b8-c9d0-4e1f-a2b3-c4d5e6f7a8b9", + "timestamp": 1742478000000, + }, + ) + + # Response must be immediate — no blocking on the 9999-second sleep + assert resp.status_code == 200 + + +# --------------------------------------------------------------------------- +# Criterion 4 — GET /health exact response body (regression guard) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_health_response_is_exactly_status_ok(): + """GET /health body must be exactly {"status": "ok"} — no extra fields.""" + async with make_app_client() as client: + response = await client.get("/health") + + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + +@pytest.mark.asyncio +async def test_health_no_timestamp_field(): + """GET /health must not expose a timestamp field (time-based fingerprinting prevention).""" + async with make_app_client() as client: + response = await client.get("/health") + + assert "timestamp" not in response.json()