Compare commits

...

10 commits

Author SHA1 Message Date
Gros Frumos
51f1943c55 fix(BATON-007): add validate_bot_token() for startup detection and fix test mocks
- Add validate_bot_token() to backend/telegram.py: calls getMe on startup,
  logs ERROR if token is invalid (never raises per #1215 contract)
- Call validate_bot_token() in lifespan() after db.init_db() for early detection
- Update conftest.py make_app_client() to mock getMe endpoint
- Add 3 tests for validate_bot_token (200, 401, network error cases)

Root cause: CHAT_ID=5190015988 (positive) was wrong — fixed to -5190015988
on server per decision #1212. Group "Big Red Button" confirmed via getChat.
Service restarted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 08:54:07 +02:00
Gros Frumos
8ee9782737 kin: BATON-007 При нажатии на кнопку происходит анимация и сообщение что сигнал отправлен, но в телеграм группу ничего не приходит. 2026-03-21 08:36:20 +02:00
Gros Frumos
8279576ccd kin: BATON-SEC-003 Добавить аутентификацию на /api/signal 2026-03-21 08:16:46 +02:00
Gros Frumos
a9021cd5cc Merge branch 'BATON-SEC-003-frontend_dev' 2026-03-21 08:13:14 +02:00
Gros Frumos
d2873bf9e0 kin: BATON-SEC-003-frontend_dev 2026-03-21 08:13:14 +02:00
Gros Frumos
2d66b1da58 kin: BATON-007 При нажатии на кнопку происходит анимация и сообщение что сигнал отправлен, но в телеграм группу ничего не приходит. 2026-03-21 08:12:49 +02:00
Gros Frumos
abae67d75a Merge branch 'BATON-SEC-003-backend_dev' 2026-03-21 08:12:01 +02:00
Gros Frumos
3a2ec11cc7 kin: BATON-SEC-003-backend_dev 2026-03-21 08:12:01 +02:00
Gros Frumos
46ed072cff kin: BATON-FIX-001 Установить FRONTEND_ORIGIN=https://baton.itafrika.com в .env на проде 2026-03-21 07:59:50 +02:00
Gros Frumos
c969825c80 nginx: добавить security-заголовки (HSTS, CSP, X-Frame-Options, X-Content-Type)
Заголовки повторены в location / из-за особенности nginx — дочерний блок
с add_header отменяет наследование родительского server-уровня.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 07:58:56 +02:00
20 changed files with 1416 additions and 135 deletions

View file

@ -30,6 +30,7 @@ async def init_db() -> None:
name TEXT NOT NULL, name TEXT NOT NULL,
is_blocked INTEGER NOT NULL DEFAULT 0, is_blocked INTEGER NOT NULL DEFAULT 0,
password_hash TEXT DEFAULT NULL, password_hash TEXT DEFAULT NULL,
api_key_hash TEXT DEFAULT NULL,
created_at TEXT DEFAULT (datetime('now')) created_at TEXT DEFAULT (datetime('now'))
); );
@ -71,6 +72,7 @@ async def init_db() -> None:
for stmt in [ for stmt in [
"ALTER TABLE users ADD COLUMN is_blocked INTEGER NOT NULL DEFAULT 0", "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 password_hash TEXT DEFAULT NULL",
"ALTER TABLE users ADD COLUMN api_key_hash TEXT DEFAULT NULL",
]: ]:
try: try:
await conn.execute(stmt) await conn.execute(stmt)
@ -80,8 +82,17 @@ async def init_db() -> None:
await conn.commit() await conn.commit()
async def register_user(uuid: str, name: str) -> dict: async def register_user(uuid: str, name: str, api_key_hash: Optional[str] = None) -> dict:
async with _get_conn() as conn: 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( await conn.execute(
"INSERT OR IGNORE INTO users (uuid, name) VALUES (?, ?)", "INSERT OR IGNORE INTO users (uuid, name) VALUES (?, ?)",
(uuid, name), (uuid, name),
@ -94,6 +105,15 @@ async def register_user(uuid: str, name: str) -> dict:
return {"user_id": row["id"], "uuid": row["uuid"]} 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( async def save_signal(
user_uuid: str, user_uuid: str,
timestamp: int, timestamp: int,

View file

@ -4,14 +4,16 @@ import asyncio
import hashlib import hashlib
import logging import logging
import os import os
import secrets
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any, Optional
import httpx import httpx
from fastapi import Depends, FastAPI, HTTPException, Request from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from backend import config, db, telegram 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, rate_limit_signal, verify_admin_token, verify_webhook_secret
@ -25,10 +27,17 @@ from backend.models import (
SignalResponse, SignalResponse,
) )
_api_key_bearer = HTTPBearer(auto_error=False)
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__) 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: def _hash_password(password: str) -> str:
"""Hash a password using PBKDF2-HMAC-SHA256 (stdlib, no external deps). """Hash a password using PBKDF2-HMAC-SHA256 (stdlib, no external deps).
@ -62,6 +71,11 @@ async def lifespan(app: FastAPI):
await db.init_db() await db.init_db()
logger.info("Database initialized") 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: if config.WEBHOOK_ENABLED:
await telegram.set_webhook(url=config.WEBHOOK_URL, secret=config.WEBHOOK_SECRET) await telegram.set_webhook(url=config.WEBHOOK_URL, secret=config.WEBHOOK_SECRET)
logger.info("Webhook registered") logger.info("Webhook registered")
@ -104,7 +118,7 @@ app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=[config.FRONTEND_ORIGIN], allow_origins=[config.FRONTEND_ORIGIN],
allow_methods=["POST"], allow_methods=["POST"],
allow_headers=["Content-Type"], allow_headers=["Content-Type", "Authorization"],
) )
@ -116,12 +130,24 @@ async def health() -> dict[str, Any]:
@app.post("/api/register", response_model=RegisterResponse) @app.post("/api/register", response_model=RegisterResponse)
async def register(body: RegisterRequest, _: None = Depends(rate_limit_register)) -> RegisterResponse: async def register(body: RegisterRequest, _: None = Depends(rate_limit_register)) -> RegisterResponse:
result = await db.register_user(uuid=body.uuid, name=body.name) api_key = secrets.token_hex(32)
return RegisterResponse(user_id=result["user_id"], uuid=result["uuid"]) 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)
@app.post("/api/signal", response_model=SignalResponse) @app.post("/api/signal", response_model=SignalResponse)
async def signal(body: SignalRequest, _: None = Depends(rate_limit_signal)) -> 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")
if await db.is_user_blocked(body.user_id): if await db.is_user_blocked(body.user_id):
raise HTTPException(status_code=403, detail="User is blocked") raise HTTPException(status_code=403, detail="User is blocked")

View file

@ -12,6 +12,7 @@ class RegisterRequest(BaseModel):
class RegisterResponse(BaseModel): class RegisterResponse(BaseModel):
user_id: int user_id: int
uuid: str uuid: str
api_key: str
class GeoData(BaseModel): class GeoData(BaseModel):

View file

@ -14,6 +14,25 @@ logger = logging.getLogger(__name__)
_TELEGRAM_API = "https://api.telegram.org/bot{token}/{method}" _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: async def send_message(text: str) -> None:
url = _TELEGRAM_API.format(token=config.BOT_TOKEN, method="sendMessage") url = _TELEGRAM_API.format(token=config.BOT_TOKEN, method="sendMessage")
async with httpx.AsyncClient(timeout=10) as client: async with httpx.AsyncClient(timeout=10) as client:

View file

@ -56,9 +56,14 @@ function _getUserName() {
return _storage.getItem('baton_user_name') || ''; return _storage.getItem('baton_user_name') || '';
} }
function _saveRegistration(name) { function _getApiKey() {
return _storage.getItem('baton_api_key') || '';
}
function _saveRegistration(name, apiKey) {
_storage.setItem('baton_user_name', name); _storage.setItem('baton_user_name', name);
_storage.setItem('baton_registered', '1'); _storage.setItem('baton_registered', '1');
if (apiKey) _storage.setItem('baton_api_key', apiKey);
} }
function _getInitials(name) { function _getInitials(name) {
@ -102,15 +107,17 @@ function _updateUserAvatar() {
// ========== API calls ========== // ========== API calls ==========
async function _apiPost(path, body) { async function _apiPost(path, body, extraHeaders) {
const res = await fetch(path, { const res = await fetch(path, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json', ...extraHeaders },
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
if (!res.ok) { if (!res.ok) {
const text = await res.text().catch(() => ''); const text = await res.text().catch(() => '');
throw new Error('HTTP ' + res.status + (text ? ': ' + text : '')); const err = new Error('HTTP ' + res.status + (text ? ': ' + text : ''));
err.status = res.status;
throw err;
} }
return res.json(); return res.json();
} }
@ -146,8 +153,8 @@ async function _handleRegister() {
try { try {
const uuid = _getOrCreateUserId(); const uuid = _getOrCreateUserId();
await _apiPost('/api/register', { uuid, name }); const data = await _apiPost('/api/register', { uuid, name });
_saveRegistration(name); _saveRegistration(name, data.api_key);
_updateUserAvatar(); _updateUserAvatar();
_showMain(); _showMain();
} catch (_) { } catch (_) {
@ -179,7 +186,9 @@ async function _handleSignal() {
const body = { user_id: uuid, timestamp: Date.now() }; const body = { user_id: uuid, timestamp: Date.now() };
if (geo) body.geo = geo; if (geo) body.geo = geo;
await _apiPost('/api/signal', body); const apiKey = _getApiKey();
const authHeaders = apiKey ? { Authorization: 'Bearer ' + apiKey } : {};
await _apiPost('/api/signal', body, authHeaders);
_setSosState('success'); _setSosState('success');
_setStatus('Signal sent!', 'success'); _setStatus('Signal sent!', 'success');
@ -187,11 +196,15 @@ async function _handleSignal() {
_setSosState('default'); _setSosState('default');
_setStatus('', ''); _setStatus('', '');
}, 2000); }, 2000);
} catch (_) { } catch (err) {
_setSosState('default'); _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');
} }
} }
}
// ========== Screens ========== // ========== Screens ==========

View file

@ -91,9 +91,27 @@ server {
proxy_connect_timeout 5s; 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) # Статика фронтенда (SPA)
location / { location / {
root /opt/baton/frontend; root /opt/baton/frontend;
try_files $uri /index.html; 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;
} }
} }

View file

@ -79,6 +79,7 @@ def make_app_client():
""" """
tg_set_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/setWebhook" 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" 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 @contextlib.asynccontextmanager
async def _ctx(): async def _ctx():
@ -86,6 +87,9 @@ def make_app_client():
from backend.main import app from backend.main import app
mock_router = respx.mock(assert_all_called=False) 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( mock_router.post(tg_set_url).mock(
return_value=httpx.Response(200, json={"ok": True, "result": True}) return_value=httpx.Response(200, json={"ok": True, "result": True})
) )

View file

@ -5,6 +5,10 @@ Acceptance criteria:
1. No asyncio task for the aggregator is created at lifespan startup. 1. No asyncio task for the aggregator is created at lifespan startup.
2. POST /api/signal calls telegram.send_message directly (no aggregator intermediary). 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. 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 <api_key>.
Tests that send signals register first and use the returned api_key.
""" """
from __future__ import annotations from __future__ import annotations
@ -15,6 +19,7 @@ os.environ.setdefault("CHAT_ID", "-1001234567890")
os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret") os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret")
os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram") os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram")
os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000") os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000")
os.environ.setdefault("ADMIN_TOKEN", "test-admin-token")
from pathlib import Path from pathlib import Path
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
@ -25,6 +30,20 @@ from tests.conftest import make_app_client
_BACKEND_DIR = Path(__file__).parent.parent / "backend" _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) # Criterion 1 — No asyncio task for aggregator created at startup (static)
@ -72,11 +91,12 @@ def test_aggregator_instantiation_commented_out_in_main():
async def test_signal_calls_telegram_send_message_directly(): async def test_signal_calls_telegram_send_message_directly():
"""POST /api/signal must call telegram.send_message directly, not via aggregator (ADR-004).""" """POST /api/signal must call telegram.send_message directly, not via aggregator (ADR-004)."""
async with make_app_client() as client: async with make_app_client() as client:
await client.post("/api/register", json={"uuid": "adr-uuid-s1", "name": "Tester"}) api_key = await _register(client, _UUID_S1, "Tester")
with patch("backend.telegram.send_message", new_callable=AsyncMock) as mock_send: with patch("backend.telegram.send_message", new_callable=AsyncMock) as mock_send:
resp = await client.post( resp = await client.post(
"/api/signal", "/api/signal",
json={"user_id": "adr-uuid-s1", "timestamp": 1742478000000}, json={"user_id": _UUID_S1, "timestamp": 1742478000000},
headers={"Authorization": f"Bearer {api_key}"},
) )
assert resp.status_code == 200 assert resp.status_code == 200
mock_send.assert_called_once() mock_send.assert_called_once()
@ -86,11 +106,12 @@ async def test_signal_calls_telegram_send_message_directly():
async def test_signal_message_contains_registered_username(): async def test_signal_message_contains_registered_username():
"""Message passed to send_message must include the registered user's name.""" """Message passed to send_message must include the registered user's name."""
async with make_app_client() as client: async with make_app_client() as client:
await client.post("/api/register", json={"uuid": "adr-uuid-s2", "name": "Alice"}) api_key = await _register(client, _UUID_S2, "Alice")
with patch("backend.telegram.send_message", new_callable=AsyncMock) as mock_send: with patch("backend.telegram.send_message", new_callable=AsyncMock) as mock_send:
await client.post( await client.post(
"/api/signal", "/api/signal",
json={"user_id": "adr-uuid-s2", "timestamp": 1742478000000}, json={"user_id": _UUID_S2, "timestamp": 1742478000000},
headers={"Authorization": f"Bearer {api_key}"},
) )
text = mock_send.call_args[0][0] text = mock_send.call_args[0][0]
assert "Alice" in text assert "Alice" in text
@ -100,11 +121,12 @@ async def test_signal_message_contains_registered_username():
async def test_signal_message_without_geo_contains_bez_geolocatsii(): async def test_signal_message_without_geo_contains_bez_geolocatsii():
"""When geo is None, message must contain 'Без геолокации'.""" """When geo is None, message must contain 'Без геолокации'."""
async with make_app_client() as client: async with make_app_client() as client:
await client.post("/api/register", json={"uuid": "adr-uuid-s3", "name": "Bob"}) api_key = await _register(client, _UUID_S3, "Bob")
with patch("backend.telegram.send_message", new_callable=AsyncMock) as mock_send: with patch("backend.telegram.send_message", new_callable=AsyncMock) as mock_send:
await client.post( await client.post(
"/api/signal", "/api/signal",
json={"user_id": "adr-uuid-s3", "timestamp": 1742478000000, "geo": None}, json={"user_id": _UUID_S3, "timestamp": 1742478000000, "geo": None},
headers={"Authorization": f"Bearer {api_key}"},
) )
text = mock_send.call_args[0][0] text = mock_send.call_args[0][0]
assert "Без геолокации" in text assert "Без геолокации" in text
@ -114,15 +136,16 @@ async def test_signal_message_without_geo_contains_bez_geolocatsii():
async def test_signal_message_with_geo_contains_coordinates(): async def test_signal_message_with_geo_contains_coordinates():
"""When geo is provided, message must contain lat and lon values.""" """When geo is provided, message must contain lat and lon values."""
async with make_app_client() as client: async with make_app_client() as client:
await client.post("/api/register", json={"uuid": "adr-uuid-s4", "name": "Charlie"}) api_key = await _register(client, _UUID_S4, "Charlie")
with patch("backend.telegram.send_message", new_callable=AsyncMock) as mock_send: with patch("backend.telegram.send_message", new_callable=AsyncMock) as mock_send:
await client.post( await client.post(
"/api/signal", "/api/signal",
json={ json={
"user_id": "adr-uuid-s4", "user_id": _UUID_S4,
"timestamp": 1742478000000, "timestamp": 1742478000000,
"geo": {"lat": 55.7558, "lon": 37.6173, "accuracy": 15.0}, "geo": {"lat": 55.7558, "lon": 37.6173, "accuracy": 15.0},
}, },
headers={"Authorization": f"Bearer {api_key}"},
) )
text = mock_send.call_args[0][0] text = mock_send.call_args[0][0]
assert "55.7558" in text assert "55.7558" in text
@ -133,29 +156,17 @@ async def test_signal_message_with_geo_contains_coordinates():
async def test_signal_message_contains_utc_marker(): async def test_signal_message_contains_utc_marker():
"""Message passed to send_message must contain 'UTC' timestamp marker.""" """Message passed to send_message must contain 'UTC' timestamp marker."""
async with make_app_client() as client: async with make_app_client() as client:
await client.post("/api/register", json={"uuid": "adr-uuid-s5", "name": "Dave"}) api_key = await _register(client, _UUID_S5, "Dave")
with patch("backend.telegram.send_message", new_callable=AsyncMock) as mock_send: with patch("backend.telegram.send_message", new_callable=AsyncMock) as mock_send:
await client.post( await client.post(
"/api/signal", "/api/signal",
json={"user_id": "adr-uuid-s5", "timestamp": 1742478000000}, json={"user_id": _UUID_S5, "timestamp": 1742478000000},
headers={"Authorization": f"Bearer {api_key}"},
) )
text = mock_send.call_args[0][0] text = mock_send.call_args[0][0]
assert "UTC" in text 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) # Criterion 3 — SignalAggregator preserved with '# v2.0 feature' marker (static)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -6,6 +6,9 @@ Acceptance criteria:
5 requests pass (200), 6th returns 429; counter resets after the 10-minute window. 5 requests pass (200), 6th returns 429; counter resets after the 10-minute window.
2. Token comparison is timing-safe: 2. Token comparison is timing-safe:
secrets.compare_digest is used in middleware.py (no == / != for token comparison). 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 from __future__ import annotations
@ -20,6 +23,7 @@ os.environ.setdefault("CHAT_ID", "-1001234567890")
os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret") os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret")
os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram") os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram")
os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000") os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000")
os.environ.setdefault("ADMIN_TOKEN", "test-admin-token")
import pytest import pytest
from tests.conftest import make_app_client from tests.conftest import make_app_client
@ -38,6 +42,24 @@ _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 # Criterion 1 — Rate limiting: first 5 requests pass
@ -51,7 +73,7 @@ async def test_register_rate_limit_allows_five_requests():
for i in range(5): for i in range(5):
resp = await client.post( resp = await client.post(
"/api/register", "/api/register",
json={"uuid": f"rl-ok-{i:03d}", "name": f"User{i}"}, json={"uuid": _UUIDS_OK[i], "name": f"User{i}"},
) )
assert resp.status_code == 200, ( assert resp.status_code == 200, (
f"Request {i + 1}/5 unexpectedly returned {resp.status_code}" f"Request {i + 1}/5 unexpectedly returned {resp.status_code}"
@ -70,11 +92,11 @@ async def test_register_rate_limit_blocks_sixth_request():
for i in range(5): for i in range(5):
await client.post( await client.post(
"/api/register", "/api/register",
json={"uuid": f"rl-blk-{i:03d}", "name": f"User{i}"}, json={"uuid": _UUIDS_BLK[i], "name": f"User{i}"},
) )
resp = await client.post( resp = await client.post(
"/api/register", "/api/register",
json={"uuid": "rl-blk-999", "name": "Attacker"}, json={"uuid": _UUID_BLK_999, "name": "Attacker"},
) )
assert resp.status_code == 429 assert resp.status_code == 429
@ -94,13 +116,13 @@ async def test_register_rate_limit_resets_after_window_expires():
for i in range(5): for i in range(5):
await client.post( await client.post(
"/api/register", "/api/register",
json={"uuid": f"rl-exp-{i:03d}", "name": f"User{i}"}, json={"uuid": _UUIDS_EXP[i], "name": f"User{i}"},
) )
# Verify the 6th is blocked before window expiry # Verify the 6th is blocked before window expiry
blocked = await client.post( blocked = await client.post(
"/api/register", "/api/register",
json={"uuid": "rl-exp-blk", "name": "Attacker"}, json={"uuid": _UUID_EXP_BLK, "name": "Attacker"},
) )
assert blocked.status_code == 429, ( assert blocked.status_code == 429, (
"Expected 429 after exhausting rate limit, got " + str(blocked.status_code) "Expected 429 after exhausting rate limit, got " + str(blocked.status_code)
@ -110,7 +132,7 @@ async def test_register_rate_limit_resets_after_window_expires():
with patch("time.time", return_value=base_time + 601): with patch("time.time", return_value=base_time + 601):
resp_after = await client.post( resp_after = await client.post(
"/api/register", "/api/register",
json={"uuid": "rl-exp-after", "name": "Legit"}, json={"uuid": _UUID_EXP_AFTER, "name": "Legit"},
) )
assert resp_after.status_code == 200, ( assert resp_after.status_code == 200, (

View file

@ -9,6 +9,10 @@ Acceptance criteria:
5. Удаление пользователь исчезает из GET /admin/users, возвращается 204 5. Удаление пользователь исчезает из GET /admin/users, возвращается 204
6. Защита: неавторизованный запрос к /admin/* возвращает 401 6. Защита: неавторизованный запрос к /admin/* возвращает 401
7. Отсутствие регрессии с основным функционалом 7. Отсутствие регрессии с основным функционалом
BATON-SEC-003: POST /api/signal now requires Authorization: Bearer <api_key>.
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 from __future__ import annotations
@ -33,6 +37,11 @@ NGINX_CONF = PROJECT_ROOT / "nginx" / "baton.conf"
ADMIN_HEADERS = {"Authorization": "Bearer test-admin-token"} ADMIN_HEADERS = {"Authorization": "Bearer test-admin-token"}
WRONG_HEADERS = {"Authorization": "Bearer wrong-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 # Criterion 6 — Unauthorised requests to /admin/* return 401
@ -250,23 +259,32 @@ async def test_admin_block_user_returns_is_blocked_true() -> None:
async def test_admin_block_user_prevents_signal() -> None: async def test_admin_block_user_prevents_signal() -> None:
"""Заблокированный пользователь не может отправить сигнал — /api/signal возвращает 403.""" """Заблокированный пользователь не может отправить сигнал — /api/signal возвращает 403."""
async with make_app_client() as client: async with make_app_client() as client:
create_resp = await client.post( # Регистрируем через /api/register чтобы получить api_key
"/admin/users", reg_resp = await client.post(
json={"uuid": "block-uuid-002", "name": "BlockSignalUser"}, "/api/register",
headers=ADMIN_HEADERS, json={"uuid": _UUID_BLOCK, "name": "BlockSignalUser"},
) )
user_id = create_resp.json()["id"] assert reg_resp.status_code == 200
user_uuid = create_resp.json()["uuid"] 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"]
# Блокируем
await client.put( await client.put(
f"/admin/users/{user_id}/block", f"/admin/users/{user_id}/block",
json={"is_blocked": True}, json={"is_blocked": True},
headers=ADMIN_HEADERS, headers=ADMIN_HEADERS,
) )
# Заблокированный пользователь должен получить 403
signal_resp = await client.post( signal_resp = await client.post(
"/api/signal", "/api/signal",
json={"user_id": user_uuid, "timestamp": 1700000000000, "geo": None}, json={"user_id": user_uuid, "timestamp": 1700000000000, "geo": None},
headers={"Authorization": f"Bearer {api_key}"},
) )
assert signal_resp.status_code == 403 assert signal_resp.status_code == 403
@ -318,13 +336,19 @@ async def test_admin_unblock_user_returns_is_blocked_false() -> None:
async def test_admin_unblock_user_restores_signal_access() -> None: async def test_admin_unblock_user_restores_signal_access() -> None:
"""После разблокировки пользователь снова может отправить сигнал (200).""" """После разблокировки пользователь снова может отправить сигнал (200)."""
async with make_app_client() as client: async with make_app_client() as client:
create_resp = await client.post( # Регистрируем через /api/register чтобы получить api_key
"/admin/users", reg_resp = await client.post(
json={"uuid": "unblock-uuid-002", "name": "UnblockSignalUser"}, "/api/register",
headers=ADMIN_HEADERS, json={"uuid": _UUID_UNBLOCK, "name": "UnblockSignalUser"},
) )
user_id = create_resp.json()["id"] assert reg_resp.status_code == 200
user_uuid = create_resp.json()["uuid"] 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"]
# Блокируем # Блокируем
await client.put( await client.put(
@ -344,6 +368,7 @@ async def test_admin_unblock_user_restores_signal_access() -> None:
signal_resp = await client.post( signal_resp = await client.post(
"/api/signal", "/api/signal",
json={"user_id": user_uuid, "timestamp": 1700000000000, "geo": None}, json={"user_id": user_uuid, "timestamp": 1700000000000, "geo": None},
headers={"Authorization": f"Bearer {api_key}"},
) )
assert signal_resp.status_code == 200 assert signal_resp.status_code == 200
assert signal_resp.json()["status"] == "ok" assert signal_resp.json()["status"] == "ok"
@ -462,26 +487,28 @@ async def test_register_not_broken_after_admin_operations() -> None:
# Основной функционал # Основной функционал
resp = await client.post( resp = await client.post(
"/api/register", "/api/register",
json={"uuid": "regress-user-uuid-001", "name": "RegularUser"}, json={"uuid": _UUID_SIG_OK, "name": "RegularUser"},
) )
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["uuid"] == "regress-user-uuid-001" assert resp.json()["uuid"] == _UUID_SIG_OK
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_signal_from_unblocked_user_succeeds() -> None: async def test_signal_from_registered_unblocked_user_succeeds() -> None:
"""Незаблокированный пользователь, созданный через admin API, может отправить сигнал.""" """Зарегистрированный незаблокированный пользователь может отправить сигнал."""
async with make_app_client() as client: async with make_app_client() as client:
create_resp = await client.post( reg_resp = await client.post(
"/admin/users", "/api/register",
json={"uuid": "regress-signal-uuid-001", "name": "SignalUser"}, json={"uuid": _UUID_SIG_OK, "name": "SignalUser"},
headers=ADMIN_HEADERS,
) )
user_uuid = create_resp.json()["uuid"] assert reg_resp.status_code == 200
api_key = reg_resp.json()["api_key"]
user_uuid = reg_resp.json()["uuid"]
signal_resp = await client.post( signal_resp = await client.post(
"/api/signal", "/api/signal",
json={"user_id": user_uuid, "timestamp": 1700000000000, "geo": None}, json={"user_id": user_uuid, "timestamp": 1700000000000, "geo": None},
headers={"Authorization": f"Bearer {api_key}"},
) )
assert signal_resp.status_code == 200 assert signal_resp.status_code == 200
assert signal_resp.json()["status"] == "ok" assert signal_resp.json()["status"] == "ok"

View file

@ -11,6 +11,9 @@ Acceptance criteria:
6. POST /api/register возвращает 200 (FastAPI-маршрут не сломан). 6. POST /api/register возвращает 200 (FastAPI-маршрут не сломан).
7. POST /api/signal возвращает 200 (FastAPI-маршрут не сломан). 7. POST /api/signal возвращает 200 (FastAPI-маршрут не сломан).
8. POST /api/webhook/telegram возвращает 200 с корректным секретом. 8. POST /api/webhook/telegram возвращает 200 с корректным секретом.
BATON-SEC-003: POST /api/signal now requires Authorization: Bearer <api_key>.
UUID constants satisfy the UUID v4 pattern.
""" """
from __future__ import annotations from __future__ import annotations
@ -23,6 +26,7 @@ os.environ.setdefault("CHAT_ID", "-1001234567890")
os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret") os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret")
os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram") os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram")
os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000") os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000")
os.environ.setdefault("ADMIN_TOKEN", "test-admin-token")
import pytest import pytest
@ -31,6 +35,10 @@ from tests.conftest import make_app_client
PROJECT_ROOT = Path(__file__).parent.parent PROJECT_ROOT = Path(__file__).parent.parent
NGINX_CONF = PROJECT_ROOT / "nginx" / "baton.conf" 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 # Criterion 1 — location /api/ proxies to FastAPI
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -52,7 +60,6 @@ def test_nginx_conf_has_api_location_block() -> None:
def test_nginx_conf_api_location_proxies_to_fastapi() -> None: def test_nginx_conf_api_location_proxies_to_fastapi() -> None:
"""Блок location /api/ должен делать proxy_pass на 127.0.0.1:8000.""" """Блок location /api/ должен делать proxy_pass на 127.0.0.1:8000."""
content = NGINX_CONF.read_text(encoding="utf-8") content = NGINX_CONF.read_text(encoding="utf-8")
# Ищем блок api и proxy_pass внутри
api_block = re.search( api_block = re.search(
r"location\s+/api/\s*\{([^}]+)\}", content, re.DOTALL r"location\s+/api/\s*\{([^}]+)\}", content, re.DOTALL
) )
@ -95,7 +102,6 @@ def test_nginx_conf_health_location_proxies_to_fastapi() -> None:
def test_nginx_conf_root_location_has_root_directive() -> None: def test_nginx_conf_root_location_has_root_directive() -> None:
"""location / в nginx.conf должен содержать директиву root (статика).""" """location / в nginx.conf должен содержать директиву root (статика)."""
content = NGINX_CONF.read_text(encoding="utf-8") content = NGINX_CONF.read_text(encoding="utf-8")
# Ищем последний блок location / (не /api/, не /health)
root_block = re.search( root_block = re.search(
r"location\s+/\s*\{([^}]+)\}", content, re.DOTALL r"location\s+/\s*\{([^}]+)\}", content, re.DOTALL
) )
@ -179,13 +185,14 @@ async def test_api_register_not_broken_after_nginx_change() -> None:
async with make_app_client() as client: async with make_app_client() as client:
response = await client.post( response = await client.post(
"/api/register", "/api/register",
json={"uuid": "baton-006-uuid-001", "name": "TestUser"}, json={"uuid": _UUID_REG, "name": "TestUser"},
) )
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["user_id"] > 0 assert data["user_id"] > 0
assert data["uuid"] == "baton-006-uuid-001" assert data["uuid"] == _UUID_REG
assert "api_key" in data
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -197,19 +204,21 @@ async def test_api_register_not_broken_after_nginx_change() -> None:
async def test_api_signal_not_broken_after_nginx_change() -> None: async def test_api_signal_not_broken_after_nginx_change() -> None:
"""POST /api/signal должен вернуть 200 — функция не сломана изменением nginx.""" """POST /api/signal должен вернуть 200 — функция не сломана изменением nginx."""
async with make_app_client() as client: async with make_app_client() as client:
# Сначала регистрируем пользователя reg_resp = await client.post(
await client.post(
"/api/register", "/api/register",
json={"uuid": "baton-006-uuid-002", "name": "SignalUser"}, json={"uuid": _UUID_SIG, "name": "SignalUser"},
) )
# Отправляем сигнал assert reg_resp.status_code == 200
api_key = reg_resp.json()["api_key"]
response = await client.post( response = await client.post(
"/api/signal", "/api/signal",
json={ json={
"user_id": "baton-006-uuid-002", "user_id": _UUID_SIG,
"timestamp": 1700000000000, "timestamp": 1700000000000,
"geo": None, "geo": None,
}, },
headers={"Authorization": f"Bearer {api_key}"},
) )
assert response.status_code == 200 assert response.status_code == 200

262
tests/test_baton_007.py Normal file
View file

@ -0,0 +1,262 @@
"""
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"
)

View file

@ -46,11 +46,11 @@ def test_register_request_empty_uuid():
def test_register_request_name_max_length(): def test_register_request_name_max_length():
"""name longer than 100 chars raises ValidationError.""" """name longer than 100 chars raises ValidationError."""
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
RegisterRequest(uuid="some-uuid", name="x" * 101) RegisterRequest(uuid="550e8400-e29b-41d4-a716-446655440000", name="x" * 101)
def test_register_request_name_exactly_100(): def test_register_request_name_exactly_100():
req = RegisterRequest(uuid="some-uuid", name="x" * 100) req = RegisterRequest(uuid="550e8400-e29b-41d4-a716-446655440000", name="x" * 100)
assert len(req.name) == 100 assert len(req.name) == 100
@ -116,7 +116,7 @@ def test_signal_request_valid():
def test_signal_request_no_geo(): def test_signal_request_no_geo():
req = SignalRequest( req = SignalRequest(
user_id="some-uuid", user_id="550e8400-e29b-41d4-a716-446655440000",
timestamp=1742478000000, timestamp=1742478000000,
geo=None, geo=None,
) )
@ -136,9 +136,9 @@ def test_signal_request_empty_user_id():
def test_signal_request_timestamp_zero(): def test_signal_request_timestamp_zero():
"""timestamp must be > 0.""" """timestamp must be > 0."""
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
SignalRequest(user_id="some-uuid", timestamp=0) SignalRequest(user_id="550e8400-e29b-41d4-a716-446655440000", timestamp=0)
def test_signal_request_timestamp_negative(): def test_signal_request_timestamp_negative():
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
SignalRequest(user_id="some-uuid", timestamp=-1) SignalRequest(user_id="550e8400-e29b-41d4-a716-446655440000", timestamp=-1)

View file

@ -1,5 +1,11 @@
""" """
Integration tests for POST /api/register. 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 from __future__ import annotations
@ -10,23 +16,34 @@ os.environ.setdefault("CHAT_ID", "-1001234567890")
os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret") os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret")
os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram") os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram")
os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000") os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000")
os.environ.setdefault("ADMIN_TOKEN", "test-admin-token")
import pytest import pytest
from tests.conftest import make_app_client 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 @pytest.mark.asyncio
async def test_register_new_user_success(): async def test_register_new_user_success():
"""POST /api/register returns 200 with user_id > 0.""" """POST /api/register returns 200 with user_id > 0 and api_key."""
async with make_app_client() as client: async with make_app_client() as client:
resp = await client.post( resp = await client.post(
"/api/register", "/api/register",
json={"uuid": "reg-uuid-001", "name": "Alice"}, json={"uuid": _UUID_REG_1, "name": "Alice"},
) )
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() data = resp.json()
assert data["user_id"] > 0 assert data["user_id"] > 0
assert data["uuid"] == "reg-uuid-001" assert data["uuid"] == _UUID_REG_1
assert "api_key" in data
assert len(data["api_key"]) == 64 # secrets.token_hex(32) = 64 hex chars
@pytest.mark.asyncio @pytest.mark.asyncio
@ -35,24 +52,42 @@ async def test_register_idempotent():
async with make_app_client() as client: async with make_app_client() as client:
r1 = await client.post( r1 = await client.post(
"/api/register", "/api/register",
json={"uuid": "reg-uuid-002", "name": "Bob"}, json={"uuid": _UUID_REG_2, "name": "Bob"},
) )
r2 = await client.post( r2 = await client.post(
"/api/register", "/api/register",
json={"uuid": "reg-uuid-002", "name": "Bob"}, json={"uuid": _UUID_REG_2, "name": "Bob"},
) )
assert r1.status_code == 200 assert r1.status_code == 200
assert r2.status_code == 200 assert r2.status_code == 200
assert r1.json()["user_id"] == r2.json()["user_id"] 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 @pytest.mark.asyncio
async def test_register_empty_name_returns_422(): async def test_register_empty_name_returns_422():
"""Empty name must fail validation with 422.""" """Empty name must fail validation with 422."""
async with make_app_client() as client: async with make_app_client() as client:
resp = await client.post( resp = await client.post(
"/api/register", "/api/register",
json={"uuid": "reg-uuid-003", "name": ""}, json={"uuid": _UUID_REG_4, "name": ""},
) )
assert resp.status_code == 422 assert resp.status_code == 422
@ -74,7 +109,18 @@ async def test_register_missing_name_returns_422():
async with make_app_client() as client: async with make_app_client() as client:
resp = await client.post( resp = await client.post(
"/api/register", "/api/register",
json={"uuid": "reg-uuid-004"}, 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"},
) )
assert resp.status_code == 422 assert resp.status_code == 422
@ -85,11 +131,11 @@ async def test_register_user_stored_in_db():
async with make_app_client() as client: async with make_app_client() as client:
r1 = await client.post( r1 = await client.post(
"/api/register", "/api/register",
json={"uuid": "reg-uuid-005", "name": "Dana"}, json={"uuid": _UUID_REG_5, "name": "Dana"},
) )
r2 = await client.post( r2 = await client.post(
"/api/register", "/api/register",
json={"uuid": "reg-uuid-005", "name": "Dana"}, json={"uuid": _UUID_REG_5, "name": "Dana"},
) )
assert r1.json()["user_id"] == r2.json()["user_id"] assert r1.json()["user_id"] == r2.json()["user_id"]
@ -100,6 +146,6 @@ async def test_register_response_contains_uuid():
async with make_app_client() as client: async with make_app_client() as client:
resp = await client.post( resp = await client.post(
"/api/register", "/api/register",
json={"uuid": "reg-uuid-006", "name": "Eve"}, json={"uuid": _UUID_REG_6, "name": "Eve"},
) )
assert resp.json()["uuid"] == "reg-uuid-006" assert resp.json()["uuid"] == _UUID_REG_6

View file

@ -7,6 +7,9 @@ Tests for BATON-SEC-002:
UUID notes: RegisterRequest.uuid and SignalRequest.user_id both require a valid 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}$). 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. All constants below satisfy this constraint.
BATON-SEC-003: POST /api/signal now requires Authorization: Bearer <api_key>.
_register_and_get_key() helper returns the api_key from the registration response.
""" """
from __future__ import annotations from __future__ import annotations
@ -40,6 +43,13 @@ _UUID_IP_B = "a0000006-0000-4000-8000-000000000006" # per-IP isolation, user
# ── Helpers ───────────────────────────────────────────────────────────────── # ── 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: 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.""" """Build a minimal Starlette Request with given headers and remote address."""
scope = { scope = {
@ -120,10 +130,10 @@ def test_get_client_ip_returns_unknown_when_no_client_and_no_headers():
async def test_signal_rate_limit_returns_429_after_10_requests(): async def test_signal_rate_limit_returns_429_after_10_requests():
"""POST /api/signal returns 429 on the 11th request from the same IP.""" """POST /api/signal returns 429 on the 11th request from the same IP."""
async with make_app_client() as client: async with make_app_client() as client:
await client.post("/api/register", json={"uuid": _UUID_SIG_RL, "name": "RL"}) api_key = await _register_and_get_key(client, _UUID_SIG_RL, "RL")
payload = {"user_id": _UUID_SIG_RL, "timestamp": 1742478000000} payload = {"user_id": _UUID_SIG_RL, "timestamp": 1742478000000}
ip_hdrs = {"X-Real-IP": "5.5.5.5"} ip_hdrs = {"X-Real-IP": "5.5.5.5", "Authorization": f"Bearer {api_key}"}
statuses = [] statuses = []
for _ in range(11): for _ in range(11):
@ -137,10 +147,10 @@ async def test_signal_rate_limit_returns_429_after_10_requests():
async def test_signal_first_10_requests_are_allowed(): async def test_signal_first_10_requests_are_allowed():
"""First 10 POST /api/signal requests from the same IP must all return 200.""" """First 10 POST /api/signal requests from the same IP must all return 200."""
async with make_app_client() as client: async with make_app_client() as client:
await client.post("/api/register", json={"uuid": _UUID_SIG_OK, "name": "OK"}) api_key = await _register_and_get_key(client, _UUID_SIG_OK, "OK")
payload = {"user_id": _UUID_SIG_OK, "timestamp": 1742478000000} payload = {"user_id": _UUID_SIG_OK, "timestamp": 1742478000000}
ip_hdrs = {"X-Real-IP": "6.6.6.6"} ip_hdrs = {"X-Real-IP": "6.6.6.6", "Authorization": f"Bearer {api_key}"}
statuses = [] statuses = []
for _ in range(10): for _ in range(10):
@ -162,26 +172,28 @@ async def test_signal_rate_limit_does_not_affect_register_counter():
to return 429 the counters use different keys ('sig:IP' vs 'IP'). to return 429 the counters use different keys ('sig:IP' vs 'IP').
""" """
async with make_app_client() as client: async with make_app_client() as client:
ip_hdrs = {"X-Real-IP": "7.7.7.7"} ip_hdrs_reg = {"X-Real-IP": "7.7.7.7"}
# Register a user (increments register counter, key='7.7.7.7', count=1) # Register a user (increments register counter, key='7.7.7.7', count=1)
r_reg = await client.post( r_reg = await client.post(
"/api/register", "/api/register",
json={"uuid": _UUID_IND_SIG, "name": "Ind"}, json={"uuid": _UUID_IND_SIG, "name": "Ind"},
headers=ip_hdrs, headers=ip_hdrs_reg,
) )
assert r_reg.status_code == 200 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') # Exhaust signal rate limit for same IP (11 requests, key='sig:7.7.7.7')
payload = {"user_id": _UUID_IND_SIG, "timestamp": 1742478000000} 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): for _ in range(11):
await client.post("/api/signal", json=payload, headers=ip_hdrs) await client.post("/api/signal", json=payload, headers=ip_hdrs_sig)
# Register counter is still at 1 — must allow another registration # Register counter is still at 1 — must allow another registration
r_reg2 = await client.post( r_reg2 = await client.post(
"/api/register", "/api/register",
json={"uuid": _UUID_IND_SIG2, "name": "Ind2"}, json={"uuid": _UUID_IND_SIG2, "name": "Ind2"},
headers=ip_hdrs, headers=ip_hdrs_reg,
) )
assert r_reg2.status_code == 200, ( assert r_reg2.status_code == 200, (
@ -206,10 +218,21 @@ async def test_register_rate_limit_does_not_affect_signal_counter():
headers=ip_hdrs, headers=ip_hdrs,
) )
assert r0.status_code == 200 assert r0.status_code == 200
api_key = r0.json()["api_key"]
# Send 5 more register requests from the same IP to exhaust the limit # Send 4 more register requests from the same IP (requests 2-5 succeed,
# (register limit = 5/600s, so request #6 → 429) # each rotates the api_key; request 6 would be 429).
for _ in range(5): # 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( await client.post(
"/api/register", "/api/register",
json={"uuid": _UUID_IND_REG, "name": "Reg"}, json={"uuid": _UUID_IND_REG, "name": "Reg"},
@ -220,7 +243,7 @@ async def test_register_rate_limit_does_not_affect_signal_counter():
r_sig = await client.post( r_sig = await client.post(
"/api/signal", "/api/signal",
json={"user_id": _UUID_IND_REG, "timestamp": 1742478000000}, json={"user_id": _UUID_IND_REG, "timestamp": 1742478000000},
headers=ip_hdrs, headers={"X-Real-IP": "8.8.8.8", "Authorization": f"Bearer {api_key}"},
) )
assert r_sig.status_code == 200, ( assert r_sig.status_code == 200, (
@ -238,22 +261,22 @@ 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. Rate limit counters are per-IP exhausting for IP A must not block IP B.
""" """
async with make_app_client() as client: async with make_app_client() as client:
await client.post("/api/register", json={"uuid": _UUID_IP_A, "name": "IPA"}) api_key_a = await _register_and_get_key(client, _UUID_IP_A, "IPA")
await client.post("/api/register", json={"uuid": _UUID_IP_B, "name": "IPB"}) api_key_b = await _register_and_get_key(client, _UUID_IP_B, "IPB")
# Exhaust rate limit for IP A (11 requests → 11th is 429) # Exhaust rate limit for IP A (11 requests → 11th is 429)
for _ in range(11): for _ in range(11):
await client.post( await client.post(
"/api/signal", "/api/signal",
json={"user_id": _UUID_IP_A, "timestamp": 1742478000000}, json={"user_id": _UUID_IP_A, "timestamp": 1742478000000},
headers={"X-Real-IP": "11.11.11.11"}, headers={"X-Real-IP": "11.11.11.11", "Authorization": f"Bearer {api_key_a}"},
) )
# IP B should still be allowed (independent counter) # IP B should still be allowed (independent counter)
r = await client.post( r = await client.post(
"/api/signal", "/api/signal",
json={"user_id": _UUID_IP_B, "timestamp": 1742478000000}, json={"user_id": _UUID_IP_B, "timestamp": 1742478000000},
headers={"X-Real-IP": "22.22.22.22"}, 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}" assert r.status_code == 200, f"IP B was incorrectly blocked: {r.status_code}"

298
tests/test_sec_003.py Normal file
View file

@ -0,0 +1,298 @@
"""
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, "В БД не должен храниться сырой ключ"

337
tests/test_sec_006.py Normal file
View file

@ -0,0 +1,337 @@
"""
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}"
)

View file

@ -6,6 +6,9 @@ Regression tests for BATON-SEC-007:
3. POST /api/signal uses asyncio.create_task HTTP response is not blocked 3. POST /api/signal uses asyncio.create_task HTTP response is not blocked
by Telegram rate-limit pauses. by Telegram rate-limit pauses.
4. GET /health returns only {"status": "ok"} no timestamp field. 4. GET /health returns only {"status": "ok"} no timestamp field.
BATON-SEC-003: POST /api/signal now requires Authorization: Bearer <api_key>.
Tests that send signals now register first and use the returned api_key.
""" """
from __future__ import annotations from __future__ import annotations
@ -18,6 +21,7 @@ os.environ.setdefault("CHAT_ID", "-1001234567890")
os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret") os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret")
os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram") os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram")
os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000") os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000")
os.environ.setdefault("ADMIN_TOKEN", "test-admin-token")
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
@ -31,6 +35,10 @@ from tests.conftest import make_app_client
SEND_URL = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage" 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 # Criterion 1 — retry loop is bounded to max 3 attempts
@ -164,10 +172,13 @@ async def test_signal_uses_create_task_for_telegram_send_message():
"""POST /api/signal must wrap telegram.send_message in asyncio.create_task.""" """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: with patch("backend.main.asyncio.create_task", wraps=asyncio.create_task) as mock_ct:
async with make_app_client() as client: async with make_app_client() as client:
await client.post("/api/register", json={"uuid": "d4e5f6a7-b8c9-4d0e-a1b2-c3d4e5f6a7b8", "name": "CT"}) 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( resp = await client.post(
"/api/signal", "/api/signal",
json={"user_id": "d4e5f6a7-b8c9-4d0e-a1b2-c3d4e5f6a7b8", "timestamp": 1742478000000}, json={"user_id": _UUID_CT, "timestamp": 1742478000000},
headers={"Authorization": f"Bearer {api_key}"},
) )
assert resp.status_code == 200 assert resp.status_code == 200
@ -177,8 +188,6 @@ async def test_signal_uses_create_task_for_telegram_send_message():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_signal_response_returns_before_telegram_completes(): async def test_signal_response_returns_before_telegram_completes():
"""POST /api/signal returns 200 even when Telegram send_message is delayed.""" """POST /api/signal returns 200 even when Telegram send_message is delayed."""
# Simulate a slow Telegram response. If send_message is awaited directly,
# the HTTP response would be delayed until sleep completes.
slow_sleep_called = False slow_sleep_called = False
async def slow_send_message(_text: str) -> None: async def slow_send_message(_text: str) -> None:
@ -189,19 +198,21 @@ async def test_signal_response_returns_before_telegram_completes():
with patch("backend.main.telegram.send_message", side_effect=slow_send_message): with patch("backend.main.telegram.send_message", side_effect=slow_send_message):
with patch("backend.main.asyncio.create_task", wraps=asyncio.create_task): with patch("backend.main.asyncio.create_task", wraps=asyncio.create_task):
async with make_app_client() as client: async with make_app_client() as client:
await client.post( reg = await client.post(
"/api/register", "/api/register",
json={"uuid": "e5f6a7b8-c9d0-4e1f-a2b3-c4d5e6f7a8b9", "name": "Slow"}, json={"uuid": _UUID_SLOW, "name": "Slow"},
) )
assert reg.status_code == 200
api_key = reg.json()["api_key"]
resp = await client.post( resp = await client.post(
"/api/signal", "/api/signal",
json={ json={
"user_id": "e5f6a7b8-c9d0-4e1f-a2b3-c4d5e6f7a8b9", "user_id": _UUID_SLOW,
"timestamp": 1742478000000, "timestamp": 1742478000000,
}, },
headers={"Authorization": f"Bearer {api_key}"},
) )
# Response must be immediate — no blocking on the 9999-second sleep
assert resp.status_code == 200 assert resp.status_code == 200

View file

@ -1,5 +1,11 @@
""" """
Integration tests for POST /api/signal. 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 <api_key>.
The _register() helper returns the api_key from the registration response.
""" """
from __future__ import annotations from __future__ import annotations
@ -10,30 +16,42 @@ os.environ.setdefault("CHAT_ID", "-1001234567890")
os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret") os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret")
os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram") os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram")
os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000") os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000")
os.environ.setdefault("ADMIN_TOKEN", "test-admin-token")
import pytest import pytest
from httpx import AsyncClient from httpx import AsyncClient
from tests.conftest import make_app_client 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) -> None:
async def _register(client: AsyncClient, uuid: str, name: str) -> str:
"""Register user, assert success, return raw api_key."""
r = await client.post("/api/register", json={"uuid": uuid, "name": name}) r = await client.post("/api/register", json={"uuid": uuid, "name": name})
assert r.status_code == 200 assert r.status_code == 200, f"Registration failed: {r.status_code} {r.text}"
return r.json()["api_key"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_signal_with_geo_success(): async def test_signal_with_geo_success():
"""POST /api/signal with geo returns 200 and signal_id > 0.""" """POST /api/signal with geo returns 200 and signal_id > 0."""
async with make_app_client() as client: async with make_app_client() as client:
await _register(client, "sig-uuid-001", "Alice") api_key = await _register(client, _UUID_1, "Alice")
resp = await client.post( resp = await client.post(
"/api/signal", "/api/signal",
json={ json={
"user_id": "sig-uuid-001", "user_id": _UUID_1,
"timestamp": 1742478000000, "timestamp": 1742478000000,
"geo": {"lat": 55.7558, "lon": 37.6173, "accuracy": 15.0}, "geo": {"lat": 55.7558, "lon": 37.6173, "accuracy": 15.0},
}, },
headers={"Authorization": f"Bearer {api_key}"},
) )
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() data = resp.json()
@ -45,14 +63,15 @@ async def test_signal_with_geo_success():
async def test_signal_without_geo_success(): async def test_signal_without_geo_success():
"""POST /api/signal with geo: null returns 200.""" """POST /api/signal with geo: null returns 200."""
async with make_app_client() as client: async with make_app_client() as client:
await _register(client, "sig-uuid-002", "Bob") api_key = await _register(client, _UUID_2, "Bob")
resp = await client.post( resp = await client.post(
"/api/signal", "/api/signal",
json={ json={
"user_id": "sig-uuid-002", "user_id": _UUID_2,
"timestamp": 1742478000000, "timestamp": 1742478000000,
"geo": None, "geo": None,
}, },
headers={"Authorization": f"Bearer {api_key}"},
) )
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["status"] == "ok" assert resp.json()["status"] == "ok"
@ -75,7 +94,7 @@ async def test_signal_missing_timestamp_returns_422():
async with make_app_client() as client: async with make_app_client() as client:
resp = await client.post( resp = await client.post(
"/api/signal", "/api/signal",
json={"user_id": "sig-uuid-003"}, json={"user_id": _UUID_3},
) )
assert resp.status_code == 422 assert resp.status_code == 422
@ -87,14 +106,16 @@ async def test_signal_stored_in_db():
proving both were persisted. proving both were persisted.
""" """
async with make_app_client() as client: async with make_app_client() as client:
await _register(client, "sig-uuid-004", "Charlie") api_key = await _register(client, _UUID_4, "Charlie")
r1 = await client.post( r1 = await client.post(
"/api/signal", "/api/signal",
json={"user_id": "sig-uuid-004", "timestamp": 1742478000001}, json={"user_id": _UUID_4, "timestamp": 1742478000001},
headers={"Authorization": f"Bearer {api_key}"},
) )
r2 = await client.post( r2 = await client.post(
"/api/signal", "/api/signal",
json={"user_id": "sig-uuid-004", "timestamp": 1742478000002}, json={"user_id": _UUID_4, "timestamp": 1742478000002},
headers={"Authorization": f"Bearer {api_key}"},
) )
assert r1.status_code == 200 assert r1.status_code == 200
assert r2.status_code == 200 assert r2.status_code == 200
@ -111,11 +132,12 @@ async def test_signal_sends_telegram_message_directly():
send_url = f"https://api.telegram.org/bot{_cfg.BOT_TOKEN}/sendMessage" send_url = f"https://api.telegram.org/bot{_cfg.BOT_TOKEN}/sendMessage"
async with make_app_client() as client: async with make_app_client() as client:
await _register(client, "sig-uuid-005", "Dana") api_key = await _register(client, _UUID_5, "Dana")
# make_app_client already mocks send_url; signal returns 200 proves send was called # make_app_client already mocks send_url; signal returns 200 proves send was called
resp = await client.post( resp = await client.post(
"/api/signal", "/api/signal",
json={"user_id": "sig-uuid-005", "timestamp": 1742478000000}, json={"user_id": _UUID_5, "timestamp": 1742478000000},
headers={"Authorization": f"Bearer {api_key}"},
) )
assert resp.status_code == 200 assert resp.status_code == 200
@ -126,10 +148,11 @@ async def test_signal_sends_telegram_message_directly():
async def test_signal_returns_signal_id_positive(): async def test_signal_returns_signal_id_positive():
"""signal_id in response is always a positive integer.""" """signal_id in response is always a positive integer."""
async with make_app_client() as client: async with make_app_client() as client:
await _register(client, "sig-uuid-006", "Eve") api_key = await _register(client, _UUID_6, "Eve")
resp = await client.post( resp = await client.post(
"/api/signal", "/api/signal",
json={"user_id": "sig-uuid-006", "timestamp": 1742478000000}, json={"user_id": _UUID_6, "timestamp": 1742478000000},
headers={"Authorization": f"Bearer {api_key}"},
) )
assert resp.json()["signal_id"] > 0 assert resp.json()["signal_id"] > 0
@ -141,7 +164,7 @@ async def test_signal_geo_invalid_lat_returns_422():
resp = await client.post( resp = await client.post(
"/api/signal", "/api/signal",
json={ json={
"user_id": "sig-uuid-007", "user_id": _UUID_1,
"timestamp": 1742478000000, "timestamp": 1742478000000,
"geo": {"lat": 200.0, "lon": 0.0, "accuracy": 10.0}, "geo": {"lat": 200.0, "lon": 0.0, "accuracy": 10.0},
}, },

View file

@ -34,11 +34,57 @@ import pytest
import respx import respx
from backend import config from backend import config
from backend.telegram import SignalAggregator, send_message, set_webhook from backend.telegram import SignalAggregator, send_message, set_webhook, validate_bot_token
SEND_URL = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage" 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" 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
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -261,6 +307,71 @@ async def test_aggregator_buffer_cleared_after_flush():
_cleanup(path) _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 @pytest.mark.asyncio
async def test_aggregator_unknown_user_shows_uuid_prefix(): async def test_aggregator_unknown_user_shows_uuid_prefix():
"""If user_name is None, the message shows first 8 chars of uuid.""" """If user_name is None, the message shows first 8 chars of uuid."""