kin: BATON-SEC-005 UUID-валидация в models.py для uuid и user_id
This commit is contained in:
parent
0a5ee35a4e
commit
8629f3e40b
2 changed files with 488 additions and 0 deletions
259
tests/test_sec_002.py
Normal file
259
tests/test_sec_002.py
Normal file
|
|
@ -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}"
|
||||||
229
tests/test_sec_007.py
Normal file
229
tests/test_sec_007.py
Normal file
|
|
@ -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()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue