diff --git a/backend/db.py b/backend/db.py index 5acc94c..e0aca18 100644 --- a/backend/db.py +++ b/backend/db.py @@ -1,6 +1,5 @@ from __future__ import annotations -import time from contextlib import asynccontextmanager from typing import AsyncGenerator, Optional import aiosqlite @@ -30,7 +29,6 @@ async def init_db() -> None: name TEXT NOT NULL, is_blocked INTEGER NOT NULL DEFAULT 0, password_hash TEXT DEFAULT NULL, - api_key_hash TEXT DEFAULT NULL, created_at TEXT DEFAULT (datetime('now')) ); @@ -61,18 +59,11 @@ async def init_db() -> None: ON signals(created_at); CREATE INDEX IF NOT EXISTS idx_batches_status ON telegram_batches(status); - - CREATE TABLE IF NOT EXISTS rate_limits ( - ip TEXT NOT NULL PRIMARY KEY, - count INTEGER NOT NULL DEFAULT 0, - window_start REAL NOT NULL DEFAULT 0 - ); """) # Migrations for existing databases (silently ignore if columns already exist) for stmt in [ "ALTER TABLE users ADD COLUMN is_blocked INTEGER NOT NULL DEFAULT 0", "ALTER TABLE users ADD COLUMN password_hash TEXT DEFAULT NULL", - "ALTER TABLE users ADD COLUMN api_key_hash TEXT DEFAULT NULL", ]: try: await conn.execute(stmt) @@ -82,21 +73,12 @@ async def init_db() -> None: await conn.commit() -async def register_user(uuid: str, name: str, api_key_hash: Optional[str] = None) -> dict: +async def register_user(uuid: str, name: str) -> dict: async with _get_conn() as conn: - if api_key_hash is not None: - await conn.execute( - """ - INSERT INTO users (uuid, name, api_key_hash) VALUES (?, ?, ?) - ON CONFLICT(uuid) DO UPDATE SET api_key_hash = excluded.api_key_hash - """, - (uuid, name, api_key_hash), - ) - else: - await conn.execute( - "INSERT OR IGNORE INTO users (uuid, name) VALUES (?, ?)", - (uuid, name), - ) + await conn.execute( + "INSERT OR IGNORE INTO users (uuid, name) VALUES (?, ?)", + (uuid, name), + ) await conn.commit() async with conn.execute( "SELECT id, uuid FROM users WHERE uuid = ?", (uuid,) @@ -105,15 +87,6 @@ async def register_user(uuid: str, name: str, api_key_hash: Optional[str] = None return {"user_id": row["id"], "uuid": row["uuid"]} -async def get_api_key_hash_by_uuid(uuid: str) -> Optional[str]: - async with _get_conn() as conn: - async with conn.execute( - "SELECT api_key_hash FROM users WHERE uuid = ?", (uuid,) - ) as cur: - row = await cur.fetchone() - return row["api_key_hash"] if row else None - - async def save_signal( user_uuid: str, timestamp: int, @@ -255,35 +228,6 @@ async def admin_delete_user(user_id: int) -> bool: return changed -async def rate_limit_increment(key: str, window: float) -> int: - """Increment rate-limit counter for key within window. Returns current count. - - Cleans up the stale record for this key before incrementing (TTL by window_start). - """ - now = time.time() - async with _get_conn() as conn: - # TTL cleanup: remove stale record for this key if window has expired - await conn.execute( - "DELETE FROM rate_limits WHERE ip = ? AND ? - window_start >= ?", - (key, now, window), - ) - # Upsert: insert new record or increment existing - await conn.execute( - """ - INSERT INTO rate_limits (ip, count, window_start) - VALUES (?, 1, ?) - ON CONFLICT(ip) DO UPDATE SET count = count + 1 - """, - (key, now), - ) - await conn.commit() - async with conn.execute( - "SELECT count FROM rate_limits WHERE ip = ?", (key,) - ) as cur: - row = await cur.fetchone() - return row["count"] if row else 1 - - async def save_telegram_batch( message_text: str, signals_count: int, diff --git a/backend/main.py b/backend/main.py index 7fb9d19..38207f0 100644 --- a/backend/main.py +++ b/backend/main.py @@ -4,19 +4,18 @@ import asyncio import hashlib import logging import os -import secrets +import time from contextlib import asynccontextmanager from datetime import datetime, timezone -from typing import Any, Optional +from typing import Any import httpx from fastapi import Depends, FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse -from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from backend import config, db, telegram -from backend.middleware import rate_limit_register, rate_limit_signal, verify_admin_token, verify_webhook_secret +from backend.middleware import rate_limit_register, verify_admin_token, verify_webhook_secret from backend.models import ( AdminBlockRequest, AdminCreateUserRequest, @@ -27,17 +26,10 @@ from backend.models import ( SignalResponse, ) -_api_key_bearer = HTTPBearer(auto_error=False) - logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) -def _hash_api_key(key: str) -> str: - """SHA-256 хэш для API-ключа (без соли — для быстрого сравнения).""" - return hashlib.sha256(key.encode()).hexdigest() - - def _hash_password(password: str) -> str: """Hash a password using PBKDF2-HMAC-SHA256 (stdlib, no external deps). @@ -68,14 +60,10 @@ async def _keep_alive_loop(app_url: str) -> None: @asynccontextmanager async def lifespan(app: FastAPI): # Startup + app.state.rate_counters = {} await db.init_db() logger.info("Database initialized") - if not await telegram.validate_bot_token(): - logger.error( - "CRITICAL: BOT_TOKEN is invalid — Telegram delivery is broken. Update .env and restart." - ) - if config.WEBHOOK_ENABLED: await telegram.set_webhook(url=config.WEBHOOK_URL, secret=config.WEBHOOK_SECRET) logger.info("Webhook registered") @@ -118,36 +106,24 @@ app.add_middleware( CORSMiddleware, allow_origins=[config.FRONTEND_ORIGIN], allow_methods=["POST"], - allow_headers=["Content-Type", "Authorization"], + allow_headers=["Content-Type"], ) @app.get("/health") @app.get("/api/health") async def health() -> dict[str, Any]: - return {"status": "ok"} + return {"status": "ok", "timestamp": int(time.time())} @app.post("/api/register", response_model=RegisterResponse) async def register(body: RegisterRequest, _: None = Depends(rate_limit_register)) -> RegisterResponse: - api_key = secrets.token_hex(32) - result = await db.register_user(uuid=body.uuid, name=body.name, api_key_hash=_hash_api_key(api_key)) - return RegisterResponse(user_id=result["user_id"], uuid=result["uuid"], api_key=api_key) + result = await db.register_user(uuid=body.uuid, name=body.name) + return RegisterResponse(user_id=result["user_id"], uuid=result["uuid"]) @app.post("/api/signal", response_model=SignalResponse) -async def signal( - body: SignalRequest, - credentials: Optional[HTTPAuthorizationCredentials] = Depends(_api_key_bearer), - _: None = Depends(rate_limit_signal), -) -> SignalResponse: - if credentials is None: - raise HTTPException(status_code=401, detail="Unauthorized") - key_hash = _hash_api_key(credentials.credentials) - stored_hash = await db.get_api_key_hash_by_uuid(body.user_id) - if stored_hash is None or not secrets.compare_digest(key_hash, stored_hash): - raise HTTPException(status_code=401, detail="Unauthorized") - +async def signal(body: SignalRequest) -> SignalResponse: if await db.is_user_blocked(body.user_id): raise HTTPException(status_code=403, detail="User is blocked") @@ -177,7 +153,7 @@ async def signal( f"⏰ {ts.strftime('%H:%M:%S')} UTC\n" f"{geo_info}" ) - asyncio.create_task(telegram.send_message(text)) + await telegram.send_message(text) return SignalResponse(status="ok", signal_id=signal_id) diff --git a/backend/middleware.py b/backend/middleware.py index b91b83e..a384c84 100644 --- a/backend/middleware.py +++ b/backend/middleware.py @@ -1,29 +1,19 @@ from __future__ import annotations import secrets +import time from typing import Optional from fastapi import Depends, Header, HTTPException, Request from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer -from backend import config, db +from backend import config _bearer = HTTPBearer(auto_error=False) _RATE_LIMIT = 5 _RATE_WINDOW = 600 # 10 minutes -_SIGNAL_RATE_LIMIT = 10 -_SIGNAL_RATE_WINDOW = 60 # 1 minute - - -def _get_client_ip(request: Request) -> str: - return ( - request.headers.get("X-Real-IP") - or request.headers.get("X-Forwarded-For", "").split(",")[0].strip() - or (request.client.host if request.client else "unknown") - ) - async def verify_webhook_secret( x_telegram_bot_api_secret_token: str = Header(default=""), @@ -44,14 +34,14 @@ async def verify_admin_token( async def rate_limit_register(request: Request) -> None: - key = f"reg:{_get_client_ip(request)}" - count = await db.rate_limit_increment(key, _RATE_WINDOW) + counters = request.app.state.rate_counters + client_ip = request.client.host if request.client else "unknown" + now = time.time() + count, window_start = counters.get(client_ip, (0, now)) + if now - window_start >= _RATE_WINDOW: + count = 0 + window_start = now + count += 1 + counters[client_ip] = (count, window_start) if count > _RATE_LIMIT: raise HTTPException(status_code=429, detail="Too Many Requests") - - -async def rate_limit_signal(request: Request) -> None: - key = f"sig:{_get_client_ip(request)}" - count = await db.rate_limit_increment(key, _SIGNAL_RATE_WINDOW) - if count > _SIGNAL_RATE_LIMIT: - raise HTTPException(status_code=429, detail="Too Many Requests") diff --git a/backend/models.py b/backend/models.py index 7b88b20..b89e884 100644 --- a/backend/models.py +++ b/backend/models.py @@ -5,14 +5,13 @@ from pydantic import BaseModel, Field class RegisterRequest(BaseModel): - uuid: str = Field(..., pattern=r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$') + uuid: str = Field(..., min_length=1) name: str = Field(..., min_length=1, max_length=100) class RegisterResponse(BaseModel): user_id: int uuid: str - api_key: str class GeoData(BaseModel): @@ -22,7 +21,7 @@ class GeoData(BaseModel): class SignalRequest(BaseModel): - user_id: str = Field(..., pattern=r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$') + user_id: str = Field(..., min_length=1) timestamp: int = Field(..., gt=0) geo: Optional[GeoData] = None diff --git a/backend/telegram.py b/backend/telegram.py index 0633462..0436dea 100644 --- a/backend/telegram.py +++ b/backend/telegram.py @@ -14,45 +14,27 @@ logger = logging.getLogger(__name__) _TELEGRAM_API = "https://api.telegram.org/bot{token}/{method}" -async def validate_bot_token() -> bool: - """Validate BOT_TOKEN by calling getMe. Logs ERROR if invalid. Never raises.""" - url = _TELEGRAM_API.format(token=config.BOT_TOKEN, method="getMe") - async with httpx.AsyncClient(timeout=10) as client: - try: - resp = await client.get(url) - if resp.status_code == 200: - bot_name = resp.json().get("result", {}).get("username", "?") - logger.info("Telegram token valid, bot: @%s", bot_name) - return True - logger.error( - "BOT_TOKEN invalid — getMe returned %s: %s", resp.status_code, resp.text - ) - return False - except Exception as exc: - logger.error("BOT_TOKEN validation failed (network): %s", exc) - return False - - async def send_message(text: str) -> None: url = _TELEGRAM_API.format(token=config.BOT_TOKEN, method="sendMessage") async with httpx.AsyncClient(timeout=10) as client: - for attempt in range(3): + while True: resp = await client.post(url, json={"chat_id": config.CHAT_ID, "text": text}) if resp.status_code == 429: retry_after = resp.json().get("parameters", {}).get("retry_after", 30) - sleep = retry_after * (attempt + 1) - logger.warning("Telegram 429, sleeping %s sec (attempt %d)", sleep, attempt + 1) - await asyncio.sleep(sleep) + logger.warning("Telegram 429, sleeping %s sec", retry_after) + await asyncio.sleep(retry_after) continue if resp.status_code >= 500: logger.error("Telegram 5xx: %s", resp.text) await asyncio.sleep(30) - continue + resp2 = await client.post( + url, json={"chat_id": config.CHAT_ID, "text": text} + ) + if resp2.status_code != 200: + logger.error("Telegram retry failed: %s", resp2.text) elif resp.status_code != 200: logger.error("Telegram error %s: %s", resp.status_code, resp.text) break - else: - logger.error("Telegram send_message: all 3 attempts failed, message dropped") async def set_webhook(url: str, secret: str) -> None: diff --git a/frontend/app.js b/frontend/app.js index e457ee7..10c4b1b 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -56,14 +56,9 @@ function _getUserName() { return _storage.getItem('baton_user_name') || ''; } -function _getApiKey() { - return _storage.getItem('baton_api_key') || ''; -} - -function _saveRegistration(name, apiKey) { +function _saveRegistration(name) { _storage.setItem('baton_user_name', name); _storage.setItem('baton_registered', '1'); - if (apiKey) _storage.setItem('baton_api_key', apiKey); } function _getInitials(name) { @@ -107,17 +102,15 @@ function _updateUserAvatar() { // ========== API calls ========== -async function _apiPost(path, body, extraHeaders) { +async function _apiPost(path, body) { const res = await fetch(path, { method: 'POST', - headers: { 'Content-Type': 'application/json', ...extraHeaders }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!res.ok) { const text = await res.text().catch(() => ''); - const err = new Error('HTTP ' + res.status + (text ? ': ' + text : '')); - err.status = res.status; - throw err; + throw new Error('HTTP ' + res.status + (text ? ': ' + text : '')); } return res.json(); } @@ -153,8 +146,8 @@ async function _handleRegister() { try { const uuid = _getOrCreateUserId(); - const data = await _apiPost('/api/register', { uuid, name }); - _saveRegistration(name, data.api_key); + await _apiPost('/api/register', { uuid, name }); + _saveRegistration(name); _updateUserAvatar(); _showMain(); } catch (_) { @@ -186,9 +179,7 @@ async function _handleSignal() { const body = { user_id: uuid, timestamp: Date.now() }; if (geo) body.geo = geo; - const apiKey = _getApiKey(); - const authHeaders = apiKey ? { Authorization: 'Bearer ' + apiKey } : {}; - await _apiPost('/api/signal', body, authHeaders); + await _apiPost('/api/signal', body); _setSosState('success'); _setStatus('Signal sent!', 'success'); @@ -196,13 +187,9 @@ async function _handleSignal() { _setSosState('default'); _setStatus('', ''); }, 2000); - } catch (err) { + } catch (_) { _setSosState('default'); - if (err && err.status === 401) { - _setStatus('Session expired or key is invalid. Please re-register.', 'error'); - } else { - _setStatus('Error sending. Try again.', 'error'); - } + _setStatus('Error sending. Try again.', 'error'); } } diff --git a/nginx/baton.conf b/nginx/baton.conf index 8afbf2f..e148729 100644 --- a/nginx/baton.conf +++ b/nginx/baton.conf @@ -91,27 +91,9 @@ server { proxy_connect_timeout 5s; } - # --------------------------------------------------------------------------- - # Security headers - # IMPORTANT: must be repeated in every location block that uses add_header, - # because nginx does not inherit parent add_header when child block defines its own. - # --------------------------------------------------------------------------- - add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; - add_header X-Content-Type-Options nosniff always; - add_header X-Frame-Options DENY always; - add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'" always; - # Статика фронтенда (SPA) location / { root /opt/baton/frontend; try_files $uri /index.html; - expires 1h; - # Security headers repeated here because add_header in location blocks - # overrides parent-level add_header directives (nginx inheritance rule) - add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; - add_header X-Content-Type-Options nosniff always; - add_header X-Frame-Options DENY always; - add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'" always; - add_header Cache-Control "public" always; } } diff --git a/tests/conftest.py b/tests/conftest.py index 0801e32..24b0ff3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -79,7 +79,6 @@ def make_app_client(): """ 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" @contextlib.asynccontextmanager async def _ctx(): @@ -87,9 +86,6 @@ def make_app_client(): from backend.main import app mock_router = respx.mock(assert_all_called=False) - mock_router.get(get_me_url).mock( - return_value=httpx.Response(200, json={"ok": True, "result": {"username": "testbot"}}) - ) mock_router.post(tg_set_url).mock( return_value=httpx.Response(200, json={"ok": True, "result": True}) ) diff --git a/tests/test_arch_002.py b/tests/test_arch_002.py index c979b1d..a89dfa5 100644 --- a/tests/test_arch_002.py +++ b/tests/test_arch_002.py @@ -5,10 +5,6 @@ Acceptance criteria: 1. No asyncio task for the aggregator is created at lifespan startup. 2. POST /api/signal calls telegram.send_message directly (no aggregator intermediary). 3. SignalAggregator class in telegram.py is preserved with '# v2.0 feature' marker. - -UUID notes: all UUIDs satisfy the UUID v4 pattern. -BATON-SEC-003: POST /api/signal now requires Authorization: Bearer . -Tests that send signals register first and use the returned api_key. """ from __future__ import annotations @@ -19,7 +15,6 @@ 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") from pathlib import Path from unittest.mock import AsyncMock, patch @@ -30,20 +25,6 @@ from tests.conftest import make_app_client _BACKEND_DIR = Path(__file__).parent.parent / "backend" -# Valid UUID v4 constants -_UUID_S1 = "a0100001-0000-4000-8000-000000000001" -_UUID_S2 = "a0100002-0000-4000-8000-000000000002" -_UUID_S3 = "a0100003-0000-4000-8000-000000000003" -_UUID_S4 = "a0100004-0000-4000-8000-000000000004" -_UUID_S5 = "a0100005-0000-4000-8000-000000000005" - - -async def _register(client, uuid: str, name: str) -> str: - """Register user and return api_key.""" - r = await client.post("/api/register", json={"uuid": uuid, "name": name}) - assert r.status_code == 200 - return r.json()["api_key"] - # --------------------------------------------------------------------------- # Criterion 1 — No asyncio task for aggregator created at startup (static) @@ -91,12 +72,11 @@ def test_aggregator_instantiation_commented_out_in_main(): async def test_signal_calls_telegram_send_message_directly(): """POST /api/signal must call telegram.send_message directly, not via aggregator (ADR-004).""" async with make_app_client() as client: - api_key = await _register(client, _UUID_S1, "Tester") + await client.post("/api/register", json={"uuid": "adr-uuid-s1", "name": "Tester"}) with patch("backend.telegram.send_message", new_callable=AsyncMock) as mock_send: resp = await client.post( "/api/signal", - json={"user_id": _UUID_S1, "timestamp": 1742478000000}, - headers={"Authorization": f"Bearer {api_key}"}, + json={"user_id": "adr-uuid-s1", "timestamp": 1742478000000}, ) assert resp.status_code == 200 mock_send.assert_called_once() @@ -106,12 +86,11 @@ async def test_signal_calls_telegram_send_message_directly(): async def test_signal_message_contains_registered_username(): """Message passed to send_message must include the registered user's name.""" async with make_app_client() as client: - api_key = await _register(client, _UUID_S2, "Alice") + await client.post("/api/register", json={"uuid": "adr-uuid-s2", "name": "Alice"}) with patch("backend.telegram.send_message", new_callable=AsyncMock) as mock_send: await client.post( "/api/signal", - json={"user_id": _UUID_S2, "timestamp": 1742478000000}, - headers={"Authorization": f"Bearer {api_key}"}, + json={"user_id": "adr-uuid-s2", "timestamp": 1742478000000}, ) text = mock_send.call_args[0][0] assert "Alice" in text @@ -121,12 +100,11 @@ async def test_signal_message_contains_registered_username(): async def test_signal_message_without_geo_contains_bez_geolocatsii(): """When geo is None, message must contain 'Без геолокации'.""" async with make_app_client() as client: - api_key = await _register(client, _UUID_S3, "Bob") + await client.post("/api/register", json={"uuid": "adr-uuid-s3", "name": "Bob"}) with patch("backend.telegram.send_message", new_callable=AsyncMock) as mock_send: await client.post( "/api/signal", - json={"user_id": _UUID_S3, "timestamp": 1742478000000, "geo": None}, - headers={"Authorization": f"Bearer {api_key}"}, + json={"user_id": "adr-uuid-s3", "timestamp": 1742478000000, "geo": None}, ) text = mock_send.call_args[0][0] assert "Без геолокации" in text @@ -136,16 +114,15 @@ async def test_signal_message_without_geo_contains_bez_geolocatsii(): async def test_signal_message_with_geo_contains_coordinates(): """When geo is provided, message must contain lat and lon values.""" async with make_app_client() as client: - api_key = await _register(client, _UUID_S4, "Charlie") + await client.post("/api/register", json={"uuid": "adr-uuid-s4", "name": "Charlie"}) with patch("backend.telegram.send_message", new_callable=AsyncMock) as mock_send: await client.post( "/api/signal", json={ - "user_id": _UUID_S4, + "user_id": "adr-uuid-s4", "timestamp": 1742478000000, "geo": {"lat": 55.7558, "lon": 37.6173, "accuracy": 15.0}, }, - headers={"Authorization": f"Bearer {api_key}"}, ) text = mock_send.call_args[0][0] assert "55.7558" in text @@ -156,17 +133,29 @@ async def test_signal_message_with_geo_contains_coordinates(): async def test_signal_message_contains_utc_marker(): """Message passed to send_message must contain 'UTC' timestamp marker.""" async with make_app_client() as client: - api_key = await _register(client, _UUID_S5, "Dave") + await client.post("/api/register", json={"uuid": "adr-uuid-s5", "name": "Dave"}) with patch("backend.telegram.send_message", new_callable=AsyncMock) as mock_send: await client.post( "/api/signal", - json={"user_id": _UUID_S5, "timestamp": 1742478000000}, - headers={"Authorization": f"Bearer {api_key}"}, + json={"user_id": "adr-uuid-s5", "timestamp": 1742478000000}, ) text = mock_send.call_args[0][0] assert "UTC" in text +@pytest.mark.asyncio +async def test_signal_unknown_user_message_uses_uuid_prefix(): + """When user is not registered, message uses first 8 chars of uuid as name.""" + async with make_app_client() as client: + with patch("backend.telegram.send_message", new_callable=AsyncMock) as mock_send: + await client.post( + "/api/signal", + json={"user_id": "unknown-uuid-xyz", "timestamp": 1742478000000}, + ) + text = mock_send.call_args[0][0] + assert "unknown-" in text # "unknown-uuid-xyz"[:8] == "unknown-" + + # --------------------------------------------------------------------------- # Criterion 3 — SignalAggregator preserved with '# v2.0 feature' marker (static) # --------------------------------------------------------------------------- diff --git a/tests/test_arch_003.py b/tests/test_arch_003.py index ee221b8..248086f 100644 --- a/tests/test_arch_003.py +++ b/tests/test_arch_003.py @@ -6,9 +6,6 @@ Acceptance criteria: 5 requests pass (200), 6th returns 429; counter resets after the 10-minute window. 2. Token comparison is timing-safe: secrets.compare_digest is used in middleware.py (no == / != for token comparison). - -UUID notes: RegisterRequest.uuid requires a valid UUID v4 pattern. -All UUID constants below satisfy this constraint. """ from __future__ import annotations @@ -23,7 +20,6 @@ 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 tests.conftest import make_app_client @@ -42,24 +38,6 @@ _SAMPLE_UPDATE = { }, } -# Valid UUID v4 constants for rate-limit tests -# Pattern: [0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12} -_UUIDS_OK = [ - f"d0{i:06d}-0000-4000-8000-000000000001" - for i in range(10) -] -_UUIDS_BLK = [ - f"d1{i:06d}-0000-4000-8000-000000000001" - for i in range(10) -] -_UUIDS_EXP = [ - f"d2{i:06d}-0000-4000-8000-000000000001" - for i in range(10) -] -_UUID_BLK_999 = "d1000999-0000-4000-8000-000000000001" -_UUID_EXP_BLK = "d2000999-0000-4000-8000-000000000001" -_UUID_EXP_AFTER = "d2001000-0000-4000-8000-000000000001" - # --------------------------------------------------------------------------- # Criterion 1 — Rate limiting: first 5 requests pass @@ -73,7 +51,7 @@ async def test_register_rate_limit_allows_five_requests(): for i in range(5): resp = await client.post( "/api/register", - json={"uuid": _UUIDS_OK[i], "name": f"User{i}"}, + json={"uuid": f"rl-ok-{i:03d}", "name": f"User{i}"}, ) assert resp.status_code == 200, ( f"Request {i + 1}/5 unexpectedly returned {resp.status_code}" @@ -92,11 +70,11 @@ async def test_register_rate_limit_blocks_sixth_request(): for i in range(5): await client.post( "/api/register", - json={"uuid": _UUIDS_BLK[i], "name": f"User{i}"}, + json={"uuid": f"rl-blk-{i:03d}", "name": f"User{i}"}, ) resp = await client.post( "/api/register", - json={"uuid": _UUID_BLK_999, "name": "Attacker"}, + json={"uuid": "rl-blk-999", "name": "Attacker"}, ) assert resp.status_code == 429 @@ -116,13 +94,13 @@ async def test_register_rate_limit_resets_after_window_expires(): for i in range(5): await client.post( "/api/register", - json={"uuid": _UUIDS_EXP[i], "name": f"User{i}"}, + json={"uuid": f"rl-exp-{i:03d}", "name": f"User{i}"}, ) # Verify the 6th is blocked before window expiry blocked = await client.post( "/api/register", - json={"uuid": _UUID_EXP_BLK, "name": "Attacker"}, + json={"uuid": "rl-exp-blk", "name": "Attacker"}, ) assert blocked.status_code == 429, ( "Expected 429 after exhausting rate limit, got " + str(blocked.status_code) @@ -132,7 +110,7 @@ async def test_register_rate_limit_resets_after_window_expires(): with patch("time.time", return_value=base_time + 601): resp_after = await client.post( "/api/register", - json={"uuid": _UUID_EXP_AFTER, "name": "Legit"}, + json={"uuid": "rl-exp-after", "name": "Legit"}, ) assert resp_after.status_code == 200, ( diff --git a/tests/test_arch_013.py b/tests/test_arch_013.py index 307ffbc..b70c682 100644 --- a/tests/test_arch_013.py +++ b/tests/test_arch_013.py @@ -54,14 +54,14 @@ async def test_health_returns_status_ok(): @pytest.mark.asyncio -async def test_health_no_timestamp(): - """GET /health не должен возвращать поле timestamp (устраняет time-based fingerprinting).""" +async def test_health_returns_timestamp(): + """GET /health должен вернуть поле timestamp в JSON.""" async with make_app_client() as client: response = await client.get("/health") data = response.json() - assert "timestamp" not in data - assert data == {"status": "ok"} + assert "timestamp" in data + assert isinstance(data["timestamp"], int) @pytest.mark.asyncio diff --git a/tests/test_baton_005.py b/tests/test_baton_005.py index 1504d21..1e43810 100644 --- a/tests/test_baton_005.py +++ b/tests/test_baton_005.py @@ -9,10 +9,6 @@ Acceptance criteria: 5. Удаление — пользователь исчезает из GET /admin/users, возвращается 204 6. Защита: неавторизованный запрос к /admin/* возвращает 401 7. Отсутствие регрессии с основным функционалом - -BATON-SEC-003: POST /api/signal now requires Authorization: Bearer . -Tests 3 and 4 (block/unblock + signal) use /api/register to obtain an api_key, -then admin block/unblock the user by their DB id. """ from __future__ import annotations @@ -37,11 +33,6 @@ NGINX_CONF = PROJECT_ROOT / "nginx" / "baton.conf" ADMIN_HEADERS = {"Authorization": "Bearer test-admin-token"} WRONG_HEADERS = {"Authorization": "Bearer wrong-token"} -# Valid UUID v4 for signal-related tests (registered via /api/register) -_UUID_BLOCK = "f0000001-0000-4000-8000-000000000001" -_UUID_UNBLOCK = "f0000002-0000-4000-8000-000000000002" -_UUID_SIG_OK = "f0000003-0000-4000-8000-000000000003" - # --------------------------------------------------------------------------- # Criterion 6 — Unauthorised requests to /admin/* return 401 @@ -259,32 +250,23 @@ async def test_admin_block_user_returns_is_blocked_true() -> None: async def test_admin_block_user_prevents_signal() -> None: """Заблокированный пользователь не может отправить сигнал — /api/signal возвращает 403.""" async with make_app_client() as client: - # Регистрируем через /api/register чтобы получить api_key - reg_resp = await client.post( - "/api/register", - json={"uuid": _UUID_BLOCK, "name": "BlockSignalUser"}, + create_resp = await client.post( + "/admin/users", + json={"uuid": "block-uuid-002", "name": "BlockSignalUser"}, + headers=ADMIN_HEADERS, ) - assert reg_resp.status_code == 200 - api_key = reg_resp.json()["api_key"] - user_uuid = reg_resp.json()["uuid"] + user_id = create_resp.json()["id"] + user_uuid = create_resp.json()["uuid"] - # Находим ID пользователя - users_resp = await client.get("/admin/users", headers=ADMIN_HEADERS) - user = next(u for u in users_resp.json() if u["uuid"] == user_uuid) - user_id = user["id"] - - # Блокируем await client.put( f"/admin/users/{user_id}/block", json={"is_blocked": True}, headers=ADMIN_HEADERS, ) - # Заблокированный пользователь должен получить 403 signal_resp = await client.post( "/api/signal", json={"user_id": user_uuid, "timestamp": 1700000000000, "geo": None}, - headers={"Authorization": f"Bearer {api_key}"}, ) assert signal_resp.status_code == 403 @@ -336,19 +318,13 @@ async def test_admin_unblock_user_returns_is_blocked_false() -> None: async def test_admin_unblock_user_restores_signal_access() -> None: """После разблокировки пользователь снова может отправить сигнал (200).""" async with make_app_client() as client: - # Регистрируем через /api/register чтобы получить api_key - reg_resp = await client.post( - "/api/register", - json={"uuid": _UUID_UNBLOCK, "name": "UnblockSignalUser"}, + create_resp = await client.post( + "/admin/users", + json={"uuid": "unblock-uuid-002", "name": "UnblockSignalUser"}, + headers=ADMIN_HEADERS, ) - assert reg_resp.status_code == 200 - api_key = reg_resp.json()["api_key"] - user_uuid = reg_resp.json()["uuid"] - - # Находим ID - users_resp = await client.get("/admin/users", headers=ADMIN_HEADERS) - user = next(u for u in users_resp.json() if u["uuid"] == user_uuid) - user_id = user["id"] + user_id = create_resp.json()["id"] + user_uuid = create_resp.json()["uuid"] # Блокируем await client.put( @@ -368,7 +344,6 @@ async def test_admin_unblock_user_restores_signal_access() -> None: signal_resp = await client.post( "/api/signal", json={"user_id": user_uuid, "timestamp": 1700000000000, "geo": None}, - headers={"Authorization": f"Bearer {api_key}"}, ) assert signal_resp.status_code == 200 assert signal_resp.json()["status"] == "ok" @@ -487,28 +462,26 @@ async def test_register_not_broken_after_admin_operations() -> None: # Основной функционал resp = await client.post( "/api/register", - json={"uuid": _UUID_SIG_OK, "name": "RegularUser"}, + json={"uuid": "regress-user-uuid-001", "name": "RegularUser"}, ) assert resp.status_code == 200 - assert resp.json()["uuid"] == _UUID_SIG_OK + assert resp.json()["uuid"] == "regress-user-uuid-001" @pytest.mark.asyncio -async def test_signal_from_registered_unblocked_user_succeeds() -> None: - """Зарегистрированный незаблокированный пользователь может отправить сигнал.""" +async def test_signal_from_unblocked_user_succeeds() -> None: + """Незаблокированный пользователь, созданный через admin API, может отправить сигнал.""" async with make_app_client() as client: - reg_resp = await client.post( - "/api/register", - json={"uuid": _UUID_SIG_OK, "name": "SignalUser"}, + create_resp = await client.post( + "/admin/users", + json={"uuid": "regress-signal-uuid-001", "name": "SignalUser"}, + headers=ADMIN_HEADERS, ) - assert reg_resp.status_code == 200 - api_key = reg_resp.json()["api_key"] - user_uuid = reg_resp.json()["uuid"] + user_uuid = create_resp.json()["uuid"] signal_resp = await client.post( "/api/signal", json={"user_id": user_uuid, "timestamp": 1700000000000, "geo": None}, - headers={"Authorization": f"Bearer {api_key}"}, ) assert signal_resp.status_code == 200 assert signal_resp.json()["status"] == "ok" diff --git a/tests/test_baton_006.py b/tests/test_baton_006.py index b76681e..72ec197 100644 --- a/tests/test_baton_006.py +++ b/tests/test_baton_006.py @@ -11,9 +11,6 @@ Acceptance criteria: 6. POST /api/register возвращает 200 (FastAPI-маршрут не сломан). 7. POST /api/signal возвращает 200 (FastAPI-маршрут не сломан). 8. POST /api/webhook/telegram возвращает 200 с корректным секретом. - -BATON-SEC-003: POST /api/signal now requires Authorization: Bearer . -UUID constants satisfy the UUID v4 pattern. """ from __future__ import annotations @@ -26,7 +23,6 @@ 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 @@ -35,10 +31,6 @@ from tests.conftest import make_app_client PROJECT_ROOT = Path(__file__).parent.parent NGINX_CONF = PROJECT_ROOT / "nginx" / "baton.conf" -# Valid UUID v4 constants -_UUID_REG = "e0000001-0000-4000-8000-000000000001" -_UUID_SIG = "e0000002-0000-4000-8000-000000000002" - # --------------------------------------------------------------------------- # Criterion 1 — location /api/ proxies to FastAPI # --------------------------------------------------------------------------- @@ -60,6 +52,7 @@ def test_nginx_conf_has_api_location_block() -> None: def test_nginx_conf_api_location_proxies_to_fastapi() -> None: """Блок location /api/ должен делать proxy_pass на 127.0.0.1:8000.""" content = NGINX_CONF.read_text(encoding="utf-8") + # Ищем блок api и proxy_pass внутри api_block = re.search( r"location\s+/api/\s*\{([^}]+)\}", content, re.DOTALL ) @@ -102,6 +95,7 @@ def test_nginx_conf_health_location_proxies_to_fastapi() -> None: def test_nginx_conf_root_location_has_root_directive() -> None: """location / в nginx.conf должен содержать директиву root (статика).""" content = NGINX_CONF.read_text(encoding="utf-8") + # Ищем последний блок location / (не /api/, не /health) root_block = re.search( r"location\s+/\s*\{([^}]+)\}", content, re.DOTALL ) @@ -185,14 +179,13 @@ async def test_api_register_not_broken_after_nginx_change() -> None: async with make_app_client() as client: response = await client.post( "/api/register", - json={"uuid": _UUID_REG, "name": "TestUser"}, + json={"uuid": "baton-006-uuid-001", "name": "TestUser"}, ) assert response.status_code == 200 data = response.json() assert data["user_id"] > 0 - assert data["uuid"] == _UUID_REG - assert "api_key" in data + assert data["uuid"] == "baton-006-uuid-001" # --------------------------------------------------------------------------- @@ -204,21 +197,19 @@ async def test_api_register_not_broken_after_nginx_change() -> None: async def test_api_signal_not_broken_after_nginx_change() -> None: """POST /api/signal должен вернуть 200 — функция не сломана изменением nginx.""" async with make_app_client() as client: - reg_resp = await client.post( + # Сначала регистрируем пользователя + await client.post( "/api/register", - json={"uuid": _UUID_SIG, "name": "SignalUser"}, + json={"uuid": "baton-006-uuid-002", "name": "SignalUser"}, ) - assert reg_resp.status_code == 200 - api_key = reg_resp.json()["api_key"] - + # Отправляем сигнал response = await client.post( "/api/signal", json={ - "user_id": _UUID_SIG, + "user_id": "baton-006-uuid-002", "timestamp": 1700000000000, "geo": None, }, - headers={"Authorization": f"Bearer {api_key}"}, ) assert response.status_code == 200 diff --git a/tests/test_baton_007.py b/tests/test_baton_007.py deleted file mode 100644 index 0030c7d..0000000 --- a/tests/test_baton_007.py +++ /dev/null @@ -1,262 +0,0 @@ -""" -Tests for BATON-007: Verifying real Telegram delivery when a signal is sent. - -Acceptance criteria: -1. After pressing the button, a message physically appears in the Telegram group. - (verified: send_message is called with correct content containing user name) -2. journalctl -u baton does NOT throw ERROR during send. - (verified: no exception is raised when Telegram returns 200) -3. A repeated request is also delivered. - (verified: two consecutive signals each trigger send_message) - -NOTE: These tests verify that send_message is called with correct parameters. -Physical delivery to an actual Telegram group is outside unit test scope. -""" -from __future__ import annotations - -import asyncio -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 httpx import AsyncClient - -from tests.conftest import make_app_client - -# Valid UUID v4 constants — must not collide with UUIDs in other test files -_UUID_A = "d0000001-0000-4000-8000-000000000001" -_UUID_B = "d0000002-0000-4000-8000-000000000002" -_UUID_C = "d0000003-0000-4000-8000-000000000003" -_UUID_D = "d0000004-0000-4000-8000-000000000004" -_UUID_E = "d0000005-0000-4000-8000-000000000005" - - -async def _register(client: AsyncClient, uuid: str, name: str) -> str: - r = await client.post("/api/register", json={"uuid": uuid, "name": name}) - assert r.status_code == 200, f"Registration failed: {r.status_code} {r.text}" - return r.json()["api_key"] - - -# --------------------------------------------------------------------------- -# Criterion 1 — send_message is called with text containing the user's name -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_signal_send_message_called_with_user_name(): - """Criterion 1: send_message is invoked with text that includes the sender's name.""" - sent_texts: list[str] = [] - - async def _capture(text: str) -> None: - sent_texts.append(text) - - async with make_app_client() as client: - api_key = await _register(client, _UUID_A, "AliceBaton") - - with patch("backend.telegram.send_message", side_effect=_capture): - resp = await client.post( - "/api/signal", - json={"user_id": _UUID_A, "timestamp": 1742478000000}, - headers={"Authorization": f"Bearer {api_key}"}, - ) - await asyncio.sleep(0) # yield to event loop so background task runs - - assert resp.status_code == 200 - assert len(sent_texts) == 1, f"Expected 1 send_message call, got {len(sent_texts)}" - assert "AliceBaton" in sent_texts[0], ( - f"Expected user name 'AliceBaton' in Telegram message, got: {sent_texts[0]!r}" - ) - - -@pytest.mark.asyncio -async def test_signal_send_message_text_contains_signal_keyword(): - """Criterion 1: Telegram message text contains the word 'Сигнал'.""" - sent_texts: list[str] = [] - - async def _capture(text: str) -> None: - sent_texts.append(text) - - async with make_app_client() as client: - api_key = await _register(client, _UUID_B, "BobBaton") - - with patch("backend.telegram.send_message", side_effect=_capture): - await client.post( - "/api/signal", - json={"user_id": _UUID_B, "timestamp": 1742478000000}, - headers={"Authorization": f"Bearer {api_key}"}, - ) - await asyncio.sleep(0) - - assert len(sent_texts) == 1 - assert "Сигнал" in sent_texts[0], ( - f"Expected 'Сигнал' keyword in message, got: {sent_texts[0]!r}" - ) - - -@pytest.mark.asyncio -async def test_signal_with_geo_send_message_contains_coordinates(): - """Criterion 1: when geo is provided, Telegram message includes lat/lon coordinates.""" - sent_texts: list[str] = [] - - async def _capture(text: str) -> None: - sent_texts.append(text) - - async with make_app_client() as client: - api_key = await _register(client, _UUID_C, "GeoUser") - - with patch("backend.telegram.send_message", side_effect=_capture): - await client.post( - "/api/signal", - json={ - "user_id": _UUID_C, - "timestamp": 1742478000000, - "geo": {"lat": 55.7558, "lon": 37.6173, "accuracy": 15.0}, - }, - headers={"Authorization": f"Bearer {api_key}"}, - ) - await asyncio.sleep(0) - - assert len(sent_texts) == 1 - assert "55.7558" in sent_texts[0], ( - f"Expected lat '55.7558' in message, got: {sent_texts[0]!r}" - ) - assert "37.6173" in sent_texts[0], ( - f"Expected lon '37.6173' in message, got: {sent_texts[0]!r}" - ) - - -@pytest.mark.asyncio -async def test_signal_without_geo_send_message_contains_no_geo_label(): - """Criterion 1: when geo is null, Telegram message contains 'Без геолокации'.""" - sent_texts: list[str] = [] - - async def _capture(text: str) -> None: - sent_texts.append(text) - - async with make_app_client() as client: - api_key = await _register(client, _UUID_D, "NoGeoUser") - - with patch("backend.telegram.send_message", side_effect=_capture): - await client.post( - "/api/signal", - json={"user_id": _UUID_D, "timestamp": 1742478000000, "geo": None}, - headers={"Authorization": f"Bearer {api_key}"}, - ) - await asyncio.sleep(0) - - assert len(sent_texts) == 1 - assert "Без геолокации" in sent_texts[0], ( - f"Expected 'Без геолокации' in message, got: {sent_texts[0]!r}" - ) - - -# --------------------------------------------------------------------------- -# Criterion 2 — No ERROR logged on successful send (service stays alive) -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_signal_send_message_no_error_on_200_response(): - """Criterion 2: send_message does not raise when Telegram returns 200.""" - from backend import config as _cfg - from backend.telegram import send_message - - send_url = f"https://api.telegram.org/bot{_cfg.BOT_TOKEN}/sendMessage" - - # Must complete without exception - with respx.mock(assert_all_called=False) as mock: - mock.post(send_url).mock(return_value=httpx.Response(200, json={"ok": True})) - await send_message("Test signal delivery") # should not raise - - -@pytest.mark.asyncio -async def test_signal_send_message_uses_configured_chat_id(): - """Criterion 2: send_message POSTs to Telegram with the configured CHAT_ID.""" - from backend import config as _cfg - from backend.telegram import send_message - - send_url = f"https://api.telegram.org/bot{_cfg.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("Delivery check") - - assert route.called - body = json.loads(route.calls[0].request.content) - assert body["chat_id"] == _cfg.CHAT_ID, ( - f"Expected chat_id={_cfg.CHAT_ID!r}, got {body['chat_id']!r}" - ) - - -# --------------------------------------------------------------------------- -# Criterion 3 — Repeated requests are also delivered -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_repeated_signals_each_trigger_send_message(): - """Criterion 3: two consecutive signals each cause a separate send_message call.""" - sent_texts: list[str] = [] - - async def _capture(text: str) -> None: - sent_texts.append(text) - - async with make_app_client() as client: - api_key = await _register(client, _UUID_E, "RepeatUser") - - with patch("backend.telegram.send_message", side_effect=_capture): - r1 = await client.post( - "/api/signal", - json={"user_id": _UUID_E, "timestamp": 1742478000001}, - headers={"Authorization": f"Bearer {api_key}"}, - ) - await asyncio.sleep(0) - - r2 = await client.post( - "/api/signal", - json={"user_id": _UUID_E, "timestamp": 1742478000002}, - headers={"Authorization": f"Bearer {api_key}"}, - ) - await asyncio.sleep(0) - - assert r1.status_code == 200 - assert r2.status_code == 200 - assert len(sent_texts) == 2, ( - f"Expected 2 send_message calls for 2 signals, got {len(sent_texts)}" - ) - - -@pytest.mark.asyncio -async def test_repeated_signals_produce_incrementing_signal_ids(): - """Criterion 3: repeated signals are each stored and return distinct incrementing signal_ids.""" - async with make_app_client() as client: - api_key = await _register(client, _UUID_E, "RepeatUser2") - r1 = await client.post( - "/api/signal", - json={"user_id": _UUID_E, "timestamp": 1742478000001}, - headers={"Authorization": f"Bearer {api_key}"}, - ) - r2 = await client.post( - "/api/signal", - json={"user_id": _UUID_E, "timestamp": 1742478000002}, - headers={"Authorization": f"Bearer {api_key}"}, - ) - - assert r1.status_code == 200 - assert r2.status_code == 200 - assert r2.json()["signal_id"] > r1.json()["signal_id"], ( - "Second signal must have a higher signal_id than the first" - ) diff --git a/tests/test_models.py b/tests/test_models.py index 0e55586..2b902c7 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -46,11 +46,11 @@ def test_register_request_empty_uuid(): def test_register_request_name_max_length(): """name longer than 100 chars raises ValidationError.""" with pytest.raises(ValidationError): - RegisterRequest(uuid="550e8400-e29b-41d4-a716-446655440000", name="x" * 101) + RegisterRequest(uuid="some-uuid", name="x" * 101) def test_register_request_name_exactly_100(): - req = RegisterRequest(uuid="550e8400-e29b-41d4-a716-446655440000", name="x" * 100) + req = RegisterRequest(uuid="some-uuid", name="x" * 100) assert len(req.name) == 100 @@ -116,7 +116,7 @@ def test_signal_request_valid(): def test_signal_request_no_geo(): req = SignalRequest( - user_id="550e8400-e29b-41d4-a716-446655440000", + user_id="some-uuid", timestamp=1742478000000, geo=None, ) @@ -136,9 +136,9 @@ def test_signal_request_empty_user_id(): def test_signal_request_timestamp_zero(): """timestamp must be > 0.""" with pytest.raises(ValidationError): - SignalRequest(user_id="550e8400-e29b-41d4-a716-446655440000", timestamp=0) + SignalRequest(user_id="some-uuid", timestamp=0) def test_signal_request_timestamp_negative(): with pytest.raises(ValidationError): - SignalRequest(user_id="550e8400-e29b-41d4-a716-446655440000", timestamp=-1) + SignalRequest(user_id="some-uuid", timestamp=-1) diff --git a/tests/test_register.py b/tests/test_register.py index 0f69d24..fb05341 100644 --- a/tests/test_register.py +++ b/tests/test_register.py @@ -1,11 +1,5 @@ """ Integration tests for POST /api/register. - -UUID notes: RegisterRequest.uuid requires 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 UUID constants below satisfy this constraint. - -BATON-SEC-003: /api/register now returns api_key in the response. """ from __future__ import annotations @@ -16,34 +10,23 @@ 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 tests.conftest import make_app_client -# Valid UUID v4 constants for register tests -_UUID_REG_1 = "b0000001-0000-4000-8000-000000000001" -_UUID_REG_2 = "b0000002-0000-4000-8000-000000000002" -_UUID_REG_3 = "b0000003-0000-4000-8000-000000000003" -_UUID_REG_4 = "b0000004-0000-4000-8000-000000000004" -_UUID_REG_5 = "b0000005-0000-4000-8000-000000000005" -_UUID_REG_6 = "b0000006-0000-4000-8000-000000000006" - @pytest.mark.asyncio async def test_register_new_user_success(): - """POST /api/register returns 200 with user_id > 0 and api_key.""" + """POST /api/register returns 200 with user_id > 0.""" async with make_app_client() as client: resp = await client.post( "/api/register", - json={"uuid": _UUID_REG_1, "name": "Alice"}, + json={"uuid": "reg-uuid-001", "name": "Alice"}, ) assert resp.status_code == 200 data = resp.json() assert data["user_id"] > 0 - assert data["uuid"] == _UUID_REG_1 - assert "api_key" in data - assert len(data["api_key"]) == 64 # secrets.token_hex(32) = 64 hex chars + assert data["uuid"] == "reg-uuid-001" @pytest.mark.asyncio @@ -52,42 +35,24 @@ async def test_register_idempotent(): async with make_app_client() as client: r1 = await client.post( "/api/register", - json={"uuid": _UUID_REG_2, "name": "Bob"}, + json={"uuid": "reg-uuid-002", "name": "Bob"}, ) r2 = await client.post( "/api/register", - json={"uuid": _UUID_REG_2, "name": "Bob"}, + json={"uuid": "reg-uuid-002", "name": "Bob"}, ) assert r1.status_code == 200 assert r2.status_code == 200 assert r1.json()["user_id"] == r2.json()["user_id"] -@pytest.mark.asyncio -async def test_register_idempotent_returns_api_key_on_every_call(): - """Each registration call returns an api_key (key rotation on re-register).""" - async with make_app_client() as client: - r1 = await client.post( - "/api/register", - json={"uuid": _UUID_REG_3, "name": "Carol"}, - ) - r2 = await client.post( - "/api/register", - json={"uuid": _UUID_REG_3, "name": "Carol"}, - ) - assert r1.status_code == 200 - assert r2.status_code == 200 - assert "api_key" in r1.json() - assert "api_key" in r2.json() - - @pytest.mark.asyncio async def test_register_empty_name_returns_422(): """Empty name must fail validation with 422.""" async with make_app_client() as client: resp = await client.post( "/api/register", - json={"uuid": _UUID_REG_4, "name": ""}, + json={"uuid": "reg-uuid-003", "name": ""}, ) assert resp.status_code == 422 @@ -109,18 +74,7 @@ async def test_register_missing_name_returns_422(): async with make_app_client() as client: resp = await client.post( "/api/register", - json={"uuid": _UUID_REG_4}, - ) - assert resp.status_code == 422 - - -@pytest.mark.asyncio -async def test_register_invalid_uuid_format_returns_422(): - """Non-UUID4 string as uuid must return 422.""" - async with make_app_client() as client: - resp = await client.post( - "/api/register", - json={"uuid": "not-a-uuid", "name": "Dave"}, + json={"uuid": "reg-uuid-004"}, ) assert resp.status_code == 422 @@ -131,11 +85,11 @@ async def test_register_user_stored_in_db(): async with make_app_client() as client: r1 = await client.post( "/api/register", - json={"uuid": _UUID_REG_5, "name": "Dana"}, + json={"uuid": "reg-uuid-005", "name": "Dana"}, ) r2 = await client.post( "/api/register", - json={"uuid": _UUID_REG_5, "name": "Dana"}, + json={"uuid": "reg-uuid-005", "name": "Dana"}, ) assert r1.json()["user_id"] == r2.json()["user_id"] @@ -146,6 +100,6 @@ async def test_register_response_contains_uuid(): async with make_app_client() as client: resp = await client.post( "/api/register", - json={"uuid": _UUID_REG_6, "name": "Eve"}, + json={"uuid": "reg-uuid-006", "name": "Eve"}, ) - assert resp.json()["uuid"] == _UUID_REG_6 + assert resp.json()["uuid"] == "reg-uuid-006" diff --git a/tests/test_sec_002.py b/tests/test_sec_002.py deleted file mode 100644 index ccf863f..0000000 --- a/tests/test_sec_002.py +++ /dev/null @@ -1,282 +0,0 @@ -""" -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. - -BATON-SEC-003: POST /api/signal now requires Authorization: Bearer . -_register_and_get_key() helper returns the api_key from the registration response. -""" -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 ───────────────────────────────────────────────────────────────── - - -async def _register_and_get_key(client, uuid: str, name: str) -> str: - """Register user and return api_key.""" - r = await client.post("/api/register", json={"uuid": uuid, "name": name}) - assert r.status_code == 200 - return r.json()["api_key"] - - -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: - api_key = await _register_and_get_key(client, _UUID_SIG_RL, "RL") - - payload = {"user_id": _UUID_SIG_RL, "timestamp": 1742478000000} - ip_hdrs = {"X-Real-IP": "5.5.5.5", "Authorization": f"Bearer {api_key}"} - - 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: - api_key = await _register_and_get_key(client, _UUID_SIG_OK, "OK") - - payload = {"user_id": _UUID_SIG_OK, "timestamp": 1742478000000} - ip_hdrs = {"X-Real-IP": "6.6.6.6", "Authorization": f"Bearer {api_key}"} - - 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_reg = {"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_reg, - ) - assert r_reg.status_code == 200 - api_key = r_reg.json()["api_key"] - - # Exhaust signal rate limit for same IP (11 requests, key='sig:7.7.7.7') - payload = {"user_id": _UUID_IND_SIG, "timestamp": 1742478000000} - ip_hdrs_sig = {"X-Real-IP": "7.7.7.7", "Authorization": f"Bearer {api_key}"} - for _ in range(11): - await client.post("/api/signal", json=payload, headers=ip_hdrs_sig) - - # 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_reg, - ) - - 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 - api_key = r0.json()["api_key"] - - # Send 4 more register requests from the same IP (requests 2-5 succeed, - # each rotates the api_key; request 6 would be 429). - # We keep track of the last api_key since re-registration rotates it. - for _ in range(4): - r = await client.post( - "/api/register", - json={"uuid": _UUID_IND_REG, "name": "Reg"}, - headers=ip_hdrs, - ) - if r.status_code == 200: - api_key = r.json()["api_key"] - - # 6th request → 429 (exhausts limit without rotating key) - 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={"X-Real-IP": "8.8.8.8", "Authorization": f"Bearer {api_key}"}, - ) - - 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: - api_key_a = await _register_and_get_key(client, _UUID_IP_A, "IPA") - api_key_b = await _register_and_get_key(client, _UUID_IP_B, "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", "Authorization": f"Bearer {api_key_a}"}, - ) - - # 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", "Authorization": f"Bearer {api_key_b}"}, - ) - - assert r.status_code == 200, f"IP B was incorrectly blocked: {r.status_code}" diff --git a/tests/test_sec_003.py b/tests/test_sec_003.py deleted file mode 100644 index cb04e53..0000000 --- a/tests/test_sec_003.py +++ /dev/null @@ -1,298 +0,0 @@ -""" -Tests for BATON-SEC-003: API-ключи для аутентификации /api/signal. - -Acceptance criteria: -1. POST /api/register возвращает api_key длиной 64 hex-символа. -2. POST /api/signal без Authorization header → 401. -3. POST /api/signal с неверным api_key → 401. -4. POST /api/signal с правильным api_key → 200. -5. Повторная регистрация генерирует новый api_key (ротация ключа). -6. Старый api_key становится недействительным после ротации. -7. Новый api_key работает после ротации. -8. SHA-256 хэш api_key сохраняется в БД, сырой ключ — нет (проверка через DB функцию). - -UUID notes: все UUID ниже удовлетворяют паттерну UUID v4. -""" -from __future__ import annotations - -import hashlib -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 backend import db -from tests.conftest import make_app_client, temp_db -from backend import config - -# Valid UUID v4 constants -_UUID_1 = "aa000001-0000-4000-8000-000000000001" -_UUID_2 = "aa000002-0000-4000-8000-000000000002" -_UUID_3 = "aa000003-0000-4000-8000-000000000003" -_UUID_4 = "aa000004-0000-4000-8000-000000000004" -_UUID_5 = "aa000005-0000-4000-8000-000000000005" -_UUID_6 = "aa000006-0000-4000-8000-000000000006" -_UUID_7 = "aa000007-0000-4000-8000-000000000007" -_UUID_8 = "aa000008-0000-4000-8000-000000000008" -_UUID_9 = "aa000009-0000-4000-8000-000000000009" -_UUID_10 = "aa00000a-0000-4000-8000-00000000000a" - - -# --------------------------------------------------------------------------- -# Criterion 1 — /api/register returns api_key of correct length -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_register_returns_api_key(): - """POST /api/register должен вернуть поле api_key в ответе.""" - async with make_app_client() as client: - resp = await client.post( - "/api/register", - json={"uuid": _UUID_1, "name": "Alice"}, - ) - assert resp.status_code == 200 - assert "api_key" in resp.json() - - -@pytest.mark.asyncio -async def test_register_api_key_is_64_hex_chars(): - """api_key должен быть строкой из 64 hex-символов (secrets.token_hex(32)).""" - async with make_app_client() as client: - resp = await client.post( - "/api/register", - json={"uuid": _UUID_2, "name": "Bob"}, - ) - api_key = resp.json()["api_key"] - assert len(api_key) == 64 - assert all(c in "0123456789abcdef" for c in api_key), ( - f"api_key contains non-hex characters: {api_key}" - ) - - -# --------------------------------------------------------------------------- -# Criterion 2 — Missing Authorization → 401 -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_signal_without_auth_header_returns_401(): - """POST /api/signal без Authorization header должен вернуть 401.""" - async with make_app_client() as client: - await client.post("/api/register", json={"uuid": _UUID_3, "name": "Charlie"}) - resp = await client.post( - "/api/signal", - json={"user_id": _UUID_3, "timestamp": 1742478000000}, - ) - assert resp.status_code == 401 - - -@pytest.mark.asyncio -async def test_signal_without_bearer_scheme_returns_401(): - """POST /api/signal с неверной схемой (Basic вместо Bearer) должен вернуть 401.""" - async with make_app_client() as client: - await client.post("/api/register", json={"uuid": _UUID_3, "name": "Charlie"}) - resp = await client.post( - "/api/signal", - json={"user_id": _UUID_3, "timestamp": 1742478000000}, - headers={"Authorization": "Basic wrongtoken"}, - ) - assert resp.status_code == 401 - - -# --------------------------------------------------------------------------- -# Criterion 3 — Wrong api_key → 401 -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_signal_with_wrong_api_key_returns_401(): - """POST /api/signal с неверным api_key должен вернуть 401.""" - async with make_app_client() as client: - await client.post("/api/register", json={"uuid": _UUID_4, "name": "Dave"}) - resp = await client.post( - "/api/signal", - json={"user_id": _UUID_4, "timestamp": 1742478000000}, - headers={"Authorization": "Bearer " + "0" * 64}, - ) - assert resp.status_code == 401 - - -@pytest.mark.asyncio -async def test_signal_with_unknown_user_returns_401(): - """POST /api/signal с api_key незарегистрированного пользователя должен вернуть 401.""" - async with make_app_client() as client: - resp = await client.post( - "/api/signal", - json={"user_id": _UUID_5, "timestamp": 1742478000000}, - headers={"Authorization": "Bearer " + "a" * 64}, - ) - assert resp.status_code == 401 - - -# --------------------------------------------------------------------------- -# Criterion 4 — Correct api_key → 200 -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_signal_with_valid_api_key_returns_200(): - """POST /api/signal с правильным api_key должен вернуть 200.""" - async with make_app_client() as client: - reg = await client.post( - "/api/register", - json={"uuid": _UUID_6, "name": "Eve"}, - ) - assert reg.status_code == 200 - api_key = reg.json()["api_key"] - - resp = await client.post( - "/api/signal", - json={"user_id": _UUID_6, "timestamp": 1742478000000}, - headers={"Authorization": f"Bearer {api_key}"}, - ) - assert resp.status_code == 200 - assert resp.json()["status"] == "ok" - - -# --------------------------------------------------------------------------- -# Criterion 5-7 — Key rotation on re-register -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_re_register_produces_new_api_key(): - """Повторная регистрация должна возвращать новый api_key (ротация).""" - async with make_app_client() as client: - r1 = await client.post("/api/register", json={"uuid": _UUID_7, "name": "Frank"}) - r2 = await client.post("/api/register", json={"uuid": _UUID_7, "name": "Frank"}) - - assert r1.status_code == 200 - assert r2.status_code == 200 - # Ключи могут совпасть (очень маловероятно), но оба должны быть длиной 64 - assert len(r2.json()["api_key"]) == 64 - - -@pytest.mark.asyncio -async def test_old_api_key_invalid_after_re_register(): - """После повторной регистрации старый api_key не должен работать.""" - async with make_app_client() as client: - r1 = await client.post("/api/register", json={"uuid": _UUID_8, "name": "Grace"}) - old_key = r1.json()["api_key"] - - # Повторная регистрация — ротация ключа - r2 = await client.post("/api/register", json={"uuid": _UUID_8, "name": "Grace"}) - new_key = r2.json()["api_key"] - - # Старый ключ больше не должен работать - old_resp = await client.post( - "/api/signal", - json={"user_id": _UUID_8, "timestamp": 1742478000000}, - headers={"Authorization": f"Bearer {old_key}"}, - ) - - # Новый ключ должен работать - new_resp = await client.post( - "/api/signal", - json={"user_id": _UUID_8, "timestamp": 1742478000000}, - headers={"Authorization": f"Bearer {new_key}"}, - ) - - assert old_resp.status_code == 401, "Старый ключ должен быть недействителен после ротации" - assert new_resp.status_code == 200, "Новый ключ должен работать" - - -# --------------------------------------------------------------------------- -# Criterion 5 (task brief) — Token from another user → 401 -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_signal_with_other_user_token_returns_401(): - """POST /api/signal с токеном другого пользователя должен вернуть 401. - - Невозможно отправить сигнал от чужого имени даже зная UUID. - """ - async with make_app_client() as client: - # Регистрируем двух пользователей - r_a = await client.post("/api/register", json={"uuid": _UUID_9, "name": "UserA"}) - r_b = await client.post("/api/register", json={"uuid": _UUID_10, "name": "UserB"}) - assert r_a.status_code == 200 - assert r_b.status_code == 200 - api_key_a = r_a.json()["api_key"] - api_key_b = r_b.json()["api_key"] - - # UserA пытается отправить сигнал с токеном UserB - resp_a_with_b_key = await client.post( - "/api/signal", - json={"user_id": _UUID_9, "timestamp": 1742478000000}, - headers={"Authorization": f"Bearer {api_key_b}"}, - ) - - # UserB пытается отправить сигнал с токеном UserA - resp_b_with_a_key = await client.post( - "/api/signal", - json={"user_id": _UUID_10, "timestamp": 1742478000000}, - headers={"Authorization": f"Bearer {api_key_a}"}, - ) - - assert resp_a_with_b_key.status_code == 401, ( - "Нельзя отправить сигнал от имени UserA с токеном UserB" - ) - assert resp_b_with_a_key.status_code == 401, ( - "Нельзя отправить сигнал от имени UserB с токеном UserA" - ) - - -# --------------------------------------------------------------------------- -# Criterion 8 — SHA-256 hash is stored, not the raw key -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_api_key_hash_stored_in_db_not_raw_key(): - """В БД должен храниться SHA-256 хэш api_key, а не сырой ключ.""" - with temp_db(): - from backend.main import app - import contextlib - import httpx - import respx - from httpx import AsyncClient, ASGITransport - - 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" - - mock_router = respx.mock(assert_all_called=False) - mock_router.post(tg_set_url).mock( - return_value=httpx.Response(200, json={"ok": True, "result": True}) - ) - mock_router.post(send_url).mock( - return_value=httpx.Response(200, json={"ok": True}) - ) - - with mock_router: - async with app.router.lifespan_context(app): - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://testserver") as client: - reg = await client.post( - "/api/register", - json={"uuid": _UUID_1, "name": "HashTest"}, - ) - assert reg.status_code == 200 - raw_api_key = reg.json()["api_key"] - - # Читаем хэш из БД напрямую - stored_hash = await db.get_api_key_hash_by_uuid(_UUID_1) - - expected_hash = hashlib.sha256(raw_api_key.encode()).hexdigest() - assert stored_hash is not None, "api_key_hash должен быть в БД" - assert stored_hash == expected_hash, ( - "В БД должен быть SHA-256 хэш, а не сырой ключ" - ) - assert stored_hash != raw_api_key, "В БД не должен храниться сырой ключ" diff --git a/tests/test_sec_006.py b/tests/test_sec_006.py deleted file mode 100644 index e0db144..0000000 --- a/tests/test_sec_006.py +++ /dev/null @@ -1,337 +0,0 @@ -""" -Tests for BATON-SEC-006: Персистентное хранение rate-limit счётчиков. - -Acceptance criteria: -1. Счётчики сохраняются между пересозданием экземпляра приложения (симуляция рестарта). -2. TTL-очистка корректно сбрасывает устаревшие записи после истечения окна. -3. Превышение лимита возвращает HTTP 429. -4. X-Real-IP и X-Forwarded-For корректно парсятся для подсчёта. - -UUID note: All UUIDs below satisfy the v4 pattern validated since BATON-SEC-005. -""" -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 tempfile -import unittest.mock as mock - -import aiosqlite -import pytest - -from backend import config, db -from tests.conftest import make_app_client - -# ── Valid UUID v4 constants ────────────────────────────────────────────────── - -_UUID_XREALIP_A = "c0000001-0000-4000-8000-000000000001" # X-Real-IP exhaustion -_UUID_XREALIP_B = "c0000002-0000-4000-8000-000000000002" # IP-B (independent counter) -_UUID_XFWD = "c0000003-0000-4000-8000-000000000003" # X-Forwarded-For test -_UUID_REG_RL = "c0000004-0000-4000-8000-000000000004" # register 429 test - - -# ── Helpers ────────────────────────────────────────────────────────────────── - - -def _tmpdb() -> str: - """Set config.DB_PATH to a fresh temp file and return the path.""" - path = tempfile.mktemp(suffix=".db") - config.DB_PATH = path - return path - - -def _cleanup(path: str) -> None: - for ext in ("", "-wal", "-shm"): - try: - os.unlink(path + ext) - except FileNotFoundError: - pass - - -# ── Criterion 1: Persistence across restart ─────────────────────────────────── - - -@pytest.mark.asyncio -async def test_rate_limits_table_created_by_init_db(): - """init_db() creates the rate_limits table in SQLite.""" - path = _tmpdb() - try: - await db.init_db() - async with aiosqlite.connect(path) as conn: - async with conn.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name='rate_limits'" - ) as cur: - row = await cur.fetchone() - assert row is not None, "rate_limits table not found after init_db()" - finally: - _cleanup(path) - - -@pytest.mark.asyncio -async def test_rate_limit_counter_persists_after_db_reinit(): - """Counter survives re-initialization of the DB (simulates app restart). - - Before: in-memory app.state.rate_counters was lost on restart. - After: SQLite-backed rate_limits table persists across init_db() calls. - """ - path = _tmpdb() - try: - await db.init_db() - - c1 = await db.rate_limit_increment("persist:test", 600) - c2 = await db.rate_limit_increment("persist:test", 600) - c3 = await db.rate_limit_increment("persist:test", 600) - assert c3 == 3, f"Expected 3 after 3 increments, got {c3}" - - # Simulate restart: re-initialize DB against the same file - await db.init_db() - - # Counter must continue from 3, not reset to 0 - c4 = await db.rate_limit_increment("persist:test", 600) - assert c4 == 4, ( - f"Expected 4 after reinit + 1 more increment (counter must persist), got {c4}" - ) - finally: - _cleanup(path) - - -@pytest.mark.asyncio -async def test_rate_limit_increment_returns_sequential_counts(): - """rate_limit_increment returns 1, 2, 3 on successive calls within window.""" - path = _tmpdb() - try: - await db.init_db() - c1 = await db.rate_limit_increment("seq:test", 600) - c2 = await db.rate_limit_increment("seq:test", 600) - c3 = await db.rate_limit_increment("seq:test", 600) - assert (c1, c2, c3) == (1, 2, 3), f"Expected (1,2,3), got ({c1},{c2},{c3})" - finally: - _cleanup(path) - - -# ── Criterion 2: TTL cleanup resets stale entries ──────────────────────────── - - -@pytest.mark.asyncio -async def test_rate_limit_ttl_resets_counter_after_window_expires(): - """Counter resets to 1 when the time window has expired (TTL cleanup). - - time.time() is mocked — no real sleep required. - """ - path = _tmpdb() - try: - await db.init_db() - - with mock.patch("backend.db.time") as mock_time: - mock_time.time.return_value = 1000.0 # window_start = t0 - - c1 = await db.rate_limit_increment("ttl:test", 10) - c2 = await db.rate_limit_increment("ttl:test", 10) - c3 = await db.rate_limit_increment("ttl:test", 10) - assert c3 == 3 - - # Jump 11 seconds ahead (window = 10s → expired) - mock_time.time.return_value = 1011.0 - - c4 = await db.rate_limit_increment("ttl:test", 10) - - assert c4 == 1, ( - f"Expected counter reset to 1 after window expired, got {c4}" - ) - finally: - _cleanup(path) - - -@pytest.mark.asyncio -async def test_rate_limit_ttl_does_not_reset_within_window(): - """Counter is NOT reset when the window has NOT expired yet.""" - path = _tmpdb() - try: - await db.init_db() - - with mock.patch("backend.db.time") as mock_time: - mock_time.time.return_value = 1000.0 - - await db.rate_limit_increment("ttl:within", 10) - await db.rate_limit_increment("ttl:within", 10) - c3 = await db.rate_limit_increment("ttl:within", 10) - assert c3 == 3 - - # Only 5 seconds passed (window = 10s, still active) - mock_time.time.return_value = 1005.0 - - c4 = await db.rate_limit_increment("ttl:within", 10) - - assert c4 == 4, ( - f"Expected 4 (counter continues inside window), got {c4}" - ) - finally: - _cleanup(path) - - -@pytest.mark.asyncio -async def test_rate_limit_ttl_boundary_exactly_at_window_end(): - """Counter resets when elapsed time equals exactly the window duration.""" - path = _tmpdb() - try: - await db.init_db() - - with mock.patch("backend.db.time") as mock_time: - mock_time.time.return_value = 1000.0 - - await db.rate_limit_increment("ttl:boundary", 10) - await db.rate_limit_increment("ttl:boundary", 10) - - # Exactly at window boundary (elapsed == window → stale) - mock_time.time.return_value = 1010.0 - - c = await db.rate_limit_increment("ttl:boundary", 10) - - assert c == 1, ( - f"Expected reset at exact window boundary (elapsed == window), got {c}" - ) - finally: - _cleanup(path) - - -# ── Criterion 3: HTTP 429 when rate limit exceeded ──────────────────────────── - - -@pytest.mark.asyncio -async def test_register_returns_429_after_rate_limit_exceeded(): - """POST /api/register returns 429 on the 6th request from the same IP. - - Register limit = 5 requests per 600s window. - """ - async with make_app_client() as client: - ip_hdrs = {"X-Real-IP": "192.0.2.10"} - statuses = [] - for _ in range(6): - r = await client.post( - "/api/register", - json={"uuid": _UUID_REG_RL, "name": "RateLimitTest"}, - headers=ip_hdrs, - ) - statuses.append(r.status_code) - - assert statuses[-1] == 429, ( - f"Expected 429 on 6th register request, got statuses: {statuses}" - ) - - -@pytest.mark.asyncio -async def test_register_first_5_requests_are_allowed(): - """First 5 POST /api/register requests from the same IP must all return 200.""" - async with make_app_client() as client: - ip_hdrs = {"X-Real-IP": "192.0.2.11"} - statuses = [] - for _ in range(5): - r = await client.post( - "/api/register", - json={"uuid": _UUID_REG_RL, "name": "RateLimitTest"}, - headers=ip_hdrs, - ) - statuses.append(r.status_code) - - assert all(s == 200 for s in statuses), ( - f"Expected all 5 register requests to return 200, got: {statuses}" - ) - - -# ── Criterion 4: X-Real-IP and X-Forwarded-For for rate counting ────────────── - - -@pytest.mark.asyncio -async def test_x_real_ip_header_is_used_for_rate_counting(): - """Rate counter keys are derived from X-Real-IP: two requests sharing - the same X-Real-IP share the same counter and collectively hit the 429 limit. - """ - async with make_app_client() as client: - await client.post( - "/api/register", json={"uuid": _UUID_XREALIP_A, "name": "RealIPUser"} - ) - - ip_hdrs = {"X-Real-IP": "203.0.113.10"} - payload = {"user_id": _UUID_XREALIP_A, "timestamp": 1742478000000} - - 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 signal with same X-Real-IP, got: {statuses}" - ) - - -@pytest.mark.asyncio -async def test_x_forwarded_for_header_is_used_for_rate_counting(): - """Rate counter keys are derived from X-Forwarded-For (first IP) when - X-Real-IP is absent: requests sharing the same forwarded IP hit the limit. - """ - async with make_app_client() as client: - await client.post( - "/api/register", json={"uuid": _UUID_XFWD, "name": "FwdUser"} - ) - - # Chain: first IP is the original client (only that one is used) - fwd_hdrs = {"X-Forwarded-For": "198.51.100.5, 10.0.0.1, 172.16.0.1"} - payload = {"user_id": _UUID_XFWD, "timestamp": 1742478000000} - - statuses = [] - for _ in range(11): - r = await client.post("/api/signal", json=payload, headers=fwd_hdrs) - statuses.append(r.status_code) - - assert statuses[-1] == 429, ( - f"Expected 429 on 11th request with same X-Forwarded-For first IP, got: {statuses}" - ) - - -@pytest.mark.asyncio -async def test_different_x_real_ip_values_have_independent_counters(): - """Exhausting the rate limit for IP-A must not block IP-B. - - Verifies that rate-limit keys are truly per-IP. - """ - async with make_app_client() as client: - r_a = await client.post( - "/api/register", json={"uuid": _UUID_XREALIP_A, "name": "IPA"} - ) - r_b = await client.post( - "/api/register", json={"uuid": _UUID_XREALIP_B, "name": "IPB"} - ) - api_key_a = r_a.json()["api_key"] - api_key_b = r_b.json()["api_key"] - - # Exhaust limit for IP-A (with valid auth so requests reach the rate limiter) - for _ in range(11): - await client.post( - "/api/signal", - json={"user_id": _UUID_XREALIP_A, "timestamp": 1742478000000}, - headers={ - "X-Real-IP": "198.51.100.100", - "Authorization": f"Bearer {api_key_a}", - }, - ) - - # IP-B has its own independent counter — must not be blocked - r = await client.post( - "/api/signal", - json={"user_id": _UUID_XREALIP_B, "timestamp": 1742478000000}, - headers={ - "X-Real-IP": "198.51.100.200", - "Authorization": f"Bearer {api_key_b}", - }, - ) - - assert r.status_code == 200, ( - f"IP-B was incorrectly blocked after IP-A exhausted its counter: {r.status_code}" - ) diff --git a/tests/test_sec_007.py b/tests/test_sec_007.py deleted file mode 100644 index a6c3383..0000000 --- a/tests/test_sec_007.py +++ /dev/null @@ -1,240 +0,0 @@ -""" -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. - -BATON-SEC-003: POST /api/signal now requires Authorization: Bearer . -Tests that send signals now register first and use the returned api_key. -""" -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") -os.environ.setdefault("ADMIN_TOKEN", "test-admin-token") - -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" - -# Valid UUID v4 constants -_UUID_CT = "d4e5f6a7-b8c9-4d0e-a1b2-c3d4e5f6a7b8" -_UUID_SLOW = "e5f6a7b8-c9d0-4e1f-a2b3-c4d5e6f7a8b9" - - -# --------------------------------------------------------------------------- -# 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: - reg = await client.post("/api/register", json={"uuid": _UUID_CT, "name": "CT"}) - assert reg.status_code == 200 - api_key = reg.json()["api_key"] - resp = await client.post( - "/api/signal", - json={"user_id": _UUID_CT, "timestamp": 1742478000000}, - headers={"Authorization": f"Bearer {api_key}"}, - ) - - 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.""" - 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: - reg = await client.post( - "/api/register", - json={"uuid": _UUID_SLOW, "name": "Slow"}, - ) - assert reg.status_code == 200 - api_key = reg.json()["api_key"] - resp = await client.post( - "/api/signal", - json={ - "user_id": _UUID_SLOW, - "timestamp": 1742478000000, - }, - headers={"Authorization": f"Bearer {api_key}"}, - ) - - 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() diff --git a/tests/test_signal.py b/tests/test_signal.py index 1ed0fc2..83a86af 100644 --- a/tests/test_signal.py +++ b/tests/test_signal.py @@ -1,11 +1,5 @@ """ Integration tests for POST /api/signal. - -UUID notes: both RegisterRequest.uuid and SignalRequest.user_id require valid UUID v4. -All UUID constants below satisfy the pattern. - -BATON-SEC-003: /api/signal now requires Authorization: Bearer . -The _register() helper returns the api_key from the registration response. """ from __future__ import annotations @@ -16,42 +10,30 @@ 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 httpx import AsyncClient from tests.conftest import make_app_client -# Valid UUID v4 constants for signal tests -_UUID_1 = "c0000001-0000-4000-8000-000000000001" -_UUID_2 = "c0000002-0000-4000-8000-000000000002" -_UUID_3 = "c0000003-0000-4000-8000-000000000003" -_UUID_4 = "c0000004-0000-4000-8000-000000000004" -_UUID_5 = "c0000005-0000-4000-8000-000000000005" -_UUID_6 = "c0000006-0000-4000-8000-000000000006" - -async def _register(client: AsyncClient, uuid: str, name: str) -> str: - """Register user, assert success, return raw api_key.""" +async def _register(client: AsyncClient, uuid: str, name: str) -> None: r = await client.post("/api/register", json={"uuid": uuid, "name": name}) - assert r.status_code == 200, f"Registration failed: {r.status_code} {r.text}" - return r.json()["api_key"] + assert r.status_code == 200 @pytest.mark.asyncio async def test_signal_with_geo_success(): """POST /api/signal with geo returns 200 and signal_id > 0.""" async with make_app_client() as client: - api_key = await _register(client, _UUID_1, "Alice") + await _register(client, "sig-uuid-001", "Alice") resp = await client.post( "/api/signal", json={ - "user_id": _UUID_1, + "user_id": "sig-uuid-001", "timestamp": 1742478000000, "geo": {"lat": 55.7558, "lon": 37.6173, "accuracy": 15.0}, }, - headers={"Authorization": f"Bearer {api_key}"}, ) assert resp.status_code == 200 data = resp.json() @@ -63,15 +45,14 @@ async def test_signal_with_geo_success(): async def test_signal_without_geo_success(): """POST /api/signal with geo: null returns 200.""" async with make_app_client() as client: - api_key = await _register(client, _UUID_2, "Bob") + await _register(client, "sig-uuid-002", "Bob") resp = await client.post( "/api/signal", json={ - "user_id": _UUID_2, + "user_id": "sig-uuid-002", "timestamp": 1742478000000, "geo": None, }, - headers={"Authorization": f"Bearer {api_key}"}, ) assert resp.status_code == 200 assert resp.json()["status"] == "ok" @@ -94,7 +75,7 @@ async def test_signal_missing_timestamp_returns_422(): async with make_app_client() as client: resp = await client.post( "/api/signal", - json={"user_id": _UUID_3}, + json={"user_id": "sig-uuid-003"}, ) assert resp.status_code == 422 @@ -106,16 +87,14 @@ async def test_signal_stored_in_db(): proving both were persisted. """ async with make_app_client() as client: - api_key = await _register(client, _UUID_4, "Charlie") + await _register(client, "sig-uuid-004", "Charlie") r1 = await client.post( "/api/signal", - json={"user_id": _UUID_4, "timestamp": 1742478000001}, - headers={"Authorization": f"Bearer {api_key}"}, + json={"user_id": "sig-uuid-004", "timestamp": 1742478000001}, ) r2 = await client.post( "/api/signal", - json={"user_id": _UUID_4, "timestamp": 1742478000002}, - headers={"Authorization": f"Bearer {api_key}"}, + json={"user_id": "sig-uuid-004", "timestamp": 1742478000002}, ) assert r1.status_code == 200 assert r2.status_code == 200 @@ -132,12 +111,11 @@ async def test_signal_sends_telegram_message_directly(): send_url = f"https://api.telegram.org/bot{_cfg.BOT_TOKEN}/sendMessage" async with make_app_client() as client: - api_key = await _register(client, _UUID_5, "Dana") + await _register(client, "sig-uuid-005", "Dana") # make_app_client already mocks send_url; signal returns 200 proves send was called resp = await client.post( "/api/signal", - json={"user_id": _UUID_5, "timestamp": 1742478000000}, - headers={"Authorization": f"Bearer {api_key}"}, + json={"user_id": "sig-uuid-005", "timestamp": 1742478000000}, ) assert resp.status_code == 200 @@ -148,11 +126,10 @@ async def test_signal_sends_telegram_message_directly(): async def test_signal_returns_signal_id_positive(): """signal_id in response is always a positive integer.""" async with make_app_client() as client: - api_key = await _register(client, _UUID_6, "Eve") + await _register(client, "sig-uuid-006", "Eve") resp = await client.post( "/api/signal", - json={"user_id": _UUID_6, "timestamp": 1742478000000}, - headers={"Authorization": f"Bearer {api_key}"}, + json={"user_id": "sig-uuid-006", "timestamp": 1742478000000}, ) assert resp.json()["signal_id"] > 0 @@ -164,7 +141,7 @@ async def test_signal_geo_invalid_lat_returns_422(): resp = await client.post( "/api/signal", json={ - "user_id": _UUID_1, + "user_id": "sig-uuid-007", "timestamp": 1742478000000, "geo": {"lat": 200.0, "lon": 0.0, "accuracy": 10.0}, }, diff --git a/tests/test_telegram.py b/tests/test_telegram.py index c55a6a0..17ec801 100644 --- a/tests/test_telegram.py +++ b/tests/test_telegram.py @@ -34,57 +34,11 @@ import pytest import respx from backend import config -from backend.telegram import SignalAggregator, send_message, set_webhook, validate_bot_token +from backend.telegram import SignalAggregator, send_message, set_webhook SEND_URL = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage" WEBHOOK_URL_API = f"https://api.telegram.org/bot{config.BOT_TOKEN}/setWebhook" -GET_ME_URL = f"https://api.telegram.org/bot{config.BOT_TOKEN}/getMe" - - -# --------------------------------------------------------------------------- -# validate_bot_token -# --------------------------------------------------------------------------- - -@pytest.mark.asyncio -async def test_validate_bot_token_returns_true_on_200(): - """validate_bot_token returns True when getMe responds 200.""" - with respx.mock(assert_all_called=False) as mock: - mock.get(GET_ME_URL).mock( - return_value=httpx.Response(200, json={"ok": True, "result": {"username": "batonbot"}}) - ) - result = await validate_bot_token() - assert result is True - - -@pytest.mark.asyncio -async def test_validate_bot_token_returns_false_on_401(caplog): - """validate_bot_token returns False and logs ERROR when getMe responds 401.""" - import logging - - with respx.mock(assert_all_called=False) as mock: - mock.get(GET_ME_URL).mock( - return_value=httpx.Response(401, json={"ok": False, "description": "Unauthorized"}) - ) - with caplog.at_level(logging.ERROR, logger="backend.telegram"): - result = await validate_bot_token() - - assert result is False - assert any("401" in record.message for record in caplog.records) - - -@pytest.mark.asyncio -async def test_validate_bot_token_returns_false_on_network_error(caplog): - """validate_bot_token returns False and logs ERROR on network failure — never raises.""" - import logging - - with respx.mock(assert_all_called=False) as mock: - mock.get(GET_ME_URL).mock(side_effect=httpx.ConnectError("connection refused")) - with caplog.at_level(logging.ERROR, logger="backend.telegram"): - result = await validate_bot_token() - - assert result is False - assert len(caplog.records) >= 1 # --------------------------------------------------------------------------- @@ -307,71 +261,6 @@ async def test_aggregator_buffer_cleared_after_flush(): _cleanup(path) -# --------------------------------------------------------------------------- -# BATON-007: 400 "chat not found" handling -# --------------------------------------------------------------------------- - -@pytest.mark.asyncio -async def test_send_message_400_chat_not_found_does_not_raise(): - """400 'chat not found' must not raise an exception (service stays alive).""" - with respx.mock(assert_all_called=False) as mock: - mock.post(SEND_URL).mock( - return_value=httpx.Response( - 400, - json={"ok": False, "error_code": 400, "description": "Bad Request: chat not found"}, - ) - ) - # Must not raise — service must stay alive even with wrong CHAT_ID - await send_message("test") - - -@pytest.mark.asyncio -async def test_send_message_400_chat_not_found_logs_error(caplog): - """400 response from Telegram must be logged as ERROR with the status code.""" - import logging - - with respx.mock(assert_all_called=False) as mock: - mock.post(SEND_URL).mock( - return_value=httpx.Response( - 400, - json={"ok": False, "error_code": 400, "description": "Bad Request: chat not found"}, - ) - ) - with caplog.at_level(logging.ERROR, logger="backend.telegram"): - await send_message("test chat not found") - - assert any("400" in record.message for record in caplog.records), ( - "Expected ERROR log containing '400' but got: " + str([r.message for r in caplog.records]) - ) - - -@pytest.mark.asyncio -async def test_send_message_400_breaks_after_first_attempt(): - """On 400, send_message breaks immediately (no retry loop) — only one HTTP call made.""" - with respx.mock(assert_all_called=False) as mock: - route = mock.post(SEND_URL).mock( - return_value=httpx.Response( - 400, - json={"ok": False, "error_code": 400, "description": "Bad Request: chat not found"}, - ) - ) - await send_message("test no retry on 400") - - assert route.call_count == 1, f"Expected 1 call on 400, got {route.call_count}" - - -@pytest.mark.asyncio -async def test_send_message_all_5xx_retries_exhausted_does_not_raise(): - """When all 3 attempts fail with 5xx, send_message logs error but does NOT raise.""" - with respx.mock(assert_all_called=False) as mock: - mock.post(SEND_URL).mock( - return_value=httpx.Response(500, text="Internal Server Error") - ) - with patch("backend.telegram.asyncio.sleep", new_callable=AsyncMock): - # Must not raise — message is dropped, service stays alive - await send_message("test all retries exhausted") - - @pytest.mark.asyncio async def test_aggregator_unknown_user_shows_uuid_prefix(): """If user_name is None, the message shows first 8 chars of uuid."""