Compare commits
31 commits
main
...
BATON-007-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51f1943c55 | ||
|
|
8ee9782737 | ||
|
|
8279576ccd | ||
|
|
a9021cd5cc | ||
|
|
d2873bf9e0 | ||
|
|
2d66b1da58 | ||
|
|
abae67d75a | ||
|
|
3a2ec11cc7 | ||
|
|
46ed072cff | ||
|
|
c969825c80 | ||
|
|
2d7b99618c | ||
|
|
ee966dd148 | ||
|
|
8629f3e40b | ||
|
|
0a5ee35a4e | ||
|
|
1cdd1e15da | ||
|
|
fb4aa2dbeb | ||
|
|
e75dc2358a | ||
|
|
718379f79a | ||
|
|
63e99d87ef | ||
|
|
3483b71fcb | ||
|
|
a8d53fa47b | ||
|
|
12a63cd6cf | ||
|
|
3e8e83481c | ||
|
|
fac6a0976d | ||
|
|
bd37560ef5 | ||
|
|
98063595f8 | ||
|
|
75a41c56b8 | ||
|
|
3a54a1e5fa | ||
|
|
284529dabe | ||
|
|
ebb6e404e5 | ||
|
|
18d63ec867 |
29 changed files with 3664 additions and 135 deletions
|
|
@ -21,3 +21,4 @@ WEBHOOK_URL: str = _require("WEBHOOK_URL")
|
|||
WEBHOOK_ENABLED: bool = os.getenv("WEBHOOK_ENABLED", "true").lower() == "true"
|
||||
FRONTEND_ORIGIN: str = os.getenv("FRONTEND_ORIGIN", "http://localhost:3000")
|
||||
APP_URL: str | None = os.getenv("APP_URL") # Публичный URL приложения для keep-alive self-ping
|
||||
ADMIN_TOKEN: str = _require("ADMIN_TOKEN")
|
||||
|
|
|
|||
203
backend/db.py
203
backend/db.py
|
|
@ -1,27 +1,36 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncGenerator, Optional
|
||||
import aiosqlite
|
||||
|
||||
from backend import config
|
||||
|
||||
|
||||
async def _get_conn() -> aiosqlite.Connection:
|
||||
@asynccontextmanager
|
||||
async def _get_conn() -> AsyncGenerator[aiosqlite.Connection, None]:
|
||||
conn = await aiosqlite.connect(config.DB_PATH)
|
||||
await conn.execute("PRAGMA journal_mode=WAL")
|
||||
await conn.execute("PRAGMA busy_timeout=5000")
|
||||
await conn.execute("PRAGMA synchronous=NORMAL")
|
||||
conn.row_factory = aiosqlite.Row
|
||||
return conn
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
async with await _get_conn() as conn:
|
||||
async with _get_conn() as conn:
|
||||
await conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
is_blocked INTEGER NOT NULL DEFAULT 0,
|
||||
password_hash TEXT DEFAULT NULL,
|
||||
api_key_hash TEXT DEFAULT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
|
|
@ -52,12 +61,38 @@ async def init_db() -> None:
|
|||
ON signals(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_batches_status
|
||||
ON telegram_batches(status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rate_limits (
|
||||
ip TEXT NOT NULL PRIMARY KEY,
|
||||
count INTEGER NOT NULL DEFAULT 0,
|
||||
window_start REAL NOT NULL DEFAULT 0
|
||||
);
|
||||
""")
|
||||
# Migrations for existing databases (silently ignore if columns already exist)
|
||||
for stmt in [
|
||||
"ALTER TABLE users ADD COLUMN is_blocked INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE users ADD COLUMN password_hash TEXT DEFAULT NULL",
|
||||
"ALTER TABLE users ADD COLUMN api_key_hash TEXT DEFAULT NULL",
|
||||
]:
|
||||
try:
|
||||
await conn.execute(stmt)
|
||||
await conn.commit()
|
||||
except Exception:
|
||||
pass # Column already exists
|
||||
await conn.commit()
|
||||
|
||||
|
||||
async def register_user(uuid: str, name: str) -> dict:
|
||||
async with await _get_conn() as conn:
|
||||
async def register_user(uuid: str, name: str, api_key_hash: Optional[str] = None) -> dict:
|
||||
async with _get_conn() as conn:
|
||||
if api_key_hash is not None:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO users (uuid, name, api_key_hash) VALUES (?, ?, ?)
|
||||
ON CONFLICT(uuid) DO UPDATE SET api_key_hash = excluded.api_key_hash
|
||||
""",
|
||||
(uuid, name, api_key_hash),
|
||||
)
|
||||
else:
|
||||
await conn.execute(
|
||||
"INSERT OR IGNORE INTO users (uuid, name) VALUES (?, ?)",
|
||||
(uuid, name),
|
||||
|
|
@ -70,6 +105,15 @@ async def register_user(uuid: str, name: str) -> dict:
|
|||
return {"user_id": row["id"], "uuid": row["uuid"]}
|
||||
|
||||
|
||||
async def get_api_key_hash_by_uuid(uuid: str) -> Optional[str]:
|
||||
async with _get_conn() as conn:
|
||||
async with conn.execute(
|
||||
"SELECT api_key_hash FROM users WHERE uuid = ?", (uuid,)
|
||||
) as cur:
|
||||
row = await cur.fetchone()
|
||||
return row["api_key_hash"] if row else None
|
||||
|
||||
|
||||
async def save_signal(
|
||||
user_uuid: str,
|
||||
timestamp: int,
|
||||
|
|
@ -77,7 +121,7 @@ async def save_signal(
|
|||
lon: Optional[float],
|
||||
accuracy: Optional[float],
|
||||
) -> int:
|
||||
async with await _get_conn() as conn:
|
||||
async with _get_conn() as conn:
|
||||
async with conn.execute(
|
||||
"""
|
||||
INSERT INTO signals (user_uuid, timestamp, lat, lon, accuracy)
|
||||
|
|
@ -91,7 +135,7 @@ async def save_signal(
|
|||
|
||||
|
||||
async def get_user_name(uuid: str) -> Optional[str]:
|
||||
async with await _get_conn() as conn:
|
||||
async with _get_conn() as conn:
|
||||
async with conn.execute(
|
||||
"SELECT name FROM users WHERE uuid = ?", (uuid,)
|
||||
) as cur:
|
||||
|
|
@ -99,12 +143,153 @@ async def get_user_name(uuid: str) -> Optional[str]:
|
|||
return row["name"] if row else None
|
||||
|
||||
|
||||
async def is_user_blocked(uuid: str) -> bool:
|
||||
async with _get_conn() as conn:
|
||||
async with conn.execute(
|
||||
"SELECT is_blocked FROM users WHERE uuid = ?", (uuid,)
|
||||
) as cur:
|
||||
row = await cur.fetchone()
|
||||
return bool(row["is_blocked"]) if row else False
|
||||
|
||||
|
||||
async def admin_list_users() -> list[dict]:
|
||||
async with _get_conn() as conn:
|
||||
async with conn.execute(
|
||||
"SELECT id, uuid, name, is_blocked, created_at FROM users ORDER BY id"
|
||||
) as cur:
|
||||
rows = await cur.fetchall()
|
||||
return [
|
||||
{
|
||||
"id": row["id"],
|
||||
"uuid": row["uuid"],
|
||||
"name": row["name"],
|
||||
"is_blocked": bool(row["is_blocked"]),
|
||||
"created_at": row["created_at"],
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
async def admin_get_user_by_id(user_id: int) -> Optional[dict]:
|
||||
async with _get_conn() as conn:
|
||||
async with conn.execute(
|
||||
"SELECT id, uuid, name, is_blocked, created_at FROM users WHERE id = ?",
|
||||
(user_id,),
|
||||
) as cur:
|
||||
row = await cur.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return {
|
||||
"id": row["id"],
|
||||
"uuid": row["uuid"],
|
||||
"name": row["name"],
|
||||
"is_blocked": bool(row["is_blocked"]),
|
||||
"created_at": row["created_at"],
|
||||
}
|
||||
|
||||
|
||||
async def admin_create_user(
|
||||
uuid: str, name: str, password_hash: Optional[str] = None
|
||||
) -> Optional[dict]:
|
||||
"""Returns None if UUID already exists."""
|
||||
async with _get_conn() as conn:
|
||||
try:
|
||||
async with conn.execute(
|
||||
"INSERT INTO users (uuid, name, password_hash) VALUES (?, ?, ?)",
|
||||
(uuid, name, password_hash),
|
||||
) as cur:
|
||||
new_id = cur.lastrowid
|
||||
except Exception:
|
||||
return None # UNIQUE constraint violation — UUID already exists
|
||||
await conn.commit()
|
||||
async with conn.execute(
|
||||
"SELECT id, uuid, name, is_blocked, created_at FROM users WHERE id = ?",
|
||||
(new_id,),
|
||||
) as cur:
|
||||
row = await cur.fetchone()
|
||||
return {
|
||||
"id": row["id"],
|
||||
"uuid": row["uuid"],
|
||||
"name": row["name"],
|
||||
"is_blocked": bool(row["is_blocked"]),
|
||||
"created_at": row["created_at"],
|
||||
}
|
||||
|
||||
|
||||
async def admin_set_password(user_id: int, password_hash: str) -> bool:
|
||||
async with _get_conn() as conn:
|
||||
async with conn.execute(
|
||||
"UPDATE users SET password_hash = ? WHERE id = ?",
|
||||
(password_hash, user_id),
|
||||
) as cur:
|
||||
changed = cur.rowcount > 0
|
||||
await conn.commit()
|
||||
return changed
|
||||
|
||||
|
||||
async def admin_set_blocked(user_id: int, is_blocked: bool) -> bool:
|
||||
async with _get_conn() as conn:
|
||||
async with conn.execute(
|
||||
"UPDATE users SET is_blocked = ? WHERE id = ?",
|
||||
(1 if is_blocked else 0, user_id),
|
||||
) as cur:
|
||||
changed = cur.rowcount > 0
|
||||
await conn.commit()
|
||||
return changed
|
||||
|
||||
|
||||
async def admin_delete_user(user_id: int) -> bool:
|
||||
async with _get_conn() as conn:
|
||||
# Delete signals first (no FK cascade in SQLite by default)
|
||||
async with conn.execute(
|
||||
"DELETE FROM signals WHERE user_uuid = (SELECT uuid FROM users WHERE id = ?)",
|
||||
(user_id,),
|
||||
):
|
||||
pass
|
||||
async with conn.execute(
|
||||
"DELETE FROM users WHERE id = ?",
|
||||
(user_id,),
|
||||
) as cur:
|
||||
changed = cur.rowcount > 0
|
||||
await conn.commit()
|
||||
return changed
|
||||
|
||||
|
||||
async def rate_limit_increment(key: str, window: float) -> int:
|
||||
"""Increment rate-limit counter for key within window. Returns current count.
|
||||
|
||||
Cleans up the stale record for this key before incrementing (TTL by window_start).
|
||||
"""
|
||||
now = time.time()
|
||||
async with _get_conn() as conn:
|
||||
# TTL cleanup: remove stale record for this key if window has expired
|
||||
await conn.execute(
|
||||
"DELETE FROM rate_limits WHERE ip = ? AND ? - window_start >= ?",
|
||||
(key, now, window),
|
||||
)
|
||||
# Upsert: insert new record or increment existing
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO rate_limits (ip, count, window_start)
|
||||
VALUES (?, 1, ?)
|
||||
ON CONFLICT(ip) DO UPDATE SET count = count + 1
|
||||
""",
|
||||
(key, now),
|
||||
)
|
||||
await conn.commit()
|
||||
async with conn.execute(
|
||||
"SELECT count FROM rate_limits WHERE ip = ?", (key,)
|
||||
) as cur:
|
||||
row = await cur.fetchone()
|
||||
return row["count"] if row else 1
|
||||
|
||||
|
||||
async def save_telegram_batch(
|
||||
message_text: str,
|
||||
signals_count: int,
|
||||
signal_ids: list[int],
|
||||
) -> int:
|
||||
async with await _get_conn() as conn:
|
||||
async with _get_conn() as conn:
|
||||
async with conn.execute(
|
||||
"""
|
||||
INSERT INTO telegram_batches (message_text, sent_at, signals_count, status)
|
||||
|
|
|
|||
103
backend/main.py
103
backend/main.py
|
|
@ -1,29 +1,52 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import time
|
||||
import os
|
||||
import secrets
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import Depends, FastAPI, Request
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from backend import config, db, telegram
|
||||
from backend.middleware import rate_limit_register, verify_webhook_secret
|
||||
from backend.middleware import rate_limit_register, rate_limit_signal, verify_admin_token, verify_webhook_secret
|
||||
from backend.models import (
|
||||
AdminBlockRequest,
|
||||
AdminCreateUserRequest,
|
||||
AdminSetPasswordRequest,
|
||||
RegisterRequest,
|
||||
RegisterResponse,
|
||||
SignalRequest,
|
||||
SignalResponse,
|
||||
)
|
||||
|
||||
_api_key_bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _hash_api_key(key: str) -> str:
|
||||
"""SHA-256 хэш для API-ключа (без соли — для быстрого сравнения)."""
|
||||
return hashlib.sha256(key.encode()).hexdigest()
|
||||
|
||||
|
||||
def _hash_password(password: str) -> str:
|
||||
"""Hash a password using PBKDF2-HMAC-SHA256 (stdlib, no external deps).
|
||||
|
||||
Stored format: ``<salt_hex>:<dk_hex>``
|
||||
"""
|
||||
salt = os.urandom(16)
|
||||
dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 260_000)
|
||||
return f"{salt.hex()}:{dk.hex()}"
|
||||
|
||||
# aggregator = telegram.SignalAggregator(interval=10) # v2.0 feature — отключено в v1 (ADR-004)
|
||||
|
||||
_KEEPALIVE_INTERVAL = 600 # 10 минут
|
||||
|
|
@ -45,10 +68,14 @@ async def _keep_alive_loop(app_url: str) -> None:
|
|||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup
|
||||
app.state.rate_counters = {}
|
||||
await db.init_db()
|
||||
logger.info("Database initialized")
|
||||
|
||||
if not await telegram.validate_bot_token():
|
||||
logger.error(
|
||||
"CRITICAL: BOT_TOKEN is invalid — Telegram delivery is broken. Update .env and restart."
|
||||
)
|
||||
|
||||
if config.WEBHOOK_ENABLED:
|
||||
await telegram.set_webhook(url=config.WEBHOOK_URL, secret=config.WEBHOOK_SECRET)
|
||||
logger.info("Webhook registered")
|
||||
|
|
@ -91,23 +118,39 @@ app.add_middleware(
|
|||
CORSMiddleware,
|
||||
allow_origins=[config.FRONTEND_ORIGIN],
|
||||
allow_methods=["POST"],
|
||||
allow_headers=["Content-Type"],
|
||||
allow_headers=["Content-Type", "Authorization"],
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
@app.get("/api/health")
|
||||
async def health() -> dict[str, Any]:
|
||||
return {"status": "ok", "timestamp": int(time.time())}
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/api/register", response_model=RegisterResponse)
|
||||
async def register(body: RegisterRequest, _: None = Depends(rate_limit_register)) -> RegisterResponse:
|
||||
result = await db.register_user(uuid=body.uuid, name=body.name)
|
||||
return RegisterResponse(user_id=result["user_id"], uuid=result["uuid"])
|
||||
api_key = secrets.token_hex(32)
|
||||
result = await db.register_user(uuid=body.uuid, name=body.name, api_key_hash=_hash_api_key(api_key))
|
||||
return RegisterResponse(user_id=result["user_id"], uuid=result["uuid"], api_key=api_key)
|
||||
|
||||
|
||||
@app.post("/api/signal", response_model=SignalResponse)
|
||||
async def signal(body: SignalRequest) -> 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):
|
||||
raise HTTPException(status_code=403, detail="User is blocked")
|
||||
|
||||
geo = body.geo
|
||||
lat = geo.lat if geo else None
|
||||
lon = geo.lon if geo else None
|
||||
|
|
@ -134,11 +177,49 @@ async def signal(body: SignalRequest) -> SignalResponse:
|
|||
f"⏰ {ts.strftime('%H:%M:%S')} UTC\n"
|
||||
f"{geo_info}"
|
||||
)
|
||||
await telegram.send_message(text)
|
||||
asyncio.create_task(telegram.send_message(text))
|
||||
|
||||
return SignalResponse(status="ok", signal_id=signal_id)
|
||||
|
||||
|
||||
@app.get("/admin/users", dependencies=[Depends(verify_admin_token)])
|
||||
async def admin_list_users() -> list[dict]:
|
||||
return await db.admin_list_users()
|
||||
|
||||
|
||||
@app.post("/admin/users", status_code=201, dependencies=[Depends(verify_admin_token)])
|
||||
async def admin_create_user(body: AdminCreateUserRequest) -> dict:
|
||||
password_hash = _hash_password(body.password) if body.password else None
|
||||
result = await db.admin_create_user(body.uuid, body.name, password_hash)
|
||||
if result is None:
|
||||
raise HTTPException(status_code=409, detail="User with this UUID already exists")
|
||||
return result
|
||||
|
||||
|
||||
@app.put("/admin/users/{user_id}/password", dependencies=[Depends(verify_admin_token)])
|
||||
async def admin_set_password(user_id: int, body: AdminSetPasswordRequest) -> dict:
|
||||
changed = await db.admin_set_password(user_id, _hash_password(body.password))
|
||||
if not changed:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.put("/admin/users/{user_id}/block", dependencies=[Depends(verify_admin_token)])
|
||||
async def admin_block_user(user_id: int, body: AdminBlockRequest) -> dict:
|
||||
changed = await db.admin_set_blocked(user_id, body.is_blocked)
|
||||
if not changed:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
user = await db.admin_get_user_by_id(user_id)
|
||||
return user # type: ignore[return-value]
|
||||
|
||||
|
||||
@app.delete("/admin/users/{user_id}", status_code=204, dependencies=[Depends(verify_admin_token)])
|
||||
async def admin_delete_user(user_id: int) -> None:
|
||||
deleted = await db.admin_delete_user(user_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
|
||||
@app.post("/api/webhook/telegram")
|
||||
async def webhook_telegram(
|
||||
request: Request,
|
||||
|
|
|
|||
|
|
@ -1,15 +1,29 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Header, HTTPException, Request
|
||||
from fastapi import Depends, Header, HTTPException, Request
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from backend import config
|
||||
from backend import config, db
|
||||
|
||||
_bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
_RATE_LIMIT = 5
|
||||
_RATE_WINDOW = 600 # 10 minutes
|
||||
|
||||
_SIGNAL_RATE_LIMIT = 10
|
||||
_SIGNAL_RATE_WINDOW = 60 # 1 minute
|
||||
|
||||
|
||||
def _get_client_ip(request: Request) -> str:
|
||||
return (
|
||||
request.headers.get("X-Real-IP")
|
||||
or request.headers.get("X-Forwarded-For", "").split(",")[0].strip()
|
||||
or (request.client.host if request.client else "unknown")
|
||||
)
|
||||
|
||||
|
||||
async def verify_webhook_secret(
|
||||
x_telegram_bot_api_secret_token: str = Header(default=""),
|
||||
|
|
@ -20,15 +34,24 @@ async def verify_webhook_secret(
|
|||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
|
||||
async def verify_admin_token(
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(_bearer),
|
||||
) -> None:
|
||||
if credentials is None or not secrets.compare_digest(
|
||||
credentials.credentials, config.ADMIN_TOKEN
|
||||
):
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
|
||||
async def rate_limit_register(request: Request) -> None:
|
||||
counters = request.app.state.rate_counters
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
now = time.time()
|
||||
count, window_start = counters.get(client_ip, (0, now))
|
||||
if now - window_start >= _RATE_WINDOW:
|
||||
count = 0
|
||||
window_start = now
|
||||
count += 1
|
||||
counters[client_ip] = (count, window_start)
|
||||
key = f"reg:{_get_client_ip(request)}"
|
||||
count = await db.rate_limit_increment(key, _RATE_WINDOW)
|
||||
if count > _RATE_LIMIT:
|
||||
raise HTTPException(status_code=429, detail="Too Many Requests")
|
||||
|
||||
|
||||
async def rate_limit_signal(request: Request) -> None:
|
||||
key = f"sig:{_get_client_ip(request)}"
|
||||
count = await db.rate_limit_increment(key, _SIGNAL_RATE_WINDOW)
|
||||
if count > _SIGNAL_RATE_LIMIT:
|
||||
raise HTTPException(status_code=429, detail="Too Many Requests")
|
||||
|
|
|
|||
|
|
@ -5,13 +5,14 @@ from pydantic import BaseModel, Field
|
|||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
uuid: str = Field(..., min_length=1)
|
||||
uuid: str = Field(..., pattern=r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$')
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
|
||||
|
||||
class RegisterResponse(BaseModel):
|
||||
user_id: int
|
||||
uuid: str
|
||||
api_key: str
|
||||
|
||||
|
||||
class GeoData(BaseModel):
|
||||
|
|
@ -21,7 +22,7 @@ class GeoData(BaseModel):
|
|||
|
||||
|
||||
class SignalRequest(BaseModel):
|
||||
user_id: str = Field(..., min_length=1)
|
||||
user_id: str = Field(..., pattern=r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$')
|
||||
timestamp: int = Field(..., gt=0)
|
||||
geo: Optional[GeoData] = None
|
||||
|
||||
|
|
@ -29,3 +30,17 @@ class SignalRequest(BaseModel):
|
|||
class SignalResponse(BaseModel):
|
||||
status: str
|
||||
signal_id: int
|
||||
|
||||
|
||||
class AdminCreateUserRequest(BaseModel):
|
||||
uuid: str = Field(..., min_length=1)
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
password: Optional[str] = None
|
||||
|
||||
|
||||
class AdminSetPasswordRequest(BaseModel):
|
||||
password: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class AdminBlockRequest(BaseModel):
|
||||
is_blocked: bool
|
||||
|
|
|
|||
|
|
@ -14,27 +14,45 @@ logger = logging.getLogger(__name__)
|
|||
_TELEGRAM_API = "https://api.telegram.org/bot{token}/{method}"
|
||||
|
||||
|
||||
async def validate_bot_token() -> bool:
|
||||
"""Validate BOT_TOKEN by calling getMe. Logs ERROR if invalid. Never raises."""
|
||||
url = _TELEGRAM_API.format(token=config.BOT_TOKEN, method="getMe")
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
try:
|
||||
resp = await client.get(url)
|
||||
if resp.status_code == 200:
|
||||
bot_name = resp.json().get("result", {}).get("username", "?")
|
||||
logger.info("Telegram token valid, bot: @%s", bot_name)
|
||||
return True
|
||||
logger.error(
|
||||
"BOT_TOKEN invalid — getMe returned %s: %s", resp.status_code, resp.text
|
||||
)
|
||||
return False
|
||||
except Exception as exc:
|
||||
logger.error("BOT_TOKEN validation failed (network): %s", exc)
|
||||
return False
|
||||
|
||||
|
||||
async def send_message(text: str) -> None:
|
||||
url = _TELEGRAM_API.format(token=config.BOT_TOKEN, method="sendMessage")
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
while True:
|
||||
for attempt in range(3):
|
||||
resp = await client.post(url, json={"chat_id": config.CHAT_ID, "text": text})
|
||||
if resp.status_code == 429:
|
||||
retry_after = resp.json().get("parameters", {}).get("retry_after", 30)
|
||||
logger.warning("Telegram 429, sleeping %s sec", retry_after)
|
||||
await asyncio.sleep(retry_after)
|
||||
sleep = retry_after * (attempt + 1)
|
||||
logger.warning("Telegram 429, sleeping %s sec (attempt %d)", sleep, attempt + 1)
|
||||
await asyncio.sleep(sleep)
|
||||
continue
|
||||
if resp.status_code >= 500:
|
||||
logger.error("Telegram 5xx: %s", resp.text)
|
||||
await asyncio.sleep(30)
|
||||
resp2 = await client.post(
|
||||
url, json={"chat_id": config.CHAT_ID, "text": text}
|
||||
)
|
||||
if resp2.status_code != 200:
|
||||
logger.error("Telegram retry failed: %s", resp2.text)
|
||||
continue
|
||||
elif resp.status_code != 200:
|
||||
logger.error("Telegram error %s: %s", resp.status_code, resp.text)
|
||||
break
|
||||
else:
|
||||
logger.error("Telegram send_message: all 3 attempts failed, message dropped")
|
||||
|
||||
|
||||
async def set_webhook(url: str, secret: str) -> None:
|
||||
|
|
|
|||
|
|
@ -5,6 +5,6 @@ Description=Baton keep-alive ping
|
|||
[Service]
|
||||
Type=oneshot
|
||||
# Замените URL на реальный адрес вашего приложения
|
||||
ExecStart=curl -sf https://your-app.example.com/health
|
||||
ExecStart=curl -sf https://baton.itafrika.com/health
|
||||
StandardOutput=null
|
||||
StandardError=journal
|
||||
|
|
|
|||
18
deploy/baton.service
Normal file
18
deploy/baton.service
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
[Unit]
|
||||
Description=Baton — Telegram bot FastAPI backend
|
||||
After=network.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=www-data
|
||||
WorkingDirectory=/opt/baton
|
||||
EnvironmentFile=/opt/baton/.env
|
||||
ExecStart=/opt/baton/venv/bin/uvicorn backend.main:app --host 127.0.0.1 --port 8000
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
22
deploy/env.template
Normal file
22
deploy/env.template
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# /opt/baton/.env — заполнить перед деплоем
|
||||
# ВНИМАНИЕ: этот файл НЕ для git, только шаблон для ручного создания на сервере
|
||||
|
||||
# Telegram Bot — получить токен через @BotFather
|
||||
BOT_TOKEN=YOUR_BOT_TOKEN_HERE
|
||||
|
||||
# Chat ID для уведомлений (example: CHAT_ID=5190015988)
|
||||
CHAT_ID=
|
||||
|
||||
# Webhook secret — случайная строка 32+ символа (сгенерировать: openssl rand -hex 32)
|
||||
WEBHOOK_SECRET=
|
||||
|
||||
# Webhook URL
|
||||
WEBHOOK_URL=https://baton.itafrika.com/api/webhook/telegram
|
||||
|
||||
WEBHOOK_ENABLED=true
|
||||
FRONTEND_ORIGIN=https://baton.itafrika.com
|
||||
APP_URL=https://baton.itafrika.com
|
||||
DB_PATH=/opt/baton/baton.db
|
||||
|
||||
# Admin API token — случайная строка 32+ символа (сгенерировать: openssl rand -hex 32)
|
||||
ADMIN_TOKEN=
|
||||
379
frontend/admin.html
Normal file
379
frontend/admin.html
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Baton — Admin</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #000000;
|
||||
--bg2: #0d0d0d;
|
||||
--text: #ffffff;
|
||||
--muted: #9ca3af;
|
||||
--input-bg: #1a1a1a;
|
||||
--border: #374151;
|
||||
--border-focus: #6b7280;
|
||||
--btn-bg: #374151;
|
||||
--btn-hover: #4b5563;
|
||||
--danger: #991b1b;
|
||||
--danger-hover: #7f1d1d;
|
||||
--warn: #78350f;
|
||||
--warn-hover: #92400e;
|
||||
--success-bg: #14532d;
|
||||
--blocked-row: #1c1008;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
font-size: 14px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ===== Token screen ===== */
|
||||
|
||||
#screen-token {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 32px 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
margin-top: -8px;
|
||||
}
|
||||
|
||||
/* ===== Panel screen ===== */
|
||||
|
||||
#screen-panel { display: none; flex-direction: column; min-height: 100vh; }
|
||||
#screen-panel.active { display: flex; }
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* ===== Table ===== */
|
||||
|
||||
.users-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 600px;
|
||||
}
|
||||
|
||||
thead tr {
|
||||
background: var(--bg2);
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--muted);
|
||||
border-bottom: 1px solid var(--border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #1f2937;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
tr.is-blocked td { background: var(--blocked-row); }
|
||||
|
||||
tr:hover td { background: #111827; }
|
||||
tr.is-blocked:hover td { background: #231508; }
|
||||
|
||||
.col-id { width: 50px; color: var(--muted); }
|
||||
.col-uuid { max-width: 120px; font-family: monospace; font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.col-date { white-space: nowrap; color: var(--muted); font-size: 12px; }
|
||||
.col-actions { white-space: nowrap; }
|
||||
|
||||
.empty-row td {
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
/* ===== Badges ===== */
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 9999px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge--active { background: #14532d; color: #4ade80; }
|
||||
.badge--blocked { background: #7f1d1d; color: #fca5a5; }
|
||||
|
||||
/* ===== Inputs ===== */
|
||||
|
||||
input[type="text"],
|
||||
input[type="password"] {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
background: var(--input-bg);
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: 10px;
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
input::placeholder { color: var(--muted); }
|
||||
input:focus { border-color: var(--border-focus); }
|
||||
|
||||
/* ===== Buttons ===== */
|
||||
|
||||
.btn {
|
||||
padding: 10px 18px;
|
||||
background: var(--btn-bg);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) { background: var(--btn-hover); }
|
||||
.btn:disabled { opacity: 0.4; cursor: default; }
|
||||
.btn--full { width: 100%; }
|
||||
.btn--danger { background: var(--danger); }
|
||||
.btn--danger:hover:not(:disabled) { background: var(--danger-hover); }
|
||||
|
||||
/* Small inline buttons */
|
||||
.btn-sm {
|
||||
padding: 4px 10px;
|
||||
background: var(--btn-bg);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
white-space: nowrap;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.btn-sm:last-child { margin-right: 0; }
|
||||
.btn-sm:hover { background: var(--btn-hover); }
|
||||
.btn-sm--danger { background: var(--danger); }
|
||||
.btn-sm--danger:hover { background: var(--danger-hover); }
|
||||
.btn-sm--warn { background: var(--warn); }
|
||||
.btn-sm--warn:hover { background: var(--warn-hover); }
|
||||
|
||||
/* ===== Error / info messages ===== */
|
||||
|
||||
.msg-error {
|
||||
color: #f87171;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.msg-info {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
/* ===== Modals ===== */
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.modal-backdrop[hidden] { display: none; }
|
||||
|
||||
.modal-box {
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 28px 24px;
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.modal-subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin-top: -6px;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.modal-actions .btn { flex: 1; }
|
||||
|
||||
/* ===== Label ===== */
|
||||
|
||||
.field-label {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.field { display: flex; flex-direction: column; gap: 4px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ===== Token screen ===== -->
|
||||
<div id="screen-token">
|
||||
<div class="login-card">
|
||||
<h1 class="login-title">Baton Admin</h1>
|
||||
<p class="login-subtitle">Введите токен для доступа</p>
|
||||
<input type="password" id="token-input" placeholder="Admin token" autocomplete="current-password">
|
||||
<button type="button" id="btn-login" class="btn btn--full">Войти</button>
|
||||
<p id="login-error" class="msg-error" hidden></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Admin panel screen ===== -->
|
||||
<div id="screen-panel">
|
||||
<header class="panel-header">
|
||||
<h1 class="panel-title">Пользователи</h1>
|
||||
<button type="button" id="btn-create" class="btn">+ Создать</button>
|
||||
<button type="button" id="btn-logout" class="btn">Выйти</button>
|
||||
</header>
|
||||
|
||||
<div class="panel-body">
|
||||
<p id="panel-error" class="msg-error" hidden></p>
|
||||
|
||||
<div class="users-wrap">
|
||||
<table id="users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-id">#</th>
|
||||
<th>Имя</th>
|
||||
<th class="col-uuid">UUID</th>
|
||||
<th>Статус</th>
|
||||
<th class="col-date">Создан</th>
|
||||
<th class="col-actions">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="users-tbody">
|
||||
<tr class="empty-row"><td colspan="6">Загрузка…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Modal: change password ===== -->
|
||||
<div id="modal-password" class="modal-backdrop" hidden>
|
||||
<div class="modal-box">
|
||||
<h2 class="modal-title">Сменить пароль</h2>
|
||||
<p id="modal-pw-subtitle" class="modal-subtitle"></p>
|
||||
<input type="hidden" id="modal-pw-user-id">
|
||||
<div class="field">
|
||||
<div class="field-label">Новый пароль</div>
|
||||
<input type="password" id="new-password" placeholder="Минимум 1 символ" autocomplete="new-password">
|
||||
</div>
|
||||
<p id="modal-pw-error" class="msg-error" hidden></p>
|
||||
<div class="modal-actions">
|
||||
<button type="button" id="btn-pw-cancel" class="btn">Отмена</button>
|
||||
<button type="button" id="btn-pw-save" class="btn">Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Modal: create user ===== -->
|
||||
<div id="modal-create" class="modal-backdrop" hidden>
|
||||
<div class="modal-box">
|
||||
<h2 class="modal-title">Создать пользователя</h2>
|
||||
<div class="field">
|
||||
<div class="field-label">UUID</div>
|
||||
<input type="text" id="create-uuid" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="field-label">Имя</div>
|
||||
<input type="text" id="create-name" placeholder="Имя пользователя" autocomplete="off">
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="field-label">Пароль (необязательно)</div>
|
||||
<input type="password" id="create-password" placeholder="Оставьте пустым если не нужен" autocomplete="new-password">
|
||||
</div>
|
||||
<p id="create-error" class="msg-error" hidden></p>
|
||||
<div class="modal-actions">
|
||||
<button type="button" id="btn-create-cancel" class="btn">Отмена</button>
|
||||
<button type="button" id="btn-create-submit" class="btn">Создать</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
333
frontend/admin.js
Normal file
333
frontend/admin.js
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
'use strict';
|
||||
|
||||
// ========== Token (sessionStorage — cleared on browser close) ==========
|
||||
|
||||
function _getToken() {
|
||||
return sessionStorage.getItem('baton_admin_token') || '';
|
||||
}
|
||||
|
||||
function _saveToken(t) {
|
||||
sessionStorage.setItem('baton_admin_token', t);
|
||||
}
|
||||
|
||||
function _clearToken() {
|
||||
sessionStorage.removeItem('baton_admin_token');
|
||||
}
|
||||
|
||||
// ========== API wrapper ==========
|
||||
|
||||
async function _api(method, path, body) {
|
||||
const opts = {
|
||||
method,
|
||||
headers: { 'Authorization': 'Bearer ' + _getToken() },
|
||||
};
|
||||
if (body !== undefined) {
|
||||
opts.headers['Content-Type'] = 'application/json';
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
const res = await fetch(path, opts);
|
||||
|
||||
if (res.status === 204) return null;
|
||||
|
||||
const text = await res.text().catch(() => '');
|
||||
if (!res.ok) {
|
||||
let detail = text;
|
||||
try { detail = JSON.parse(text).detail || text; } catch (_) {}
|
||||
throw new Error('HTTP ' + res.status + (detail ? ': ' + detail : ''));
|
||||
}
|
||||
|
||||
try { return JSON.parse(text); } catch (_) { return null; }
|
||||
}
|
||||
|
||||
// ========== UI helpers ==========
|
||||
|
||||
function _esc(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function _setError(id, msg) {
|
||||
const el = document.getElementById(id);
|
||||
el.textContent = msg;
|
||||
el.hidden = !msg;
|
||||
}
|
||||
|
||||
function _showPanel() {
|
||||
document.getElementById('screen-token').style.display = 'none';
|
||||
document.getElementById('screen-panel').classList.add('active');
|
||||
}
|
||||
|
||||
function _showTokenScreen() {
|
||||
document.getElementById('screen-panel').classList.remove('active');
|
||||
document.getElementById('screen-token').style.display = '';
|
||||
document.getElementById('token-input').value = '';
|
||||
}
|
||||
|
||||
// ========== Users table ==========
|
||||
|
||||
function _renderTable(users) {
|
||||
const tbody = document.getElementById('users-tbody');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (!users.length) {
|
||||
const tr = document.createElement('tr');
|
||||
tr.className = 'empty-row';
|
||||
tr.innerHTML = '<td colspan="6">Нет пользователей</td>';
|
||||
tbody.appendChild(tr);
|
||||
return;
|
||||
}
|
||||
|
||||
users.forEach((u) => {
|
||||
const tr = document.createElement('tr');
|
||||
if (u.is_blocked) tr.classList.add('is-blocked');
|
||||
|
||||
const date = u.created_at ? u.created_at.slice(0, 16).replace('T', ' ') : '—';
|
||||
const uuidShort = u.uuid ? u.uuid.slice(0, 8) + '…' : '—';
|
||||
|
||||
tr.innerHTML = `
|
||||
<td class="col-id">${u.id}</td>
|
||||
<td>${_esc(u.name)}</td>
|
||||
<td class="col-uuid" title="${_esc(u.uuid)}">${_esc(uuidShort)}</td>
|
||||
<td>
|
||||
<span class="badge ${u.is_blocked ? 'badge--blocked' : 'badge--active'}">
|
||||
${u.is_blocked ? 'Заблокирован' : 'Активен'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="col-date">${_esc(date)}</td>
|
||||
<td class="col-actions">
|
||||
<button class="btn-sm"
|
||||
data-action="password"
|
||||
data-id="${u.id}"
|
||||
data-name="${_esc(u.name)}">Пароль</button>
|
||||
<button class="btn-sm ${u.is_blocked ? 'btn-sm--warn' : ''}"
|
||||
data-action="block"
|
||||
data-id="${u.id}"
|
||||
data-blocked="${u.is_blocked ? '1' : '0'}">
|
||||
${u.is_blocked ? 'Разблокировать' : 'Заблокировать'}
|
||||
</button>
|
||||
<button class="btn-sm btn-sm--danger"
|
||||
data-action="delete"
|
||||
data-id="${u.id}"
|
||||
data-name="${_esc(u.name)}">Удалить</button>
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
// ========== Load users ==========
|
||||
|
||||
async function _loadUsers() {
|
||||
_setError('panel-error', '');
|
||||
try {
|
||||
const users = await _api('GET', '/admin/users');
|
||||
_renderTable(users);
|
||||
} catch (err) {
|
||||
_setError('panel-error', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Login / Logout ==========
|
||||
|
||||
async function _handleLogin() {
|
||||
const input = document.getElementById('token-input');
|
||||
const btn = document.getElementById('btn-login');
|
||||
const token = input.value.trim();
|
||||
if (!token) return;
|
||||
|
||||
btn.disabled = true;
|
||||
_setError('login-error', '');
|
||||
_saveToken(token);
|
||||
|
||||
try {
|
||||
const users = await _api('GET', '/admin/users');
|
||||
_renderTable(users);
|
||||
_showPanel();
|
||||
} catch (err) {
|
||||
_clearToken();
|
||||
const msg = err.message.includes('401') ? 'Неверный токен' : err.message;
|
||||
_setError('login-error', msg);
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function _handleLogout() {
|
||||
_clearToken();
|
||||
_showTokenScreen();
|
||||
}
|
||||
|
||||
// ========== Table action dispatcher (event delegation) ==========
|
||||
|
||||
async function _handleTableClick(e) {
|
||||
const btn = e.target.closest('[data-action]');
|
||||
if (!btn) return;
|
||||
|
||||
const { action, id, name, blocked } = btn.dataset;
|
||||
|
||||
if (action === 'password') {
|
||||
_openPasswordModal(id, name);
|
||||
} else if (action === 'block') {
|
||||
await _toggleBlock(id, blocked === '1');
|
||||
} else if (action === 'delete') {
|
||||
await _handleDelete(id, name);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Block / Unblock ==========
|
||||
|
||||
async function _toggleBlock(userId, currentlyBlocked) {
|
||||
_setError('panel-error', '');
|
||||
try {
|
||||
await _api('PUT', `/admin/users/${userId}/block`, { is_blocked: !currentlyBlocked });
|
||||
await _loadUsers();
|
||||
} catch (err) {
|
||||
_setError('panel-error', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Delete ==========
|
||||
|
||||
async function _handleDelete(userId, userName) {
|
||||
if (!confirm(`Удалить пользователя "${userName}"?\n\nБудут удалены все его сигналы. Действие нельзя отменить.`)) return;
|
||||
_setError('panel-error', '');
|
||||
try {
|
||||
await _api('DELETE', `/admin/users/${userId}`);
|
||||
await _loadUsers();
|
||||
} catch (err) {
|
||||
_setError('panel-error', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Password modal ==========
|
||||
|
||||
function _openPasswordModal(userId, userName) {
|
||||
document.getElementById('modal-pw-subtitle').textContent = `Пользователь: ${userName}`;
|
||||
document.getElementById('modal-pw-user-id').value = userId;
|
||||
document.getElementById('new-password').value = '';
|
||||
_setError('modal-pw-error', '');
|
||||
document.getElementById('btn-pw-save').disabled = false;
|
||||
document.getElementById('modal-password').hidden = false;
|
||||
document.getElementById('new-password').focus();
|
||||
}
|
||||
|
||||
function _closePasswordModal() {
|
||||
document.getElementById('modal-password').hidden = true;
|
||||
}
|
||||
|
||||
async function _handleSetPassword() {
|
||||
const userId = document.getElementById('modal-pw-user-id').value;
|
||||
const password = document.getElementById('new-password').value;
|
||||
const btn = document.getElementById('btn-pw-save');
|
||||
|
||||
if (!password) {
|
||||
_setError('modal-pw-error', 'Введите пароль');
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
_setError('modal-pw-error', '');
|
||||
|
||||
try {
|
||||
await _api('PUT', `/admin/users/${userId}/password`, { password });
|
||||
_closePasswordModal();
|
||||
} catch (err) {
|
||||
_setError('modal-pw-error', err.message);
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Create user modal ==========
|
||||
|
||||
function _openCreateModal() {
|
||||
document.getElementById('create-uuid').value = crypto.randomUUID();
|
||||
document.getElementById('create-name').value = '';
|
||||
document.getElementById('create-password').value = '';
|
||||
_setError('create-error', '');
|
||||
document.getElementById('btn-create-submit').disabled = false;
|
||||
document.getElementById('modal-create').hidden = false;
|
||||
document.getElementById('create-name').focus();
|
||||
}
|
||||
|
||||
function _closeCreateModal() {
|
||||
document.getElementById('modal-create').hidden = true;
|
||||
}
|
||||
|
||||
async function _handleCreateUser() {
|
||||
const uuid = document.getElementById('create-uuid').value.trim();
|
||||
const name = document.getElementById('create-name').value.trim();
|
||||
const password = document.getElementById('create-password').value;
|
||||
const btn = document.getElementById('btn-create-submit');
|
||||
|
||||
if (!uuid || !name) {
|
||||
_setError('create-error', 'UUID и имя обязательны');
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
_setError('create-error', '');
|
||||
|
||||
const body = { uuid, name };
|
||||
if (password) body.password = password;
|
||||
|
||||
try {
|
||||
await _api('POST', '/admin/users', body);
|
||||
_closeCreateModal();
|
||||
await _loadUsers();
|
||||
} catch (err) {
|
||||
const msg = err.message.includes('409') ? 'Пользователь с таким UUID уже существует' : err.message;
|
||||
_setError('create-error', msg);
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Init ==========
|
||||
|
||||
function _init() {
|
||||
// Login screen
|
||||
document.getElementById('btn-login').addEventListener('click', _handleLogin);
|
||||
document.getElementById('token-input').addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') _handleLogin();
|
||||
});
|
||||
|
||||
// Panel
|
||||
document.getElementById('btn-logout').addEventListener('click', _handleLogout);
|
||||
document.getElementById('btn-create').addEventListener('click', _openCreateModal);
|
||||
|
||||
// Table (event delegation)
|
||||
document.getElementById('users-table').addEventListener('click', _handleTableClick);
|
||||
|
||||
// Password modal
|
||||
document.getElementById('btn-pw-cancel').addEventListener('click', _closePasswordModal);
|
||||
document.getElementById('btn-pw-save').addEventListener('click', _handleSetPassword);
|
||||
document.getElementById('new-password').addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') _handleSetPassword();
|
||||
});
|
||||
document.getElementById('modal-password').addEventListener('click', (e) => {
|
||||
if (e.target.id === 'modal-password') _closePasswordModal();
|
||||
});
|
||||
|
||||
// Create modal
|
||||
document.getElementById('btn-create-cancel').addEventListener('click', _closeCreateModal);
|
||||
document.getElementById('btn-create-submit').addEventListener('click', _handleCreateUser);
|
||||
document.getElementById('create-password').addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') _handleCreateUser();
|
||||
});
|
||||
document.getElementById('modal-create').addEventListener('click', (e) => {
|
||||
if (e.target.id === 'modal-create') _closeCreateModal();
|
||||
});
|
||||
|
||||
// Auto-login if token is already saved in sessionStorage
|
||||
if (_getToken()) {
|
||||
_showPanel();
|
||||
_loadUsers().catch(() => {
|
||||
_clearToken();
|
||||
_showTokenScreen();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', _init);
|
||||
|
|
@ -56,9 +56,14 @@ function _getUserName() {
|
|||
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_registered', '1');
|
||||
if (apiKey) _storage.setItem('baton_api_key', apiKey);
|
||||
}
|
||||
|
||||
function _getInitials(name) {
|
||||
|
|
@ -102,15 +107,17 @@ function _updateUserAvatar() {
|
|||
|
||||
// ========== API calls ==========
|
||||
|
||||
async function _apiPost(path, body) {
|
||||
async function _apiPost(path, body, extraHeaders) {
|
||||
const res = await fetch(path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', ...extraHeaders },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
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();
|
||||
}
|
||||
|
|
@ -146,8 +153,8 @@ async function _handleRegister() {
|
|||
|
||||
try {
|
||||
const uuid = _getOrCreateUserId();
|
||||
await _apiPost('/api/register', { uuid, name });
|
||||
_saveRegistration(name);
|
||||
const data = await _apiPost('/api/register', { uuid, name });
|
||||
_saveRegistration(name, data.api_key);
|
||||
_updateUserAvatar();
|
||||
_showMain();
|
||||
} catch (_) {
|
||||
|
|
@ -179,7 +186,9 @@ async function _handleSignal() {
|
|||
const body = { user_id: uuid, timestamp: Date.now() };
|
||||
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');
|
||||
_setStatus('Signal sent!', 'success');
|
||||
|
|
@ -187,10 +196,14 @@ async function _handleSignal() {
|
|||
_setSosState('default');
|
||||
_setStatus('', '');
|
||||
}, 2000);
|
||||
} catch (_) {
|
||||
} catch (err) {
|
||||
_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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Screens ==========
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ log_format baton_secure '$remote_addr - $remote_user [$time_local] '
|
|||
# ---------------------------------------------------------------------------
|
||||
server {
|
||||
listen 80;
|
||||
server_name <YOUR_DOMAIN>;
|
||||
server_name baton.itafrika.com;
|
||||
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
|
@ -41,10 +41,10 @@ server {
|
|||
# ---------------------------------------------------------------------------
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name <YOUR_DOMAIN>;
|
||||
server_name baton.itafrika.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/<YOUR_DOMAIN>/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/<YOUR_DOMAIN>/privkey.pem;
|
||||
ssl_certificate /etc/letsencrypt/live/baton.itafrika.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/baton.itafrika.com/privkey.pem;
|
||||
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_prefer_server_ciphers on;
|
||||
|
|
@ -55,16 +55,63 @@ server {
|
|||
|
||||
# Заголовки X-Telegram-Bot-Api-Secret-Token НЕ логируются —
|
||||
# они передаются только в proxy_pass и не попадают в access_log.
|
||||
location / {
|
||||
|
||||
# API → FastAPI
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Таймауты для webhook-запросов от Telegram
|
||||
proxy_read_timeout 30s;
|
||||
proxy_send_timeout 30s;
|
||||
proxy_connect_timeout 5s;
|
||||
}
|
||||
|
||||
# Health → FastAPI
|
||||
location /health {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Admin API → FastAPI (UI-страница /admin.html раздаётся статикой ниже)
|
||||
location /admin/users {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_read_timeout 30s;
|
||||
proxy_send_timeout 30s;
|
||||
proxy_connect_timeout 5s;
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Security headers
|
||||
# IMPORTANT: must be repeated in every location block that uses add_header,
|
||||
# because nginx does not inherit parent add_header when child block defines its own.
|
||||
# ---------------------------------------------------------------------------
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-Frame-Options DENY always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'" always;
|
||||
|
||||
# Статика фронтенда (SPA)
|
||||
location / {
|
||||
root /opt/baton/frontend;
|
||||
try_files $uri /index.html;
|
||||
expires 1h;
|
||||
# Security headers repeated here because add_header in location blocks
|
||||
# overrides parent-level add_header directives (nginx inheritance rule)
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-Frame-Options DENY always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'" always;
|
||||
add_header Cache-Control "public" always;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ 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")
|
||||
|
||||
# ── 2. aiosqlite monkey-patch ────────────────────────────────────────────────
|
||||
import aiosqlite
|
||||
|
|
@ -78,6 +79,7 @@ def make_app_client():
|
|||
"""
|
||||
tg_set_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/setWebhook"
|
||||
send_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage"
|
||||
get_me_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/getMe"
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def _ctx():
|
||||
|
|
@ -85,6 +87,9 @@ def make_app_client():
|
|||
from backend.main import app
|
||||
|
||||
mock_router = respx.mock(assert_all_called=False)
|
||||
mock_router.get(get_me_url).mock(
|
||||
return_value=httpx.Response(200, json={"ok": True, "result": {"username": "testbot"}})
|
||||
)
|
||||
mock_router.post(tg_set_url).mock(
|
||||
return_value=httpx.Response(200, json={"ok": True, "result": True})
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,10 @@ Acceptance criteria:
|
|||
1. No asyncio task for the aggregator is created at lifespan startup.
|
||||
2. POST /api/signal calls telegram.send_message directly (no aggregator intermediary).
|
||||
3. SignalAggregator class in telegram.py is preserved with '# v2.0 feature' marker.
|
||||
|
||||
UUID notes: all UUIDs satisfy the UUID v4 pattern.
|
||||
BATON-SEC-003: POST /api/signal now requires Authorization: Bearer <api_key>.
|
||||
Tests that send signals register first and use the returned api_key.
|
||||
"""
|
||||
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_URL", "https://example.com/api/webhook/telegram")
|
||||
os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000")
|
||||
os.environ.setdefault("ADMIN_TOKEN", "test-admin-token")
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
|
@ -25,6 +30,20 @@ from tests.conftest import make_app_client
|
|||
|
||||
_BACKEND_DIR = Path(__file__).parent.parent / "backend"
|
||||
|
||||
# Valid UUID v4 constants
|
||||
_UUID_S1 = "a0100001-0000-4000-8000-000000000001"
|
||||
_UUID_S2 = "a0100002-0000-4000-8000-000000000002"
|
||||
_UUID_S3 = "a0100003-0000-4000-8000-000000000003"
|
||||
_UUID_S4 = "a0100004-0000-4000-8000-000000000004"
|
||||
_UUID_S5 = "a0100005-0000-4000-8000-000000000005"
|
||||
|
||||
|
||||
async def _register(client, uuid: str, name: str) -> str:
|
||||
"""Register user and return api_key."""
|
||||
r = await client.post("/api/register", json={"uuid": uuid, "name": name})
|
||||
assert r.status_code == 200
|
||||
return r.json()["api_key"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Criterion 1 — No asyncio task for aggregator created at startup (static)
|
||||
|
|
@ -72,11 +91,12 @@ def test_aggregator_instantiation_commented_out_in_main():
|
|||
async def test_signal_calls_telegram_send_message_directly():
|
||||
"""POST /api/signal must call telegram.send_message directly, not via aggregator (ADR-004)."""
|
||||
async with make_app_client() as client:
|
||||
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:
|
||||
resp = await client.post(
|
||||
"/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
|
||||
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():
|
||||
"""Message passed to send_message must include the registered user's name."""
|
||||
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:
|
||||
await client.post(
|
||||
"/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]
|
||||
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():
|
||||
"""When geo is None, message must contain 'Без геолокации'."""
|
||||
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:
|
||||
await client.post(
|
||||
"/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]
|
||||
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():
|
||||
"""When geo is provided, message must contain lat and lon values."""
|
||||
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:
|
||||
await client.post(
|
||||
"/api/signal",
|
||||
json={
|
||||
"user_id": "adr-uuid-s4",
|
||||
"user_id": _UUID_S4,
|
||||
"timestamp": 1742478000000,
|
||||
"geo": {"lat": 55.7558, "lon": 37.6173, "accuracy": 15.0},
|
||||
},
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
)
|
||||
text = mock_send.call_args[0][0]
|
||||
assert "55.7558" in text
|
||||
|
|
@ -133,29 +156,17 @@ async def test_signal_message_with_geo_contains_coordinates():
|
|||
async def test_signal_message_contains_utc_marker():
|
||||
"""Message passed to send_message must contain 'UTC' timestamp marker."""
|
||||
async with make_app_client() as client:
|
||||
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:
|
||||
await client.post(
|
||||
"/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]
|
||||
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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ Acceptance criteria:
|
|||
5 requests pass (200), 6th returns 429; counter resets after the 10-minute window.
|
||||
2. Token comparison is timing-safe:
|
||||
secrets.compare_digest is used in middleware.py (no == / != for token comparison).
|
||||
|
||||
UUID notes: RegisterRequest.uuid requires a valid UUID v4 pattern.
|
||||
All UUID constants below satisfy this constraint.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -20,6 +23,7 @@ os.environ.setdefault("CHAT_ID", "-1001234567890")
|
|||
os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret")
|
||||
os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram")
|
||||
os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000")
|
||||
os.environ.setdefault("ADMIN_TOKEN", "test-admin-token")
|
||||
|
||||
import pytest
|
||||
from tests.conftest import make_app_client
|
||||
|
|
@ -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
|
||||
|
|
@ -51,7 +73,7 @@ async def test_register_rate_limit_allows_five_requests():
|
|||
for i in range(5):
|
||||
resp = await client.post(
|
||||
"/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, (
|
||||
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):
|
||||
await client.post(
|
||||
"/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(
|
||||
"/api/register",
|
||||
json={"uuid": "rl-blk-999", "name": "Attacker"},
|
||||
json={"uuid": _UUID_BLK_999, "name": "Attacker"},
|
||||
)
|
||||
assert resp.status_code == 429
|
||||
|
||||
|
|
@ -94,13 +116,13 @@ async def test_register_rate_limit_resets_after_window_expires():
|
|||
for i in range(5):
|
||||
await client.post(
|
||||
"/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
|
||||
blocked = await client.post(
|
||||
"/api/register",
|
||||
json={"uuid": "rl-exp-blk", "name": "Attacker"},
|
||||
json={"uuid": _UUID_EXP_BLK, "name": "Attacker"},
|
||||
)
|
||||
assert blocked.status_code == 429, (
|
||||
"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):
|
||||
resp_after = await client.post(
|
||||
"/api/register",
|
||||
json={"uuid": "rl-exp-after", "name": "Legit"},
|
||||
json={"uuid": _UUID_EXP_AFTER, "name": "Legit"},
|
||||
)
|
||||
|
||||
assert resp_after.status_code == 200, (
|
||||
|
|
|
|||
|
|
@ -54,14 +54,14 @@ async def test_health_returns_status_ok():
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_returns_timestamp():
|
||||
"""GET /health должен вернуть поле timestamp в JSON."""
|
||||
async def test_health_no_timestamp():
|
||||
"""GET /health не должен возвращать поле timestamp (устраняет time-based fingerprinting)."""
|
||||
async with make_app_client() as client:
|
||||
response = await client.get("/health")
|
||||
|
||||
data = response.json()
|
||||
assert "timestamp" in data
|
||||
assert isinstance(data["timestamp"], int)
|
||||
assert "timestamp" not in data
|
||||
assert data == {"status": "ok"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
514
tests/test_baton_005.py
Normal file
514
tests/test_baton_005.py
Normal file
|
|
@ -0,0 +1,514 @@
|
|||
"""
|
||||
Tests for BATON-005: Admin panel — user creation, password change, block/unblock, delete.
|
||||
|
||||
Acceptance criteria:
|
||||
1. Создание пользователя — пользователь появляется в БД (GET /admin/users)
|
||||
2. Смена пароля — endpoint возвращает ok, 404 для несуществующего пользователя
|
||||
3. Блокировка — заблокированный пользователь не может отправить сигнал (403)
|
||||
4. Разблокировка — восстанавливает доступ (сигнал снова проходит)
|
||||
5. Удаление — пользователь исчезает из GET /admin/users, возвращается 204
|
||||
6. Защита: неавторизованный запрос к /admin/* возвращает 401
|
||||
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
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
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 tests.conftest import make_app_client
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
NGINX_CONF = PROJECT_ROOT / "nginx" / "baton.conf"
|
||||
|
||||
ADMIN_HEADERS = {"Authorization": "Bearer test-admin-token"}
|
||||
WRONG_HEADERS = {"Authorization": "Bearer wrong-token"}
|
||||
|
||||
# Valid UUID v4 for signal-related tests (registered via /api/register)
|
||||
_UUID_BLOCK = "f0000001-0000-4000-8000-000000000001"
|
||||
_UUID_UNBLOCK = "f0000002-0000-4000-8000-000000000002"
|
||||
_UUID_SIG_OK = "f0000003-0000-4000-8000-000000000003"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Criterion 6 — Unauthorised requests to /admin/* return 401
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_list_users_without_token_returns_401() -> None:
|
||||
"""GET /admin/users без Authorization header должен вернуть 401."""
|
||||
async with make_app_client() as client:
|
||||
resp = await client.get("/admin/users")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_list_users_wrong_token_returns_401() -> None:
|
||||
"""GET /admin/users с неверным токеном должен вернуть 401."""
|
||||
async with make_app_client() as client:
|
||||
resp = await client.get("/admin/users", headers=WRONG_HEADERS)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_create_user_without_token_returns_401() -> None:
|
||||
"""POST /admin/users без токена должен вернуть 401."""
|
||||
async with make_app_client() as client:
|
||||
resp = await client.post(
|
||||
"/admin/users",
|
||||
json={"uuid": "unauth-uuid-001", "name": "Ghost"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_set_password_without_token_returns_401() -> None:
|
||||
"""PUT /admin/users/1/password без токена должен вернуть 401."""
|
||||
async with make_app_client() as client:
|
||||
resp = await client.put(
|
||||
"/admin/users/1/password",
|
||||
json={"password": "newpass"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_block_user_without_token_returns_401() -> None:
|
||||
"""PUT /admin/users/1/block без токена должен вернуть 401."""
|
||||
async with make_app_client() as client:
|
||||
resp = await client.put(
|
||||
"/admin/users/1/block",
|
||||
json={"is_blocked": True},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_delete_user_without_token_returns_401() -> None:
|
||||
"""DELETE /admin/users/1 без токена должен вернуть 401."""
|
||||
async with make_app_client() as client:
|
||||
resp = await client.delete("/admin/users/1")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Criterion 1 — Create user: appears in DB
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_create_user_returns_201_with_user_data() -> None:
|
||||
"""POST /admin/users с валидными данными должен вернуть 201 с полями пользователя."""
|
||||
async with make_app_client() as client:
|
||||
resp = await client.post(
|
||||
"/admin/users",
|
||||
json={"uuid": "create-uuid-001", "name": "Alice Admin"},
|
||||
headers=ADMIN_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["uuid"] == "create-uuid-001"
|
||||
assert data["name"] == "Alice Admin"
|
||||
assert data["id"] > 0
|
||||
assert data["is_blocked"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_create_user_appears_in_list() -> None:
|
||||
"""После POST /admin/users пользователь появляется в GET /admin/users."""
|
||||
async with make_app_client() as client:
|
||||
await client.post(
|
||||
"/admin/users",
|
||||
json={"uuid": "create-uuid-002", "name": "Bob Admin"},
|
||||
headers=ADMIN_HEADERS,
|
||||
)
|
||||
resp = await client.get("/admin/users", headers=ADMIN_HEADERS)
|
||||
|
||||
assert resp.status_code == 200
|
||||
users = resp.json()
|
||||
uuids = [u["uuid"] for u in users]
|
||||
assert "create-uuid-002" in uuids
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_create_user_duplicate_uuid_returns_409() -> None:
|
||||
"""POST /admin/users с существующим UUID должен вернуть 409."""
|
||||
async with make_app_client() as client:
|
||||
await client.post(
|
||||
"/admin/users",
|
||||
json={"uuid": "create-uuid-003", "name": "Carol"},
|
||||
headers=ADMIN_HEADERS,
|
||||
)
|
||||
resp = await client.post(
|
||||
"/admin/users",
|
||||
json={"uuid": "create-uuid-003", "name": "Carol Duplicate"},
|
||||
headers=ADMIN_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_list_users_returns_200_with_list() -> None:
|
||||
"""GET /admin/users с правильным токеном должен вернуть 200 со списком."""
|
||||
async with make_app_client() as client:
|
||||
resp = await client.get("/admin/users", headers=ADMIN_HEADERS)
|
||||
assert resp.status_code == 200
|
||||
assert isinstance(resp.json(), list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Criterion 2 — Password change
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_set_password_returns_ok() -> None:
|
||||
"""PUT /admin/users/{id}/password для существующего пользователя возвращает {"ok": True}."""
|
||||
async with make_app_client() as client:
|
||||
create_resp = await client.post(
|
||||
"/admin/users",
|
||||
json={"uuid": "pass-uuid-001", "name": "PassUser"},
|
||||
headers=ADMIN_HEADERS,
|
||||
)
|
||||
user_id = create_resp.json()["id"]
|
||||
|
||||
resp = await client.put(
|
||||
f"/admin/users/{user_id}/password",
|
||||
json={"password": "newpassword123"},
|
||||
headers=ADMIN_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"ok": True}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_set_password_nonexistent_user_returns_404() -> None:
|
||||
"""PUT /admin/users/99999/password для несуществующего пользователя возвращает 404."""
|
||||
async with make_app_client() as client:
|
||||
resp = await client.put(
|
||||
"/admin/users/99999/password",
|
||||
json={"password": "somepassword"},
|
||||
headers=ADMIN_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_set_password_user_still_accessible_after_change() -> None:
|
||||
"""Пользователь остаётся доступен в GET /admin/users после смены пароля."""
|
||||
async with make_app_client() as client:
|
||||
create_resp = await client.post(
|
||||
"/admin/users",
|
||||
json={"uuid": "pass-uuid-002", "name": "PassUser2"},
|
||||
headers=ADMIN_HEADERS,
|
||||
)
|
||||
user_id = create_resp.json()["id"]
|
||||
|
||||
await client.put(
|
||||
f"/admin/users/{user_id}/password",
|
||||
json={"password": "updatedpass"},
|
||||
headers=ADMIN_HEADERS,
|
||||
)
|
||||
|
||||
list_resp = await client.get("/admin/users", headers=ADMIN_HEADERS)
|
||||
|
||||
uuids = [u["uuid"] for u in list_resp.json()]
|
||||
assert "pass-uuid-002" in uuids
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Criterion 3 — Block user: blocked user cannot send signal
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_block_user_returns_is_blocked_true() -> None:
|
||||
"""PUT /admin/users/{id}/block с is_blocked=true должен вернуть пользователя с is_blocked=True."""
|
||||
async with make_app_client() as client:
|
||||
create_resp = await client.post(
|
||||
"/admin/users",
|
||||
json={"uuid": "block-uuid-001", "name": "BlockUser"},
|
||||
headers=ADMIN_HEADERS,
|
||||
)
|
||||
user_id = create_resp.json()["id"]
|
||||
|
||||
resp = await client.put(
|
||||
f"/admin/users/{user_id}/block",
|
||||
json={"is_blocked": True},
|
||||
headers=ADMIN_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["is_blocked"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_block_user_prevents_signal() -> None:
|
||||
"""Заблокированный пользователь не может отправить сигнал — /api/signal возвращает 403."""
|
||||
async with make_app_client() as client:
|
||||
# Регистрируем через /api/register чтобы получить api_key
|
||||
reg_resp = await client.post(
|
||||
"/api/register",
|
||||
json={"uuid": _UUID_BLOCK, "name": "BlockSignalUser"},
|
||||
)
|
||||
assert reg_resp.status_code == 200
|
||||
api_key = reg_resp.json()["api_key"]
|
||||
user_uuid = reg_resp.json()["uuid"]
|
||||
|
||||
# Находим ID пользователя
|
||||
users_resp = await client.get("/admin/users", headers=ADMIN_HEADERS)
|
||||
user = next(u for u in users_resp.json() if u["uuid"] == user_uuid)
|
||||
user_id = user["id"]
|
||||
|
||||
# Блокируем
|
||||
await client.put(
|
||||
f"/admin/users/{user_id}/block",
|
||||
json={"is_blocked": True},
|
||||
headers=ADMIN_HEADERS,
|
||||
)
|
||||
|
||||
# Заблокированный пользователь должен получить 403
|
||||
signal_resp = await client.post(
|
||||
"/api/signal",
|
||||
json={"user_id": user_uuid, "timestamp": 1700000000000, "geo": None},
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
)
|
||||
assert signal_resp.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_block_nonexistent_user_returns_404() -> None:
|
||||
"""PUT /admin/users/99999/block для несуществующего пользователя возвращает 404."""
|
||||
async with make_app_client() as client:
|
||||
resp = await client.put(
|
||||
"/admin/users/99999/block",
|
||||
json={"is_blocked": True},
|
||||
headers=ADMIN_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Criterion 4 — Unblock user: restores access
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_unblock_user_returns_is_blocked_false() -> None:
|
||||
"""PUT /admin/users/{id}/block с is_blocked=false должен вернуть пользователя с is_blocked=False."""
|
||||
async with make_app_client() as client:
|
||||
create_resp = await client.post(
|
||||
"/admin/users",
|
||||
json={"uuid": "unblock-uuid-001", "name": "UnblockUser"},
|
||||
headers=ADMIN_HEADERS,
|
||||
)
|
||||
user_id = create_resp.json()["id"]
|
||||
|
||||
await client.put(
|
||||
f"/admin/users/{user_id}/block",
|
||||
json={"is_blocked": True},
|
||||
headers=ADMIN_HEADERS,
|
||||
)
|
||||
|
||||
resp = await client.put(
|
||||
f"/admin/users/{user_id}/block",
|
||||
json={"is_blocked": False},
|
||||
headers=ADMIN_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["is_blocked"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_unblock_user_restores_signal_access() -> None:
|
||||
"""После разблокировки пользователь снова может отправить сигнал (200)."""
|
||||
async with make_app_client() as client:
|
||||
# Регистрируем через /api/register чтобы получить api_key
|
||||
reg_resp = await client.post(
|
||||
"/api/register",
|
||||
json={"uuid": _UUID_UNBLOCK, "name": "UnblockSignalUser"},
|
||||
)
|
||||
assert reg_resp.status_code == 200
|
||||
api_key = reg_resp.json()["api_key"]
|
||||
user_uuid = reg_resp.json()["uuid"]
|
||||
|
||||
# Находим ID
|
||||
users_resp = await client.get("/admin/users", headers=ADMIN_HEADERS)
|
||||
user = next(u for u in users_resp.json() if u["uuid"] == user_uuid)
|
||||
user_id = user["id"]
|
||||
|
||||
# Блокируем
|
||||
await client.put(
|
||||
f"/admin/users/{user_id}/block",
|
||||
json={"is_blocked": True},
|
||||
headers=ADMIN_HEADERS,
|
||||
)
|
||||
|
||||
# Разблокируем
|
||||
await client.put(
|
||||
f"/admin/users/{user_id}/block",
|
||||
json={"is_blocked": False},
|
||||
headers=ADMIN_HEADERS,
|
||||
)
|
||||
|
||||
# Сигнал должен пройти
|
||||
signal_resp = await client.post(
|
||||
"/api/signal",
|
||||
json={"user_id": user_uuid, "timestamp": 1700000000000, "geo": None},
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
)
|
||||
assert signal_resp.status_code == 200
|
||||
assert signal_resp.json()["status"] == "ok"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Criterion 5 — Delete user: disappears from DB
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_delete_user_returns_204() -> None:
|
||||
"""DELETE /admin/users/{id} для существующего пользователя возвращает 204."""
|
||||
async with make_app_client() as client:
|
||||
create_resp = await client.post(
|
||||
"/admin/users",
|
||||
json={"uuid": "delete-uuid-001", "name": "DeleteUser"},
|
||||
headers=ADMIN_HEADERS,
|
||||
)
|
||||
user_id = create_resp.json()["id"]
|
||||
|
||||
resp = await client.delete(
|
||||
f"/admin/users/{user_id}",
|
||||
headers=ADMIN_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 204
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_delete_user_disappears_from_list() -> None:
|
||||
"""После DELETE /admin/users/{id} пользователь отсутствует в GET /admin/users."""
|
||||
async with make_app_client() as client:
|
||||
create_resp = await client.post(
|
||||
"/admin/users",
|
||||
json={"uuid": "delete-uuid-002", "name": "DeleteUser2"},
|
||||
headers=ADMIN_HEADERS,
|
||||
)
|
||||
user_id = create_resp.json()["id"]
|
||||
|
||||
await client.delete(
|
||||
f"/admin/users/{user_id}",
|
||||
headers=ADMIN_HEADERS,
|
||||
)
|
||||
|
||||
list_resp = await client.get("/admin/users", headers=ADMIN_HEADERS)
|
||||
|
||||
uuids = [u["uuid"] for u in list_resp.json()]
|
||||
assert "delete-uuid-002" not in uuids
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_delete_nonexistent_user_returns_404() -> None:
|
||||
"""DELETE /admin/users/99999 для несуществующего пользователя возвращает 404."""
|
||||
async with make_app_client() as client:
|
||||
resp = await client.delete(
|
||||
"/admin/users/99999",
|
||||
headers=ADMIN_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# nginx config — location /admin/users block (BATON-006 fix)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_nginx_conf_has_admin_users_location_block() -> None:
|
||||
"""nginx/baton.conf должен содержать блок location /admin/users."""
|
||||
content = NGINX_CONF.read_text(encoding="utf-8")
|
||||
assert re.search(r"location\s+/admin/users\b", content), (
|
||||
"nginx/baton.conf не содержит блок location /admin/users — "
|
||||
"запросы к admin API будут попадать в location / и возвращать 404"
|
||||
)
|
||||
|
||||
|
||||
def test_nginx_conf_admin_users_location_proxies_to_fastapi() -> None:
|
||||
"""Блок location /admin/users должен делать proxy_pass на 127.0.0.1:8000."""
|
||||
content = NGINX_CONF.read_text(encoding="utf-8")
|
||||
admin_block = re.search(
|
||||
r"location\s+/admin/users\s*\{([^}]+)\}", content, re.DOTALL
|
||||
)
|
||||
assert admin_block is not None, "Блок location /admin/users { ... } не найден"
|
||||
assert re.search(r"proxy_pass\s+http://127\.0\.0\.1:8000", admin_block.group(1)), (
|
||||
"Блок location /admin/users не содержит proxy_pass http://127.0.0.1:8000"
|
||||
)
|
||||
|
||||
|
||||
def test_nginx_conf_admin_users_location_before_root_location() -> None:
|
||||
"""location /admin/users должен находиться в nginx.conf до location / для корректного prefix-matching."""
|
||||
content = NGINX_CONF.read_text(encoding="utf-8")
|
||||
admin_pos = content.find("location /admin/users")
|
||||
root_pos = re.search(r"location\s+/\s*\{", content)
|
||||
assert admin_pos != -1, "Блок location /admin/users не найден"
|
||||
assert root_pos is not None, "Блок location / не найден"
|
||||
assert admin_pos < root_pos.start(), (
|
||||
"location /admin/users должен быть определён ДО location / в nginx.conf"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Criterion 7 — No regression with main functionality
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_not_broken_after_admin_operations() -> None:
|
||||
"""POST /api/register работает корректно после выполнения admin-операций."""
|
||||
async with make_app_client() as client:
|
||||
# Admin операции
|
||||
await client.post(
|
||||
"/admin/users",
|
||||
json={"uuid": "regress-admin-uuid-001", "name": "AdminCreated"},
|
||||
headers=ADMIN_HEADERS,
|
||||
)
|
||||
|
||||
# Основной функционал
|
||||
resp = await client.post(
|
||||
"/api/register",
|
||||
json={"uuid": _UUID_SIG_OK, "name": "RegularUser"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["uuid"] == _UUID_SIG_OK
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_signal_from_registered_unblocked_user_succeeds() -> None:
|
||||
"""Зарегистрированный незаблокированный пользователь может отправить сигнал."""
|
||||
async with make_app_client() as client:
|
||||
reg_resp = await client.post(
|
||||
"/api/register",
|
||||
json={"uuid": _UUID_SIG_OK, "name": "SignalUser"},
|
||||
)
|
||||
assert reg_resp.status_code == 200
|
||||
api_key = reg_resp.json()["api_key"]
|
||||
user_uuid = reg_resp.json()["uuid"]
|
||||
|
||||
signal_resp = await client.post(
|
||||
"/api/signal",
|
||||
json={"user_id": user_uuid, "timestamp": 1700000000000, "geo": None},
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
)
|
||||
assert signal_resp.status_code == 200
|
||||
assert signal_resp.json()["status"] == "ok"
|
||||
244
tests/test_baton_006.py
Normal file
244
tests/test_baton_006.py
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
"""
|
||||
Tests for BATON-006: не работает фронт — {"detail":"Not Found"}
|
||||
|
||||
Acceptance criteria:
|
||||
1. nginx/baton.conf содержит location /api/ (prefix match), проксирует на FastAPI.
|
||||
2. nginx/baton.conf содержит location /health, проксирует на FastAPI.
|
||||
3. nginx/baton.conf содержит location / с root и try_files (SPA-поведение).
|
||||
4. GET / на FastAPI возвращает 404 (маршрут / не зарегистрирован в main.py —
|
||||
статику должен отдавать nginx, а не FastAPI).
|
||||
5. GET /health возвращает 200 (FastAPI-маршрут работает).
|
||||
6. POST /api/register возвращает 200 (FastAPI-маршрут не сломан).
|
||||
7. POST /api/signal возвращает 200 (FastAPI-маршрут не сломан).
|
||||
8. POST /api/webhook/telegram возвращает 200 с корректным секретом.
|
||||
|
||||
BATON-SEC-003: POST /api/signal now requires Authorization: Bearer <api_key>.
|
||||
UUID constants satisfy the UUID v4 pattern.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
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 tests.conftest import make_app_client
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
NGINX_CONF = PROJECT_ROOT / "nginx" / "baton.conf"
|
||||
|
||||
# Valid UUID v4 constants
|
||||
_UUID_REG = "e0000001-0000-4000-8000-000000000001"
|
||||
_UUID_SIG = "e0000002-0000-4000-8000-000000000002"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Criterion 1 — location /api/ proxies to FastAPI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_nginx_conf_exists() -> None:
|
||||
"""nginx/baton.conf должен существовать в репозитории."""
|
||||
assert NGINX_CONF.is_file(), f"nginx/baton.conf не найден: {NGINX_CONF}"
|
||||
|
||||
|
||||
def test_nginx_conf_has_api_location_block() -> None:
|
||||
"""nginx/baton.conf должен содержать location /api/ (prefix match)."""
|
||||
content = NGINX_CONF.read_text(encoding="utf-8")
|
||||
assert re.search(r"location\s+/api/", content), (
|
||||
"nginx/baton.conf не содержит блок location /api/"
|
||||
)
|
||||
|
||||
|
||||
def test_nginx_conf_api_location_proxies_to_fastapi() -> None:
|
||||
"""Блок location /api/ должен делать proxy_pass на 127.0.0.1:8000."""
|
||||
content = NGINX_CONF.read_text(encoding="utf-8")
|
||||
api_block = re.search(
|
||||
r"location\s+/api/\s*\{([^}]+)\}", content, re.DOTALL
|
||||
)
|
||||
assert api_block is not None, "Блок location /api/ { ... } не найден"
|
||||
assert re.search(r"proxy_pass\s+http://127\.0\.0\.1:8000", api_block.group(1)), (
|
||||
"Блок location /api/ не содержит proxy_pass http://127.0.0.1:8000"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Criterion 2 — location /health proxies to FastAPI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_nginx_conf_has_health_location_block() -> None:
|
||||
"""nginx/baton.conf должен содержать отдельный location /health."""
|
||||
content = NGINX_CONF.read_text(encoding="utf-8")
|
||||
assert re.search(r"location\s+/health\b", content), (
|
||||
"nginx/baton.conf не содержит блок location /health"
|
||||
)
|
||||
|
||||
|
||||
def test_nginx_conf_health_location_proxies_to_fastapi() -> None:
|
||||
"""Блок location /health должен делать proxy_pass на 127.0.0.1:8000."""
|
||||
content = NGINX_CONF.read_text(encoding="utf-8")
|
||||
health_block = re.search(
|
||||
r"location\s+/health\s*\{([^}]+)\}", content, re.DOTALL
|
||||
)
|
||||
assert health_block is not None, "Блок location /health { ... } не найден"
|
||||
assert re.search(r"proxy_pass\s+http://127\.0\.0\.1:8000", health_block.group(1)), (
|
||||
"Блок location /health не содержит proxy_pass http://127.0.0.1:8000"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Criterion 3 — location / serves static files (SPA)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_nginx_conf_root_location_has_root_directive() -> None:
|
||||
"""location / в nginx.conf должен содержать директиву root (статика)."""
|
||||
content = NGINX_CONF.read_text(encoding="utf-8")
|
||||
root_block = re.search(
|
||||
r"location\s+/\s*\{([^}]+)\}", content, re.DOTALL
|
||||
)
|
||||
assert root_block is not None, "Блок location / { ... } не найден"
|
||||
assert re.search(r"root\s+", root_block.group(1)), (
|
||||
"Блок location / не содержит директиву root — SPA статика не настроена"
|
||||
)
|
||||
|
||||
|
||||
def test_nginx_conf_root_location_has_try_files_for_spa() -> None:
|
||||
"""location / должен содержать try_files с fallback на /index.html (SPA)."""
|
||||
content = NGINX_CONF.read_text(encoding="utf-8")
|
||||
root_block = re.search(
|
||||
r"location\s+/\s*\{([^}]+)\}", content, re.DOTALL
|
||||
)
|
||||
assert root_block is not None, "Блок location / { ... } не найден"
|
||||
assert re.search(r"try_files\s+\$uri\s+/index\.html", root_block.group(1)), (
|
||||
"Блок location / не содержит try_files $uri /index.html — "
|
||||
"SPA-роутинг не работает"
|
||||
)
|
||||
|
||||
|
||||
def test_nginx_conf_root_location_does_not_proxy_to_fastapi() -> None:
|
||||
"""location / НЕ должен делать proxy_pass на FastAPI (только статика)."""
|
||||
content = NGINX_CONF.read_text(encoding="utf-8")
|
||||
root_block = re.search(
|
||||
r"location\s+/\s*\{([^}]+)\}", content, re.DOTALL
|
||||
)
|
||||
assert root_block is not None, "Блок location / { ... } не найден"
|
||||
assert not re.search(r"proxy_pass", root_block.group(1)), (
|
||||
"Блок location / содержит proxy_pass — GET / будет проксирован в FastAPI, "
|
||||
"что вернёт 404 {'detail':'Not Found'} (исходная ошибка BATON-006)"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Criterion 4 — FastAPI не имеет маршрута GET /
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fastapi_root_returns_404() -> None:
|
||||
"""GET / должен возвращать 404 от FastAPI — маршрут не зарегистрирован.
|
||||
|
||||
Это ожидаемое поведение: статику отдаёт nginx (location / с root + try_files),
|
||||
а не FastAPI. Регрессия: если когда-нибудь GET / начнёт возвращать 200 от FastAPI,
|
||||
это нарушит архитектуру (FastAPI не должен отдавать статику).
|
||||
"""
|
||||
async with make_app_client() as client:
|
||||
response = await client.get("/")
|
||||
|
||||
assert response.status_code == 404, (
|
||||
f"GET / должен возвращать 404 от FastAPI (статику отдаёт nginx). "
|
||||
f"Получено: {response.status_code}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Criterion 5 — GET /health работает
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_endpoint_returns_200() -> None:
|
||||
"""GET /health должен возвращать 200 после изменений nginx-конфига."""
|
||||
async with make_app_client() as client:
|
||||
response = await client.get("/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json().get("status") == "ok"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Criterion 6 — POST /api/register не сломан
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_register_not_broken_after_nginx_change() -> None:
|
||||
"""POST /api/register должен вернуть 200 — функция не сломана изменением nginx."""
|
||||
async with make_app_client() as client:
|
||||
response = await client.post(
|
||||
"/api/register",
|
||||
json={"uuid": _UUID_REG, "name": "TestUser"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["user_id"] > 0
|
||||
assert data["uuid"] == _UUID_REG
|
||||
assert "api_key" in data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Criterion 7 — POST /api/signal не сломан
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_signal_not_broken_after_nginx_change() -> None:
|
||||
"""POST /api/signal должен вернуть 200 — функция не сломана изменением nginx."""
|
||||
async with make_app_client() as client:
|
||||
reg_resp = await client.post(
|
||||
"/api/register",
|
||||
json={"uuid": _UUID_SIG, "name": "SignalUser"},
|
||||
)
|
||||
assert reg_resp.status_code == 200
|
||||
api_key = reg_resp.json()["api_key"]
|
||||
|
||||
response = await client.post(
|
||||
"/api/signal",
|
||||
json={
|
||||
"user_id": _UUID_SIG,
|
||||
"timestamp": 1700000000000,
|
||||
"geo": None,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json().get("status") == "ok"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Criterion 8 — POST /api/webhook/telegram не сломан
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_webhook_telegram_not_broken_after_nginx_change() -> None:
|
||||
"""POST /api/webhook/telegram с корректным секретом должен вернуть 200."""
|
||||
async with make_app_client() as client:
|
||||
response = await client.post(
|
||||
"/api/webhook/telegram",
|
||||
json={"update_id": 200, "message": {"text": "hello"}},
|
||||
headers={"X-Telegram-Bot-Api-Secret-Token": "test-webhook-secret"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"ok": True}
|
||||
262
tests/test_baton_007.py
Normal file
262
tests/test_baton_007.py
Normal 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"
|
||||
)
|
||||
|
|
@ -109,10 +109,9 @@ async def test_init_db_synchronous():
|
|||
await db.init_db()
|
||||
# Check synchronous on a new connection via _get_conn()
|
||||
from backend.db import _get_conn
|
||||
conn = await _get_conn()
|
||||
async with _get_conn() as conn:
|
||||
async with conn.execute("PRAGMA synchronous") as cur:
|
||||
row = await cur.fetchone()
|
||||
await conn.close()
|
||||
# 1 == NORMAL
|
||||
assert row[0] == 1
|
||||
finally:
|
||||
|
|
|
|||
|
|
@ -46,11 +46,11 @@ def test_register_request_empty_uuid():
|
|||
def test_register_request_name_max_length():
|
||||
"""name longer than 100 chars raises ValidationError."""
|
||||
with pytest.raises(ValidationError):
|
||||
RegisterRequest(uuid="some-uuid", name="x" * 101)
|
||||
RegisterRequest(uuid="550e8400-e29b-41d4-a716-446655440000", name="x" * 101)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -116,7 +116,7 @@ def test_signal_request_valid():
|
|||
|
||||
def test_signal_request_no_geo():
|
||||
req = SignalRequest(
|
||||
user_id="some-uuid",
|
||||
user_id="550e8400-e29b-41d4-a716-446655440000",
|
||||
timestamp=1742478000000,
|
||||
geo=None,
|
||||
)
|
||||
|
|
@ -136,9 +136,9 @@ def test_signal_request_empty_user_id():
|
|||
def test_signal_request_timestamp_zero():
|
||||
"""timestamp must be > 0."""
|
||||
with pytest.raises(ValidationError):
|
||||
SignalRequest(user_id="some-uuid", timestamp=0)
|
||||
SignalRequest(user_id="550e8400-e29b-41d4-a716-446655440000", timestamp=0)
|
||||
|
||||
|
||||
def test_signal_request_timestamp_negative():
|
||||
with pytest.raises(ValidationError):
|
||||
SignalRequest(user_id="some-uuid", timestamp=-1)
|
||||
SignalRequest(user_id="550e8400-e29b-41d4-a716-446655440000", timestamp=-1)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
"""
|
||||
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
|
||||
|
||||
|
|
@ -10,23 +16,34 @@ os.environ.setdefault("CHAT_ID", "-1001234567890")
|
|||
os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret")
|
||||
os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram")
|
||||
os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000")
|
||||
os.environ.setdefault("ADMIN_TOKEN", "test-admin-token")
|
||||
|
||||
import pytest
|
||||
from tests.conftest import make_app_client
|
||||
|
||||
# Valid UUID v4 constants for register tests
|
||||
_UUID_REG_1 = "b0000001-0000-4000-8000-000000000001"
|
||||
_UUID_REG_2 = "b0000002-0000-4000-8000-000000000002"
|
||||
_UUID_REG_3 = "b0000003-0000-4000-8000-000000000003"
|
||||
_UUID_REG_4 = "b0000004-0000-4000-8000-000000000004"
|
||||
_UUID_REG_5 = "b0000005-0000-4000-8000-000000000005"
|
||||
_UUID_REG_6 = "b0000006-0000-4000-8000-000000000006"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_new_user_success():
|
||||
"""POST /api/register returns 200 with user_id > 0."""
|
||||
"""POST /api/register returns 200 with user_id > 0 and api_key."""
|
||||
async with make_app_client() as client:
|
||||
resp = await client.post(
|
||||
"/api/register",
|
||||
json={"uuid": "reg-uuid-001", "name": "Alice"},
|
||||
json={"uuid": _UUID_REG_1, "name": "Alice"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
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
|
||||
|
|
@ -35,24 +52,42 @@ async def test_register_idempotent():
|
|||
async with make_app_client() as client:
|
||||
r1 = await client.post(
|
||||
"/api/register",
|
||||
json={"uuid": "reg-uuid-002", "name": "Bob"},
|
||||
json={"uuid": _UUID_REG_2, "name": "Bob"},
|
||||
)
|
||||
r2 = await client.post(
|
||||
"/api/register",
|
||||
json={"uuid": "reg-uuid-002", "name": "Bob"},
|
||||
json={"uuid": _UUID_REG_2, "name": "Bob"},
|
||||
)
|
||||
assert r1.status_code == 200
|
||||
assert r2.status_code == 200
|
||||
assert r1.json()["user_id"] == r2.json()["user_id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_idempotent_returns_api_key_on_every_call():
|
||||
"""Each registration call returns an api_key (key rotation on re-register)."""
|
||||
async with make_app_client() as client:
|
||||
r1 = await client.post(
|
||||
"/api/register",
|
||||
json={"uuid": _UUID_REG_3, "name": "Carol"},
|
||||
)
|
||||
r2 = await client.post(
|
||||
"/api/register",
|
||||
json={"uuid": _UUID_REG_3, "name": "Carol"},
|
||||
)
|
||||
assert r1.status_code == 200
|
||||
assert r2.status_code == 200
|
||||
assert "api_key" in r1.json()
|
||||
assert "api_key" in r2.json()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_empty_name_returns_422():
|
||||
"""Empty name must fail validation with 422."""
|
||||
async with make_app_client() as client:
|
||||
resp = await client.post(
|
||||
"/api/register",
|
||||
json={"uuid": "reg-uuid-003", "name": ""},
|
||||
json={"uuid": _UUID_REG_4, "name": ""},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
|
@ -74,7 +109,18 @@ async def test_register_missing_name_returns_422():
|
|||
async with make_app_client() as client:
|
||||
resp = await client.post(
|
||||
"/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
|
||||
|
||||
|
|
@ -85,11 +131,11 @@ async def test_register_user_stored_in_db():
|
|||
async with make_app_client() as client:
|
||||
r1 = await client.post(
|
||||
"/api/register",
|
||||
json={"uuid": "reg-uuid-005", "name": "Dana"},
|
||||
json={"uuid": _UUID_REG_5, "name": "Dana"},
|
||||
)
|
||||
r2 = await client.post(
|
||||
"/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"]
|
||||
|
||||
|
|
@ -100,6 +146,6 @@ async def test_register_response_contains_uuid():
|
|||
async with make_app_client() as client:
|
||||
resp = await client.post(
|
||||
"/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
|
||||
|
|
|
|||
282
tests/test_sec_002.py
Normal file
282
tests/test_sec_002.py
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
"""
|
||||
Tests for BATON-SEC-002:
|
||||
1. _get_client_ip() extracts real IP from X-Real-IP / X-Forwarded-For headers.
|
||||
2. POST /api/signal returns 429 when the per-IP rate limit is exceeded.
|
||||
3. Rate counters for register and signal are independent (separate key namespaces).
|
||||
|
||||
UUID notes: RegisterRequest.uuid and SignalRequest.user_id both require a valid
|
||||
UUID v4 pattern (^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$).
|
||||
All constants below satisfy this constraint.
|
||||
|
||||
BATON-SEC-003: POST /api/signal now requires Authorization: Bearer <api_key>.
|
||||
_register_and_get_key() helper returns the api_key from the registration response.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("BOT_TOKEN", "test-bot-token")
|
||||
os.environ.setdefault("CHAT_ID", "-1001234567890")
|
||||
os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret")
|
||||
os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram")
|
||||
os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000")
|
||||
os.environ.setdefault("ADMIN_TOKEN", "test-admin-token")
|
||||
|
||||
import pytest
|
||||
from starlette.requests import Request
|
||||
|
||||
from backend.middleware import _get_client_ip
|
||||
from tests.conftest import make_app_client
|
||||
|
||||
# ── Valid UUID v4 constants ──────────────────────────────────────────────────
|
||||
# Pattern: xxxxxxxx-xxxx-4xxx-[89ab]xxx-xxxxxxxxxxxx (all hex chars)
|
||||
|
||||
_UUID_SIG_RL = "a0000001-0000-4000-8000-000000000001" # rate-limit 429 test
|
||||
_UUID_SIG_OK = "a0000002-0000-4000-8000-000000000002" # first-10-allowed test
|
||||
_UUID_IND_SIG = "a0000003-0000-4000-8000-000000000003" # independence (exhaust signal)
|
||||
_UUID_IND_SIG2 = "a0000033-0000-4000-8000-000000000033" # second register after exhaust
|
||||
_UUID_IND_REG = "a0000004-0000-4000-8000-000000000004" # independence (exhaust register)
|
||||
_UUID_IP_A = "a0000005-0000-4000-8000-000000000005" # per-IP isolation, user A
|
||||
_UUID_IP_B = "a0000006-0000-4000-8000-000000000006" # per-IP isolation, user B
|
||||
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _register_and_get_key(client, uuid: str, name: str) -> str:
|
||||
"""Register user and return api_key."""
|
||||
r = await client.post("/api/register", json={"uuid": uuid, "name": name})
|
||||
assert r.status_code == 200
|
||||
return r.json()["api_key"]
|
||||
|
||||
|
||||
def _make_request(headers: dict | None = None, client_host: str = "127.0.0.1") -> Request:
|
||||
"""Build a minimal Starlette Request with given headers and remote address."""
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": "/",
|
||||
"headers": [
|
||||
(k.lower().encode(), v.encode())
|
||||
for k, v in (headers or {}).items()
|
||||
],
|
||||
"client": (client_host, 12345),
|
||||
}
|
||||
return Request(scope)
|
||||
|
||||
|
||||
# ── Unit: _get_client_ip ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_get_client_ip_returns_x_real_ip_when_present():
|
||||
"""X-Real-IP header is returned as-is (highest priority)."""
|
||||
req = _make_request({"X-Real-IP": "203.0.113.10"}, client_host="127.0.0.1")
|
||||
assert _get_client_ip(req) == "203.0.113.10"
|
||||
|
||||
|
||||
def test_get_client_ip_ignores_client_host_when_x_real_ip_set():
|
||||
"""When X-Real-IP is present, client.host (127.0.0.1) must NOT be returned."""
|
||||
req = _make_request({"X-Real-IP": "10.20.30.40"}, client_host="127.0.0.1")
|
||||
assert _get_client_ip(req) != "127.0.0.1"
|
||||
|
||||
|
||||
def test_get_client_ip_uses_x_forwarded_for_when_no_x_real_ip():
|
||||
"""X-Forwarded-For is used when X-Real-IP is absent."""
|
||||
req = _make_request({"X-Forwarded-For": "198.51.100.5"}, client_host="127.0.0.1")
|
||||
assert _get_client_ip(req) == "198.51.100.5"
|
||||
|
||||
|
||||
def test_get_client_ip_x_forwarded_for_returns_first_ip_in_chain():
|
||||
"""When X-Forwarded-For contains a chain, only the first (original) IP is returned."""
|
||||
req = _make_request(
|
||||
{"X-Forwarded-For": "192.0.2.1, 10.0.0.1, 172.16.0.1"},
|
||||
client_host="127.0.0.1",
|
||||
)
|
||||
assert _get_client_ip(req) == "192.0.2.1"
|
||||
|
||||
|
||||
def test_get_client_ip_x_real_ip_takes_priority_over_x_forwarded_for():
|
||||
"""X-Real-IP beats X-Forwarded-For when both headers are present."""
|
||||
req = _make_request(
|
||||
{"X-Real-IP": "1.1.1.1", "X-Forwarded-For": "2.2.2.2"},
|
||||
client_host="127.0.0.1",
|
||||
)
|
||||
assert _get_client_ip(req) == "1.1.1.1"
|
||||
|
||||
|
||||
def test_get_client_ip_falls_back_to_client_host_when_no_proxy_headers():
|
||||
"""Without proxy headers, client.host is returned."""
|
||||
req = _make_request(client_host="203.0.113.99")
|
||||
assert _get_client_ip(req) == "203.0.113.99"
|
||||
|
||||
|
||||
def test_get_client_ip_returns_unknown_when_no_client_and_no_headers():
|
||||
"""If no proxy headers and client is None, 'unknown' is returned."""
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": "/",
|
||||
"headers": [],
|
||||
"client": None,
|
||||
}
|
||||
req = Request(scope)
|
||||
assert _get_client_ip(req) == "unknown"
|
||||
|
||||
|
||||
# ── Integration: signal rate limit (429) ────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_signal_rate_limit_returns_429_after_10_requests():
|
||||
"""POST /api/signal returns 429 on the 11th request from the same IP."""
|
||||
async with make_app_client() as client:
|
||||
api_key = await _register_and_get_key(client, _UUID_SIG_RL, "RL")
|
||||
|
||||
payload = {"user_id": _UUID_SIG_RL, "timestamp": 1742478000000}
|
||||
ip_hdrs = {"X-Real-IP": "5.5.5.5", "Authorization": f"Bearer {api_key}"}
|
||||
|
||||
statuses = []
|
||||
for _ in range(11):
|
||||
r = await client.post("/api/signal", json=payload, headers=ip_hdrs)
|
||||
statuses.append(r.status_code)
|
||||
|
||||
assert statuses[-1] == 429, f"Expected 429 on 11th request, got {statuses}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_signal_first_10_requests_are_allowed():
|
||||
"""First 10 POST /api/signal requests from the same IP must all return 200."""
|
||||
async with make_app_client() as client:
|
||||
api_key = await _register_and_get_key(client, _UUID_SIG_OK, "OK")
|
||||
|
||||
payload = {"user_id": _UUID_SIG_OK, "timestamp": 1742478000000}
|
||||
ip_hdrs = {"X-Real-IP": "6.6.6.6", "Authorization": f"Bearer {api_key}"}
|
||||
|
||||
statuses = []
|
||||
for _ in range(10):
|
||||
r = await client.post("/api/signal", json=payload, headers=ip_hdrs)
|
||||
statuses.append(r.status_code)
|
||||
|
||||
assert all(s == 200 for s in statuses), (
|
||||
f"Some request(s) before limit returned non-200: {statuses}"
|
||||
)
|
||||
|
||||
|
||||
# ── Integration: independence of register and signal rate limits ─────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_signal_rate_limit_does_not_affect_register_counter():
|
||||
"""
|
||||
Exhausting the signal rate limit (11 requests) must NOT cause /api/register
|
||||
to return 429 — the counters use different keys ('sig:IP' vs 'IP').
|
||||
"""
|
||||
async with make_app_client() as client:
|
||||
ip_hdrs_reg = {"X-Real-IP": "7.7.7.7"}
|
||||
|
||||
# Register a user (increments register counter, key='7.7.7.7', count=1)
|
||||
r_reg = await client.post(
|
||||
"/api/register",
|
||||
json={"uuid": _UUID_IND_SIG, "name": "Ind"},
|
||||
headers=ip_hdrs_reg,
|
||||
)
|
||||
assert r_reg.status_code == 200
|
||||
api_key = r_reg.json()["api_key"]
|
||||
|
||||
# Exhaust signal rate limit for same IP (11 requests, key='sig:7.7.7.7')
|
||||
payload = {"user_id": _UUID_IND_SIG, "timestamp": 1742478000000}
|
||||
ip_hdrs_sig = {"X-Real-IP": "7.7.7.7", "Authorization": f"Bearer {api_key}"}
|
||||
for _ in range(11):
|
||||
await client.post("/api/signal", json=payload, headers=ip_hdrs_sig)
|
||||
|
||||
# Register counter is still at 1 — must allow another registration
|
||||
r_reg2 = await client.post(
|
||||
"/api/register",
|
||||
json={"uuid": _UUID_IND_SIG2, "name": "Ind2"},
|
||||
headers=ip_hdrs_reg,
|
||||
)
|
||||
|
||||
assert r_reg2.status_code == 200, (
|
||||
f"Register returned {r_reg2.status_code} — "
|
||||
"signal exhaustion incorrectly bled into register counter"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_rate_limit_does_not_affect_signal_counter():
|
||||
"""
|
||||
Exhausting the register rate limit (6 requests → 6th returns 429) must NOT
|
||||
prevent subsequent /api/signal requests from the same IP.
|
||||
"""
|
||||
async with make_app_client() as client:
|
||||
ip_hdrs = {"X-Real-IP": "8.8.8.8"}
|
||||
|
||||
# First register succeeds and creates the user we'll signal later
|
||||
r0 = await client.post(
|
||||
"/api/register",
|
||||
json={"uuid": _UUID_IND_REG, "name": "Reg"},
|
||||
headers=ip_hdrs,
|
||||
)
|
||||
assert r0.status_code == 200
|
||||
api_key = r0.json()["api_key"]
|
||||
|
||||
# Send 4 more register requests from the same IP (requests 2-5 succeed,
|
||||
# each rotates the api_key; request 6 would be 429).
|
||||
# We keep track of the last api_key since re-registration rotates it.
|
||||
for _ in range(4):
|
||||
r = await client.post(
|
||||
"/api/register",
|
||||
json={"uuid": _UUID_IND_REG, "name": "Reg"},
|
||||
headers=ip_hdrs,
|
||||
)
|
||||
if r.status_code == 200:
|
||||
api_key = r.json()["api_key"]
|
||||
|
||||
# 6th request → 429 (exhausts limit without rotating key)
|
||||
await client.post(
|
||||
"/api/register",
|
||||
json={"uuid": _UUID_IND_REG, "name": "Reg"},
|
||||
headers=ip_hdrs,
|
||||
)
|
||||
|
||||
# Signal must still succeed — signal counter (key='sig:8.8.8.8') is still 0
|
||||
r_sig = await client.post(
|
||||
"/api/signal",
|
||||
json={"user_id": _UUID_IND_REG, "timestamp": 1742478000000},
|
||||
headers={"X-Real-IP": "8.8.8.8", "Authorization": f"Bearer {api_key}"},
|
||||
)
|
||||
|
||||
assert r_sig.status_code == 200, (
|
||||
f"Signal returned {r_sig.status_code} — "
|
||||
"register exhaustion incorrectly bled into signal counter"
|
||||
)
|
||||
|
||||
|
||||
# ── Integration: signal rate limit is per-IP ─────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_signal_rate_limit_is_per_ip_different_ips_are_independent():
|
||||
"""
|
||||
Rate limit counters are per-IP — exhausting for IP A must not block IP B.
|
||||
"""
|
||||
async with make_app_client() as client:
|
||||
api_key_a = await _register_and_get_key(client, _UUID_IP_A, "IPA")
|
||||
api_key_b = await _register_and_get_key(client, _UUID_IP_B, "IPB")
|
||||
|
||||
# Exhaust rate limit for IP A (11 requests → 11th is 429)
|
||||
for _ in range(11):
|
||||
await client.post(
|
||||
"/api/signal",
|
||||
json={"user_id": _UUID_IP_A, "timestamp": 1742478000000},
|
||||
headers={"X-Real-IP": "11.11.11.11", "Authorization": f"Bearer {api_key_a}"},
|
||||
)
|
||||
|
||||
# IP B should still be allowed (independent counter)
|
||||
r = await client.post(
|
||||
"/api/signal",
|
||||
json={"user_id": _UUID_IP_B, "timestamp": 1742478000000},
|
||||
headers={"X-Real-IP": "22.22.22.22", "Authorization": f"Bearer {api_key_b}"},
|
||||
)
|
||||
|
||||
assert r.status_code == 200, f"IP B was incorrectly blocked: {r.status_code}"
|
||||
298
tests/test_sec_003.py
Normal file
298
tests/test_sec_003.py
Normal 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
337
tests/test_sec_006.py
Normal 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}"
|
||||
)
|
||||
240
tests/test_sec_007.py
Normal file
240
tests/test_sec_007.py
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
"""
|
||||
Regression tests for BATON-SEC-007:
|
||||
|
||||
1. Retry loop in telegram.py is bounded to exactly 3 attempts.
|
||||
2. Exponential backoff applies correctly: sleep = retry_after * (attempt + 1).
|
||||
3. POST /api/signal uses asyncio.create_task — HTTP response is not blocked
|
||||
by Telegram rate-limit pauses.
|
||||
4. GET /health returns only {"status": "ok"} — no timestamp field.
|
||||
|
||||
BATON-SEC-003: POST /api/signal now requires Authorization: Bearer <api_key>.
|
||||
Tests that send signals now register first and use the returned api_key.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
|
||||
os.environ.setdefault("BOT_TOKEN", "test-bot-token")
|
||||
os.environ.setdefault("CHAT_ID", "-1001234567890")
|
||||
os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret")
|
||||
os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram")
|
||||
os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000")
|
||||
os.environ.setdefault("ADMIN_TOKEN", "test-admin-token")
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from backend import config
|
||||
from backend.telegram import send_message
|
||||
from tests.conftest import make_app_client
|
||||
|
||||
SEND_URL = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage"
|
||||
|
||||
# Valid UUID v4 constants
|
||||
_UUID_CT = "d4e5f6a7-b8c9-4d0e-a1b2-c3d4e5f6a7b8"
|
||||
_UUID_SLOW = "e5f6a7b8-c9d0-4e1f-a2b3-c4d5e6f7a8b9"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Criterion 1 — retry loop is bounded to max 3 attempts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_loop_stops_after_3_attempts_on_all_429():
|
||||
"""When all 3 responses are 429, send_message makes exactly 3 HTTP requests and stops."""
|
||||
responses = [
|
||||
httpx.Response(429, json={"ok": False, "parameters": {"retry_after": 1}}),
|
||||
httpx.Response(429, json={"ok": False, "parameters": {"retry_after": 1}}),
|
||||
httpx.Response(429, json={"ok": False, "parameters": {"retry_after": 1}}),
|
||||
]
|
||||
with respx.mock(assert_all_called=False) as mock:
|
||||
route = mock.post(SEND_URL).mock(side_effect=responses)
|
||||
with patch("backend.telegram.asyncio.sleep", new_callable=AsyncMock):
|
||||
await send_message("test max 3 attempts")
|
||||
|
||||
assert route.call_count == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_loop_does_not_make_4th_attempt_on_all_429():
|
||||
"""send_message must never attempt a 4th request when the first 3 all return 429."""
|
||||
call_count = 0
|
||||
|
||||
async def _count_and_return_429(_request):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return httpx.Response(429, json={"ok": False, "parameters": {"retry_after": 1}})
|
||||
|
||||
with respx.mock(assert_all_called=False) as mock:
|
||||
mock.post(SEND_URL).mock(side_effect=_count_and_return_429)
|
||||
with patch("backend.telegram.asyncio.sleep", new_callable=AsyncMock):
|
||||
await send_message("test no 4th attempt")
|
||||
|
||||
assert call_count == 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Criterion 2 — exponential backoff: sleep = retry_after * (attempt + 1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_429_first_attempt_sleeps_retry_after_times_1():
|
||||
"""First 429 (attempt 0): sleep duration must be retry_after * 1."""
|
||||
retry_after = 7
|
||||
responses = [
|
||||
httpx.Response(429, json={"ok": False, "parameters": {"retry_after": retry_after}}),
|
||||
httpx.Response(200, json={"ok": True}),
|
||||
]
|
||||
with respx.mock(assert_all_called=False) as mock:
|
||||
mock.post(SEND_URL).mock(side_effect=responses)
|
||||
with patch("backend.telegram.asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
|
||||
await send_message("test attempt 0 backoff")
|
||||
|
||||
mock_sleep.assert_called_once_with(retry_after * 1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_429_exponential_backoff_sleep_sequence():
|
||||
"""Two consecutive 429 responses produce sleep = retry_after*1 then retry_after*2."""
|
||||
retry_after = 10
|
||||
responses = [
|
||||
httpx.Response(429, json={"ok": False, "parameters": {"retry_after": retry_after}}),
|
||||
httpx.Response(429, json={"ok": False, "parameters": {"retry_after": retry_after}}),
|
||||
httpx.Response(200, json={"ok": True}),
|
||||
]
|
||||
with respx.mock(assert_all_called=False) as mock:
|
||||
mock.post(SEND_URL).mock(side_effect=responses)
|
||||
with patch("backend.telegram.asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
|
||||
await send_message("test backoff sequence")
|
||||
|
||||
sleep_args = [c.args[0] for c in mock_sleep.call_args_list]
|
||||
assert retry_after * 1 in sleep_args, f"Expected sleep({retry_after}) not found in {sleep_args}"
|
||||
assert retry_after * 2 in sleep_args, f"Expected sleep({retry_after * 2}) not found in {sleep_args}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_429_third_attempt_sleeps_retry_after_times_3():
|
||||
"""Third 429 (attempt 2): sleep duration must be retry_after * 3."""
|
||||
retry_after = 5
|
||||
responses = [
|
||||
httpx.Response(429, json={"ok": False, "parameters": {"retry_after": retry_after}}),
|
||||
httpx.Response(429, json={"ok": False, "parameters": {"retry_after": retry_after}}),
|
||||
httpx.Response(429, json={"ok": False, "parameters": {"retry_after": retry_after}}),
|
||||
]
|
||||
with respx.mock(assert_all_called=False) as mock:
|
||||
mock.post(SEND_URL).mock(side_effect=responses)
|
||||
with patch("backend.telegram.asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
|
||||
await send_message("test attempt 2 backoff")
|
||||
|
||||
sleep_args = [c.args[0] for c in mock_sleep.call_args_list]
|
||||
assert retry_after * 3 in sleep_args, f"Expected sleep({retry_after * 3}) not found in {sleep_args}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# After exhausting all 3 attempts — error is logged
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_message_all_attempts_exhausted_logs_error(caplog):
|
||||
"""After 3 failed 429 attempts, an ERROR containing 'all 3 attempts' is logged."""
|
||||
responses = [
|
||||
httpx.Response(429, json={"ok": False, "parameters": {"retry_after": 1}}),
|
||||
httpx.Response(429, json={"ok": False, "parameters": {"retry_after": 1}}),
|
||||
httpx.Response(429, json={"ok": False, "parameters": {"retry_after": 1}}),
|
||||
]
|
||||
with respx.mock(assert_all_called=False) as mock:
|
||||
mock.post(SEND_URL).mock(side_effect=responses)
|
||||
with patch("backend.telegram.asyncio.sleep", new_callable=AsyncMock):
|
||||
with caplog.at_level(logging.ERROR, logger="backend.telegram"):
|
||||
await send_message("test exhausted log")
|
||||
|
||||
error_messages = [r.message for r in caplog.records if r.levelno == logging.ERROR]
|
||||
assert any("all 3 attempts" in m.lower() for m in error_messages), (
|
||||
f"Expected 'all 3 attempts' in error logs, got: {error_messages}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Criterion 3 — POST /api/signal uses asyncio.create_task (non-blocking)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_signal_uses_create_task_for_telegram_send_message():
|
||||
"""POST /api/signal must wrap telegram.send_message in asyncio.create_task."""
|
||||
with patch("backend.main.asyncio.create_task", wraps=asyncio.create_task) as mock_ct:
|
||||
async with make_app_client() as client:
|
||||
reg = await client.post("/api/register", json={"uuid": _UUID_CT, "name": "CT"})
|
||||
assert reg.status_code == 200
|
||||
api_key = reg.json()["api_key"]
|
||||
resp = await client.post(
|
||||
"/api/signal",
|
||||
json={"user_id": _UUID_CT, "timestamp": 1742478000000},
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert mock_ct.called, "asyncio.create_task was never called — send_message may have been awaited directly"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_signal_response_returns_before_telegram_completes():
|
||||
"""POST /api/signal returns 200 even when Telegram send_message is delayed."""
|
||||
slow_sleep_called = False
|
||||
|
||||
async def slow_send_message(_text: str) -> None:
|
||||
nonlocal slow_sleep_called
|
||||
slow_sleep_called = True
|
||||
await asyncio.sleep(9999) # would block forever if awaited
|
||||
|
||||
with patch("backend.main.telegram.send_message", side_effect=slow_send_message):
|
||||
with patch("backend.main.asyncio.create_task", wraps=asyncio.create_task):
|
||||
async with make_app_client() as client:
|
||||
reg = await client.post(
|
||||
"/api/register",
|
||||
json={"uuid": _UUID_SLOW, "name": "Slow"},
|
||||
)
|
||||
assert reg.status_code == 200
|
||||
api_key = reg.json()["api_key"]
|
||||
resp = await client.post(
|
||||
"/api/signal",
|
||||
json={
|
||||
"user_id": _UUID_SLOW,
|
||||
"timestamp": 1742478000000,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Criterion 4 — GET /health exact response body (regression guard)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_response_is_exactly_status_ok():
|
||||
"""GET /health body must be exactly {"status": "ok"} — no extra fields."""
|
||||
async with make_app_client() as client:
|
||||
response = await client.get("/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_no_timestamp_field():
|
||||
"""GET /health must not expose a timestamp field (time-based fingerprinting prevention)."""
|
||||
async with make_app_client() as client:
|
||||
response = await client.get("/health")
|
||||
|
||||
assert "timestamp" not in response.json()
|
||||
|
|
@ -1,5 +1,11 @@
|
|||
"""
|
||||
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
|
||||
|
||||
|
|
@ -10,30 +16,42 @@ os.environ.setdefault("CHAT_ID", "-1001234567890")
|
|||
os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret")
|
||||
os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram")
|
||||
os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000")
|
||||
os.environ.setdefault("ADMIN_TOKEN", "test-admin-token")
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from tests.conftest import make_app_client
|
||||
|
||||
# Valid UUID v4 constants for signal tests
|
||||
_UUID_1 = "c0000001-0000-4000-8000-000000000001"
|
||||
_UUID_2 = "c0000002-0000-4000-8000-000000000002"
|
||||
_UUID_3 = "c0000003-0000-4000-8000-000000000003"
|
||||
_UUID_4 = "c0000004-0000-4000-8000-000000000004"
|
||||
_UUID_5 = "c0000005-0000-4000-8000-000000000005"
|
||||
_UUID_6 = "c0000006-0000-4000-8000-000000000006"
|
||||
|
||||
async def _register(client: AsyncClient, uuid: str, name: str) -> 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})
|
||||
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
|
||||
async def test_signal_with_geo_success():
|
||||
"""POST /api/signal with geo returns 200 and signal_id > 0."""
|
||||
async with make_app_client() as client:
|
||||
await _register(client, "sig-uuid-001", "Alice")
|
||||
api_key = await _register(client, _UUID_1, "Alice")
|
||||
resp = await client.post(
|
||||
"/api/signal",
|
||||
json={
|
||||
"user_id": "sig-uuid-001",
|
||||
"user_id": _UUID_1,
|
||||
"timestamp": 1742478000000,
|
||||
"geo": {"lat": 55.7558, "lon": 37.6173, "accuracy": 15.0},
|
||||
},
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
|
|
@ -45,14 +63,15 @@ async def test_signal_with_geo_success():
|
|||
async def test_signal_without_geo_success():
|
||||
"""POST /api/signal with geo: null returns 200."""
|
||||
async with make_app_client() as client:
|
||||
await _register(client, "sig-uuid-002", "Bob")
|
||||
api_key = await _register(client, _UUID_2, "Bob")
|
||||
resp = await client.post(
|
||||
"/api/signal",
|
||||
json={
|
||||
"user_id": "sig-uuid-002",
|
||||
"user_id": _UUID_2,
|
||||
"timestamp": 1742478000000,
|
||||
"geo": None,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "ok"
|
||||
|
|
@ -75,7 +94,7 @@ async def test_signal_missing_timestamp_returns_422():
|
|||
async with make_app_client() as client:
|
||||
resp = await client.post(
|
||||
"/api/signal",
|
||||
json={"user_id": "sig-uuid-003"},
|
||||
json={"user_id": _UUID_3},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
|
@ -87,14 +106,16 @@ async def test_signal_stored_in_db():
|
|||
proving both were persisted.
|
||||
"""
|
||||
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(
|
||||
"/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(
|
||||
"/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 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"
|
||||
|
||||
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
|
||||
resp = await client.post(
|
||||
"/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
|
||||
|
|
@ -126,10 +148,11 @@ async def test_signal_sends_telegram_message_directly():
|
|||
async def test_signal_returns_signal_id_positive():
|
||||
"""signal_id in response is always a positive integer."""
|
||||
async with make_app_client() as client:
|
||||
await _register(client, "sig-uuid-006", "Eve")
|
||||
api_key = await _register(client, _UUID_6, "Eve")
|
||||
resp = await client.post(
|
||||
"/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
|
||||
|
||||
|
|
@ -141,7 +164,7 @@ async def test_signal_geo_invalid_lat_returns_422():
|
|||
resp = await client.post(
|
||||
"/api/signal",
|
||||
json={
|
||||
"user_id": "sig-uuid-007",
|
||||
"user_id": _UUID_1,
|
||||
"timestamp": 1742478000000,
|
||||
"geo": {"lat": 200.0, "lon": 0.0, "accuracy": 10.0},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -34,11 +34,57 @@ import pytest
|
|||
import respx
|
||||
|
||||
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"
|
||||
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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BATON-007: 400 "chat not found" handling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_message_400_chat_not_found_does_not_raise():
|
||||
"""400 'chat not found' must not raise an exception (service stays alive)."""
|
||||
with respx.mock(assert_all_called=False) as mock:
|
||||
mock.post(SEND_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
400,
|
||||
json={"ok": False, "error_code": 400, "description": "Bad Request: chat not found"},
|
||||
)
|
||||
)
|
||||
# Must not raise — service must stay alive even with wrong CHAT_ID
|
||||
await send_message("test")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_message_400_chat_not_found_logs_error(caplog):
|
||||
"""400 response from Telegram must be logged as ERROR with the status code."""
|
||||
import logging
|
||||
|
||||
with respx.mock(assert_all_called=False) as mock:
|
||||
mock.post(SEND_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
400,
|
||||
json={"ok": False, "error_code": 400, "description": "Bad Request: chat not found"},
|
||||
)
|
||||
)
|
||||
with caplog.at_level(logging.ERROR, logger="backend.telegram"):
|
||||
await send_message("test chat not found")
|
||||
|
||||
assert any("400" in record.message for record in caplog.records), (
|
||||
"Expected ERROR log containing '400' but got: " + str([r.message for r in caplog.records])
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_message_400_breaks_after_first_attempt():
|
||||
"""On 400, send_message breaks immediately (no retry loop) — only one HTTP call made."""
|
||||
with respx.mock(assert_all_called=False) as mock:
|
||||
route = mock.post(SEND_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
400,
|
||||
json={"ok": False, "error_code": 400, "description": "Bad Request: chat not found"},
|
||||
)
|
||||
)
|
||||
await send_message("test no retry on 400")
|
||||
|
||||
assert route.call_count == 1, f"Expected 1 call on 400, got {route.call_count}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_message_all_5xx_retries_exhausted_does_not_raise():
|
||||
"""When all 3 attempts fail with 5xx, send_message logs error but does NOT raise."""
|
||||
with respx.mock(assert_all_called=False) as mock:
|
||||
mock.post(SEND_URL).mock(
|
||||
return_value=httpx.Response(500, text="Internal Server Error")
|
||||
)
|
||||
with patch("backend.telegram.asyncio.sleep", new_callable=AsyncMock):
|
||||
# Must not raise — message is dropped, service stays alive
|
||||
await send_message("test all retries exhausted")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aggregator_unknown_user_shows_uuid_prefix():
|
||||
"""If user_name is None, the message shows first 8 chars of uuid."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue