diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index c29bee9..0000000 --- a/.dockerignore +++ /dev/null @@ -1,16 +0,0 @@ -.git -.gitignore -.env -.venv -venv -__pycache__ -*.pyc -*.db -tests/ -docs/ -deploy/ -frontend/ -nginx/ -*.md -.kin_worktrees/ -PROGRESS.md diff --git a/.env.example b/.env.example index 6d8ac36..cf447e0 100644 --- a/.env.example +++ b/.env.example @@ -11,8 +11,3 @@ DB_PATH=baton.db # CORS FRONTEND_ORIGIN=https://yourdomain.com - -# VAPID Push Notifications (generate with: python -c "from py_vapid import Vapid; v=Vapid(); v.generate_keys(); print(v.public_key, v.private_key)") -VAPID_PUBLIC_KEY= -VAPID_PRIVATE_KEY= -VAPID_CLAIMS_EMAIL= diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml deleted file mode 100644 index 7732a47..0000000 --- a/.pre-commit-config.yaml +++ /dev/null @@ -1,11 +0,0 @@ -repos: - - repo: local - hooks: - - id: no-telegram-bot-token - name: Block Telegram bot tokens - # Matches tokens of format: 1234567890:AAFisjLS-yO_AmwqMjpBQgfV9qlHnexZlMs - # Pattern: 9-10 digits, colon, "AA", then 35 alphanumeric/dash/underscore chars - entry: '\d{9,10}:AA[A-Za-z0-9_-]{35}' - language: pygrep - types: [text] - exclude: '^\.pre-commit-config\.yaml$' diff --git a/=2.0.0 b/=2.0.0 deleted file mode 100644 index c8c6c93..0000000 --- a/=2.0.0 +++ /dev/null @@ -1 +0,0 @@ -(eval):1: command not found: pip diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 8345948..0000000 --- a/Dockerfile +++ /dev/null @@ -1,12 +0,0 @@ -FROM python:3.12-slim - -WORKDIR /app - -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -COPY backend/ backend/ - -EXPOSE 8000 - -CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/config.py b/backend/config.py index 305d05e..40159b0 100644 --- a/backend/config.py +++ b/backend/config.py @@ -22,9 +22,3 @@ 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") -ADMIN_CHAT_ID: str = _require("ADMIN_CHAT_ID") -VAPID_PRIVATE_KEY: str = os.getenv("VAPID_PRIVATE_KEY", "") -VAPID_PUBLIC_KEY: str = os.getenv("VAPID_PUBLIC_KEY", "") -VAPID_CLAIMS_EMAIL: str = os.getenv("VAPID_CLAIMS_EMAIL", "") -JWT_SECRET: str = os.getenv("JWT_SECRET", "") -JWT_TOKEN_EXPIRE_SECONDS: int = int(os.getenv("JWT_TOKEN_EXPIRE_SECONDS", "2592000")) # 30 days diff --git a/backend/db.py b/backend/db.py index 6243e04..e0aca18 100644 --- a/backend/db.py +++ b/backend/db.py @@ -1,6 +1,5 @@ from __future__ import annotations -import time from contextlib import asynccontextmanager from typing import AsyncGenerator, Optional import aiosqlite @@ -30,7 +29,6 @@ async def init_db() -> None: name TEXT NOT NULL, is_blocked INTEGER NOT NULL DEFAULT 0, password_hash TEXT DEFAULT NULL, - api_key_hash TEXT DEFAULT NULL, created_at TEXT DEFAULT (datetime('now')) ); @@ -61,43 +59,11 @@ async def init_db() -> None: ON signals(created_at); CREATE INDEX IF NOT EXISTS idx_batches_status ON telegram_batches(status); - - CREATE TABLE IF NOT EXISTS rate_limits ( - ip TEXT NOT NULL PRIMARY KEY, - count INTEGER NOT NULL DEFAULT 0, - window_start REAL NOT NULL DEFAULT 0 - ); - - CREATE TABLE IF NOT EXISTS registrations ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - email TEXT UNIQUE NOT NULL, - login TEXT UNIQUE NOT NULL, - password_hash TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'pending', - push_subscription TEXT DEFAULT NULL, - created_at TEXT DEFAULT (datetime('now')) - ); - - CREATE INDEX IF NOT EXISTS idx_registrations_status - ON registrations(status); - CREATE INDEX IF NOT EXISTS idx_registrations_email - ON registrations(email); - CREATE INDEX IF NOT EXISTS idx_registrations_login - ON registrations(login); - - CREATE TABLE IF NOT EXISTS ip_blocks ( - ip TEXT NOT NULL PRIMARY KEY, - violation_count INTEGER NOT NULL DEFAULT 0, - is_blocked INTEGER NOT NULL DEFAULT 0, - created_at TEXT DEFAULT (datetime('now')), - blocked_at TEXT DEFAULT NULL - ); """) # 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) @@ -107,21 +73,12 @@ async def init_db() -> None: await conn.commit() -async def register_user(uuid: str, name: str, api_key_hash: Optional[str] = None) -> dict: +async def register_user(uuid: str, name: str) -> dict: async with _get_conn() as conn: - if api_key_hash is not None: - await conn.execute( - """ - INSERT INTO users (uuid, name, api_key_hash) VALUES (?, ?, ?) - ON CONFLICT(uuid) DO UPDATE SET api_key_hash = excluded.api_key_hash - """, - (uuid, name, api_key_hash), - ) - else: - await conn.execute( - "INSERT OR IGNORE INTO users (uuid, name) VALUES (?, ?)", - (uuid, name), - ) + await conn.execute( + "INSERT OR IGNORE INTO users (uuid, name) VALUES (?, ?)", + (uuid, name), + ) await conn.commit() async with conn.execute( "SELECT id, uuid FROM users WHERE uuid = ?", (uuid,) @@ -130,15 +87,6 @@ async def register_user(uuid: str, name: str, api_key_hash: Optional[str] = None return {"user_id": row["id"], "uuid": row["uuid"]} -async def get_api_key_hash_by_uuid(uuid: str) -> Optional[str]: - async with _get_conn() as conn: - async with conn.execute( - "SELECT api_key_hash FROM users WHERE uuid = ?", (uuid,) - ) as cur: - row = await cur.fetchone() - return row["api_key_hash"] if row else None - - async def save_signal( user_uuid: str, timestamp: int, @@ -280,110 +228,6 @@ async def admin_delete_user(user_id: int) -> bool: return changed -async def rate_limit_increment(key: str, window: float) -> int: - """Increment rate-limit counter for key within window. Returns current count. - - Cleans up the stale record for this key before incrementing (TTL by window_start). - """ - now = time.time() - async with _get_conn() as conn: - # TTL cleanup: remove stale record for this key if window has expired - await conn.execute( - "DELETE FROM rate_limits WHERE ip = ? AND ? - window_start >= ?", - (key, now, window), - ) - # Upsert: insert new record or increment existing - await conn.execute( - """ - INSERT INTO rate_limits (ip, count, window_start) - VALUES (?, 1, ?) - ON CONFLICT(ip) DO UPDATE SET count = count + 1 - """, - (key, now), - ) - await conn.commit() - async with conn.execute( - "SELECT count FROM rate_limits WHERE ip = ?", (key,) - ) as cur: - row = await cur.fetchone() - return row["count"] if row else 1 - - -async def create_registration( - email: str, - login: str, - password_hash: str, - push_subscription: Optional[str] = None, -) -> int: - """Insert a new registration. Raises aiosqlite.IntegrityError on email/login conflict.""" - async with _get_conn() as conn: - async with conn.execute( - """ - INSERT INTO registrations (email, login, password_hash, push_subscription) - VALUES (?, ?, ?, ?) - """, - (email, login, password_hash, push_subscription), - ) as cur: - reg_id = cur.lastrowid - await conn.commit() - return reg_id # type: ignore[return-value] - - -async def get_registration(reg_id: int) -> Optional[dict]: - async with _get_conn() as conn: - async with conn.execute( - "SELECT id, email, login, status, push_subscription, created_at FROM registrations WHERE id = ?", - (reg_id,), - ) as cur: - row = await cur.fetchone() - if row is None: - return None - return { - "id": row["id"], - "email": row["email"], - "login": row["login"], - "status": row["status"], - "push_subscription": row["push_subscription"], - "created_at": row["created_at"], - } - - -async def update_registration_status(reg_id: int, status: str) -> bool: - """Update registration status only if currently 'pending'. Returns False if already processed.""" - async with _get_conn() as conn: - async with conn.execute( - "UPDATE registrations SET status = ? WHERE id = ? AND status = 'pending'", - (status, reg_id), - ) as cur: - changed = cur.rowcount > 0 - await conn.commit() - return changed - - -async def get_registration_by_login_or_email(login_or_email: str) -> Optional[dict]: - async with _get_conn() as conn: - async with conn.execute( - """ - SELECT id, email, login, password_hash, status, push_subscription, created_at - FROM registrations - WHERE login = ? OR email = ? - """, - (login_or_email, login_or_email), - ) as cur: - row = await cur.fetchone() - if row is None: - return None - return { - "id": row["id"], - "email": row["email"], - "login": row["login"], - "password_hash": row["password_hash"], - "status": row["status"], - "push_subscription": row["push_subscription"], - "created_at": row["created_at"], - } - - async def save_telegram_batch( message_text: str, signals_count: int, @@ -406,36 +250,3 @@ async def save_telegram_batch( ) await conn.commit() return batch_id - - -async def is_ip_blocked(ip: str) -> bool: - async with _get_conn() as conn: - async with conn.execute( - "SELECT is_blocked FROM ip_blocks WHERE ip = ?", (ip,) - ) as cur: - row = await cur.fetchone() - return bool(row["is_blocked"]) if row else False - - -async def record_ip_violation(ip: str) -> int: - """Increment violation count for IP. Returns new count. Blocks IP at threshold.""" - async with _get_conn() as conn: - await conn.execute( - """ - INSERT INTO ip_blocks (ip, violation_count) VALUES (?, 1) - ON CONFLICT(ip) DO UPDATE SET violation_count = violation_count + 1 - """, - (ip,), - ) - async with conn.execute( - "SELECT violation_count FROM ip_blocks WHERE ip = ?", (ip,) - ) as cur: - row = await cur.fetchone() - count = row["violation_count"] - if count >= 5: - await conn.execute( - "UPDATE ip_blocks SET is_blocked = 1, blocked_at = datetime('now') WHERE ip = ?", - (ip,), - ) - await conn.commit() - return count diff --git a/backend/main.py b/backend/main.py index 672cfaa..38207f0 100644 --- a/backend/main.py +++ b/backend/main.py @@ -2,60 +2,34 @@ from __future__ import annotations import asyncio import hashlib -import hmac import logging import os -import secrets +import time from contextlib import asynccontextmanager from datetime import datetime, timezone -from typing import Any, Optional +from typing import Any import httpx from fastapi import Depends, FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse -from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer -from backend import config, db, push, telegram -from backend.middleware import ( - _get_client_ip, - _verify_jwt_token, - check_ip_not_blocked, - create_auth_token, - rate_limit_auth_login, - rate_limit_auth_register, - rate_limit_register, - rate_limit_signal, - verify_admin_token, - verify_webhook_secret, -) +from backend import config, db, telegram +from backend.middleware import rate_limit_register, verify_admin_token, verify_webhook_secret from backend.models import ( AdminBlockRequest, AdminCreateUserRequest, AdminSetPasswordRequest, - AuthLoginRequest, - AuthLoginResponse, - AuthRegisterRequest, - AuthRegisterResponse, RegisterRequest, RegisterResponse, SignalRequest, SignalResponse, ) -_api_key_bearer = HTTPBearer(auto_error=False) - logging.basicConfig(level=logging.INFO) -logging.getLogger("httpx").setLevel(logging.WARNING) -logging.getLogger("httpcore").setLevel(logging.WARNING) 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). @@ -65,18 +39,6 @@ def _hash_password(password: str) -> str: dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 260_000) return f"{salt.hex()}:{dk.hex()}" -def _verify_password(password: str, stored_hash: str) -> bool: - """Verify a password against a stored PBKDF2-HMAC-SHA256 hash (salt_hex:dk_hex).""" - try: - salt_hex, dk_hex = stored_hash.split(":", 1) - salt = bytes.fromhex(salt_hex) - expected_dk = bytes.fromhex(dk_hex) - actual_dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 260_000) - return hmac.compare_digest(actual_dk, expected_dk) - except Exception: - return False - - # aggregator = telegram.SignalAggregator(interval=10) # v2.0 feature — отключено в v1 (ADR-004) _KEEPALIVE_INTERVAL = 600 # 10 минут @@ -98,14 +60,10 @@ async def _keep_alive_loop(app_url: str) -> None: @asynccontextmanager async def lifespan(app: FastAPI): # Startup + app.state.rate_counters = {} await db.init_db() logger.info("Database initialized") - if not await telegram.validate_bot_token(): - logger.error( - "CRITICAL: BOT_TOKEN is invalid — Telegram delivery is broken. Update .env and restart." - ) - if config.WEBHOOK_ENABLED: await telegram.set_webhook(url=config.WEBHOOK_URL, secret=config.WEBHOOK_SECRET) logger.info("Webhook registered") @@ -147,68 +105,27 @@ app = FastAPI(lifespan=lifespan) app.add_middleware( CORSMiddleware, allow_origins=[config.FRONTEND_ORIGIN], - allow_methods=["GET", "HEAD", "OPTIONS", "POST"], - allow_headers=["Content-Type", "Authorization"], + allow_methods=["POST"], + allow_headers=["Content-Type"], ) @app.get("/health") @app.get("/api/health") async def health() -> dict[str, Any]: - return {"status": "ok"} - - -@app.get("/api/vapid-public-key") -@app.get("/api/push/public-key") -async def vapid_public_key() -> dict[str, str]: - return {"vapid_public_key": config.VAPID_PUBLIC_KEY} + return {"status": "ok", "timestamp": int(time.time())} @app.post("/api/register", response_model=RegisterResponse) async def register(body: RegisterRequest, _: None = Depends(rate_limit_register)) -> RegisterResponse: - api_key = secrets.token_hex(32) - result = await db.register_user(uuid=body.uuid, name=body.name, api_key_hash=_hash_api_key(api_key)) - return RegisterResponse(user_id=result["user_id"], uuid=result["uuid"], api_key=api_key) + result = await db.register_user(uuid=body.uuid, name=body.name) + return RegisterResponse(user_id=result["user_id"], uuid=result["uuid"]) @app.post("/api/signal", response_model=SignalResponse) -async def signal( - body: SignalRequest, - credentials: Optional[HTTPAuthorizationCredentials] = Depends(_api_key_bearer), - _: None = Depends(rate_limit_signal), -) -> SignalResponse: - if credentials is None: - raise HTTPException(status_code=401, detail="Unauthorized") - - user_identifier: str = "" - user_name: str = "" - - # Try JWT auth first (new registration flow) - jwt_payload = None - try: - jwt_payload = _verify_jwt_token(credentials.credentials) - except Exception: - pass - - if jwt_payload is not None: - reg_id = int(jwt_payload["sub"]) - reg = await db.get_registration(reg_id) - if reg is None or reg["status"] != "approved": - raise HTTPException(status_code=401, detail="Unauthorized") - user_identifier = reg["login"] - user_name = reg["login"] - else: - # Legacy api_key auth - if not body.user_id: - 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") - user_identifier = body.user_id - user_name = await db.get_user_name(body.user_id) or body.user_id[:8] +async def signal(body: SignalRequest) -> SignalResponse: + 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 @@ -216,157 +133,31 @@ async def signal( accuracy = geo.accuracy if geo else None signal_id = await db.save_signal( - user_uuid=user_identifier, + user_uuid=body.user_id, timestamp=body.timestamp, lat=lat, lon=lon, accuracy=accuracy, ) + user_name = await db.get_user_name(body.user_id) ts = datetime.fromtimestamp(body.timestamp / 1000, tz=timezone.utc) + name = user_name or body.user_id[:8] geo_info = ( - f"📍 {lat}, {lon} (±{accuracy:.0f}м)" + f"📍 {lat}, {lon} (±{accuracy}м)" if geo - else "Гео нету" + else "Без геолокации" ) - if body.is_test: - text = ( - f"🧪 Тест от {user_name}\n" - f"⏰ {ts.strftime('%H:%M:%S')} UTC\n" - f"{geo_info}" - ) - else: - text = ( - f"🚨 Сигнал от {user_name}\n" - f"⏰ {ts.strftime('%H:%M:%S')} UTC\n" - f"{geo_info}" - ) - asyncio.create_task(telegram.send_message(text)) + text = ( + f"🚨 Сигнал от {name}\n" + f"⏰ {ts.strftime('%H:%M:%S')} UTC\n" + f"{geo_info}" + ) + await telegram.send_message(text) return SignalResponse(status="ok", signal_id=signal_id) -_ALLOWED_EMAIL_DOMAIN = "tutlot.com" -_VIOLATION_BLOCK_THRESHOLD = 5 - -@app.post("/api/auth/register", response_model=AuthRegisterResponse, status_code=201) -async def auth_register( - request: Request, - body: AuthRegisterRequest, - _: None = Depends(rate_limit_auth_register), - __: None = Depends(check_ip_not_blocked), -) -> AuthRegisterResponse: - # Domain verification (server-side only) - email_str = str(body.email) - domain = email_str.rsplit("@", 1)[-1].lower() if "@" in email_str else "" - if domain != _ALLOWED_EMAIL_DOMAIN: - client_ip = _get_client_ip(request) - count = await db.record_ip_violation(client_ip) - logger.warning("Domain violation from %s (attempt %d): %s", client_ip, count, email_str) - raise HTTPException( - status_code=403, - detail="Ваш IP отправлен компетентным службам и за вами уже выехали. Ожидайте.", - ) - - password_hash = _hash_password(body.password) - push_sub_json = ( - body.push_subscription.model_dump_json() if body.push_subscription else None - ) - try: - reg_id = await db.create_registration( - email=email_str, - login=body.login, - password_hash=password_hash, - push_subscription=push_sub_json, - ) - except Exception as exc: - # aiosqlite.IntegrityError on email/login UNIQUE conflict - if "UNIQUE" in str(exc) or "unique" in str(exc).lower(): - raise HTTPException(status_code=409, detail="Email или логин уже существует") - raise - reg = await db.get_registration(reg_id) - asyncio.create_task( - telegram.send_registration_notification( - reg_id=reg_id, - login=body.login, - email=email_str, - created_at=reg["created_at"] if reg else "", - ) - ) - return AuthRegisterResponse(status="pending", message="Заявка отправлена на рассмотрение") - - -@app.post("/api/auth/login", response_model=AuthLoginResponse) -async def auth_login( - body: AuthLoginRequest, - _: None = Depends(rate_limit_auth_login), - __: None = Depends(check_ip_not_blocked), -) -> AuthLoginResponse: - reg = await db.get_registration_by_login_or_email(body.login_or_email) - if reg is None or not _verify_password(body.password, reg["password_hash"]): - raise HTTPException(status_code=401, detail="Неверный логин или пароль") - if reg["status"] == "pending": - raise HTTPException(status_code=403, detail="Ваша заявка ожидает рассмотрения") - if reg["status"] == "rejected": - raise HTTPException(status_code=403, detail="Ваша заявка отклонена") - if reg["status"] != "approved": - raise HTTPException(status_code=403, detail="Доступ запрещён") - token = create_auth_token(reg["id"], reg["login"]) - return AuthLoginResponse(token=token, login=reg["login"]) - - -async def _handle_callback_query(cb: dict) -> None: - """Process approve/reject callback from admin Telegram inline buttons.""" - data = cb.get("data", "") - callback_query_id = cb.get("id", "") - message = cb.get("message", {}) - chat_id = message.get("chat", {}).get("id") - message_id = message.get("message_id") - - if ":" not in data: - return - action, reg_id_str = data.split(":", 1) - try: - reg_id = int(reg_id_str) - except ValueError: - return - - reg = await db.get_registration(reg_id) - if reg is None: - await telegram.answer_callback_query(callback_query_id) - return - - if action == "approve": - updated = await db.update_registration_status(reg_id, "approved") - if not updated: - # Already processed (not pending) — ack the callback and stop - await telegram.answer_callback_query(callback_query_id) - return - if chat_id and message_id: - await telegram.edit_message_text( - chat_id, message_id, f"✅ Пользователь {reg['login']} одобрен" - ) - if reg["push_subscription"]: - asyncio.create_task( - push.send_push( - reg["push_subscription"], - "Baton", - "Ваша регистрация одобрена!", - ) - ) - elif action == "reject": - updated = await db.update_registration_status(reg_id, "rejected") - if not updated: - await telegram.answer_callback_query(callback_query_id) - return - if chat_id and message_id: - await telegram.edit_message_text( - chat_id, message_id, f"❌ Пользователь {reg['login']} отклонён" - ) - - await telegram.answer_callback_query(callback_query_id) - - @app.get("/admin/users", dependencies=[Depends(verify_admin_token)]) async def admin_list_users() -> list[dict]: return await db.admin_list_users() @@ -411,13 +202,6 @@ async def webhook_telegram( _: None = Depends(verify_webhook_secret), ) -> dict[str, Any]: update = await request.json() - - # Handle inline button callback queries (approve/reject registration) - callback_query = update.get("callback_query") - if callback_query: - await _handle_callback_query(callback_query) - return {"ok": True} - message = update.get("message", {}) text = message.get("text", "") diff --git a/backend/middleware.py b/backend/middleware.py index 55f4269..a384c84 100644 --- a/backend/middleware.py +++ b/backend/middleware.py @@ -1,9 +1,5 @@ from __future__ import annotations -import base64 -import hashlib -import hmac -import json import secrets import time from typing import Optional @@ -11,39 +7,13 @@ from typing import Optional from fastapi import Depends, Header, HTTPException, Request from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer -from backend import config, db - -# JWT secret: stable across restarts if JWT_SECRET env var is set; random per-process otherwise -_JWT_SECRET: str = config.JWT_SECRET or secrets.token_hex(32) -_JWT_HEADER_B64: str = ( - base64.urlsafe_b64encode(b'{"alg":"HS256","typ":"JWT"}').rstrip(b"=").decode() -) +from backend import config _bearer = HTTPBearer(auto_error=False) _RATE_LIMIT = 5 _RATE_WINDOW = 600 # 10 minutes -_SIGNAL_RATE_LIMIT = 10 -_SIGNAL_RATE_WINDOW = 60 # 1 minute - -_AUTH_REGISTER_RATE_LIMIT = 3 -_AUTH_REGISTER_RATE_WINDOW = 600 # 10 minutes - - -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 check_ip_not_blocked(request: Request) -> None: - ip = _get_client_ip(request) - if await db.is_ip_blocked(ip): - raise HTTPException(status_code=403, detail="Доступ запрещён") - async def verify_webhook_secret( x_telegram_bot_api_secret_token: str = Header(default=""), @@ -64,92 +34,14 @@ async def verify_admin_token( async def rate_limit_register(request: Request) -> None: - key = f"reg:{_get_client_ip(request)}" - count = await db.rate_limit_increment(key, _RATE_WINDOW) + counters = request.app.state.rate_counters + client_ip = request.client.host if request.client else "unknown" + now = time.time() + count, window_start = counters.get(client_ip, (0, now)) + if now - window_start >= _RATE_WINDOW: + count = 0 + window_start = now + count += 1 + counters[client_ip] = (count, window_start) if count > _RATE_LIMIT: raise HTTPException(status_code=429, detail="Too Many Requests") - - -async def rate_limit_signal(request: Request) -> None: - key = f"sig:{_get_client_ip(request)}" - count = await db.rate_limit_increment(key, _SIGNAL_RATE_WINDOW) - if count > _SIGNAL_RATE_LIMIT: - raise HTTPException(status_code=429, detail="Too Many Requests") - - -async def rate_limit_auth_register(request: Request) -> None: - key = f"authreg:{_get_client_ip(request)}" - count = await db.rate_limit_increment(key, _AUTH_REGISTER_RATE_WINDOW) - if count > _AUTH_REGISTER_RATE_LIMIT: - raise HTTPException(status_code=429, detail="Too Many Requests") - - -_AUTH_LOGIN_RATE_LIMIT = 5 -_AUTH_LOGIN_RATE_WINDOW = 900 # 15 minutes - - -def _b64url_encode(data: bytes) -> str: - return base64.urlsafe_b64encode(data).rstrip(b"=").decode() - - -def _b64url_decode(s: str) -> bytes: - padding = 4 - len(s) % 4 - if padding != 4: - s += "=" * padding - return base64.urlsafe_b64decode(s) - - -def create_auth_token(reg_id: int, login: str) -> str: - """Create a signed HS256 JWT for an approved registration.""" - now = int(time.time()) - payload = { - "sub": str(reg_id), - "login": login, - "iat": now, - "exp": now + config.JWT_TOKEN_EXPIRE_SECONDS, - } - payload_b64 = _b64url_encode(json.dumps(payload, separators=(",", ":")).encode()) - signing_input = f"{_JWT_HEADER_B64}.{payload_b64}" - sig = hmac.new( - _JWT_SECRET.encode(), signing_input.encode(), hashlib.sha256 - ).digest() - return f"{signing_input}.{_b64url_encode(sig)}" - - -def _verify_jwt_token(token: str) -> dict: - """Verify token signature and expiry. Returns payload dict on success.""" - parts = token.split(".") - if len(parts) != 3: - raise ValueError("Invalid token format") - header_b64, payload_b64, sig_b64 = parts - signing_input = f"{header_b64}.{payload_b64}" - expected_sig = hmac.new( - _JWT_SECRET.encode(), signing_input.encode(), hashlib.sha256 - ).digest() - actual_sig = _b64url_decode(sig_b64) - if not hmac.compare_digest(expected_sig, actual_sig): - raise ValueError("Invalid signature") - payload = json.loads(_b64url_decode(payload_b64)) - if payload.get("exp", 0) < time.time(): - raise ValueError("Token expired") - return payload - - -async def rate_limit_auth_login(request: Request) -> None: - key = f"login:{_get_client_ip(request)}" - count = await db.rate_limit_increment(key, _AUTH_LOGIN_RATE_WINDOW) - if count > _AUTH_LOGIN_RATE_LIMIT: - raise HTTPException(status_code=429, detail="Too Many Requests") - - -async def verify_auth_token( - credentials: Optional[HTTPAuthorizationCredentials] = Depends(_bearer), -) -> dict: - """Dependency for protected endpoints — verifies Bearer JWT, returns payload.""" - if credentials is None: - raise HTTPException(status_code=401, detail="Unauthorized") - try: - payload = _verify_jwt_token(credentials.credentials) - except Exception: - raise HTTPException(status_code=401, detail="Unauthorized") - return payload diff --git a/backend/models.py b/backend/models.py index 91c0941..b89e884 100644 --- a/backend/models.py +++ b/backend/models.py @@ -1,18 +1,17 @@ from __future__ import annotations from typing import Optional -from pydantic import BaseModel, EmailStr, Field +from pydantic import BaseModel, Field class RegisterRequest(BaseModel): - uuid: str = Field(..., pattern=r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$') + uuid: str = Field(..., min_length=1) name: str = Field(..., min_length=1, max_length=100) class RegisterResponse(BaseModel): user_id: int uuid: str - api_key: str class GeoData(BaseModel): @@ -22,10 +21,9 @@ class GeoData(BaseModel): class SignalRequest(BaseModel): - user_id: Optional[str] = None # UUID for legacy api_key auth; omit for JWT auth + user_id: str = Field(..., min_length=1) timestamp: int = Field(..., gt=0) geo: Optional[GeoData] = None - is_test: bool = False class SignalResponse(BaseModel): @@ -45,35 +43,3 @@ class AdminSetPasswordRequest(BaseModel): class AdminBlockRequest(BaseModel): is_blocked: bool - - -class PushSubscriptionKeys(BaseModel): - p256dh: str - auth: str - - -class PushSubscription(BaseModel): - endpoint: str - keys: PushSubscriptionKeys - - -class AuthRegisterRequest(BaseModel): - email: EmailStr - login: str = Field(..., min_length=3, max_length=30, pattern=r'^[a-zA-Z0-9_-]+$') - password: str = Field(..., min_length=8, max_length=128) - push_subscription: Optional[PushSubscription] = None - - -class AuthRegisterResponse(BaseModel): - status: str - message: str - - -class AuthLoginRequest(BaseModel): - login_or_email: str = Field(..., min_length=1, max_length=255) - password: str = Field(..., min_length=1, max_length=128) - - -class AuthLoginResponse(BaseModel): - token: str - login: str diff --git a/backend/push.py b/backend/push.py deleted file mode 100644 index c86f799..0000000 --- a/backend/push.py +++ /dev/null @@ -1,35 +0,0 @@ -from __future__ import annotations - -import asyncio -import json -import logging - -from backend import config - -logger = logging.getLogger(__name__) - - -async def send_push(subscription_json: str, title: str, body: str) -> None: - """Send a Web Push notification. Swallows all errors — never raises.""" - if not config.VAPID_PRIVATE_KEY: - logger.warning("VAPID_PRIVATE_KEY not configured — push notification skipped") - return - try: - import pywebpush # type: ignore[import] - except ImportError: - logger.warning("pywebpush not installed — push notification skipped") - return - try: - subscription_info = json.loads(subscription_json) - data = json.dumps({"title": title, "body": body}) - vapid_claims = {"sub": f"mailto:{config.VAPID_CLAIMS_EMAIL or 'admin@example.com'}"} - - await asyncio.to_thread( - pywebpush.webpush, - subscription_info=subscription_info, - data=data, - vapid_private_key=config.VAPID_PRIVATE_KEY, - vapid_claims=vapid_claims, - ) - except Exception as exc: - logger.error("Web Push failed: %s", exc) diff --git a/backend/telegram.py b/backend/telegram.py index 881bb23..0436dea 100644 --- a/backend/telegram.py +++ b/backend/telegram.py @@ -14,123 +14,27 @@ logger = logging.getLogger(__name__) _TELEGRAM_API = "https://api.telegram.org/bot{token}/{method}" -def _mask_token(token: str) -> str: - """Return a safe representation of the bot token for logging.""" - if not token or len(token) < 4: - return "***REDACTED***" - return f"***{token[-4:]}" - - -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: - # Do not log `exc` directly — it may contain the API URL with the token - # embedded (httpx includes request URL in some exception types/versions). - logger.error( - "BOT_TOKEN validation failed (network error): %s — token ends with %s", - type(exc).__name__, - _mask_token(config.BOT_TOKEN), - ) - return False - - async def send_message(text: str) -> None: url = _TELEGRAM_API.format(token=config.BOT_TOKEN, method="sendMessage") async with httpx.AsyncClient(timeout=10) as client: - for attempt in range(3): - resp = await client.post(url, json={"chat_id": config.CHAT_ID, "text": text, "parse_mode": "HTML"}) + while True: + resp = await client.post(url, json={"chat_id": config.CHAT_ID, "text": text}) if resp.status_code == 429: retry_after = resp.json().get("parameters", {}).get("retry_after", 30) - sleep = retry_after * (attempt + 1) - logger.warning("Telegram 429, sleeping %s sec (attempt %d)", sleep, attempt + 1) - await asyncio.sleep(sleep) + logger.warning("Telegram 429, sleeping %s sec", retry_after) + await asyncio.sleep(retry_after) continue if resp.status_code >= 500: logger.error("Telegram 5xx: %s", resp.text) await asyncio.sleep(30) - continue + resp2 = await client.post( + url, json={"chat_id": config.CHAT_ID, "text": text} + ) + if resp2.status_code != 200: + logger.error("Telegram retry failed: %s", resp2.text) elif resp.status_code != 200: logger.error("Telegram error %s: %s", resp.status_code, resp.text) break - else: - logger.error("Telegram send_message: all 3 attempts failed, message dropped") - - -async def send_registration_notification( - reg_id: int, login: str, email: str, created_at: str -) -> None: - """Send registration request notification to admin with approve/reject inline buttons. - Swallows all errors — never raises.""" - url = _TELEGRAM_API.format(token=config.BOT_TOKEN, method="sendMessage") - text = ( - f"📋 Новая заявка на регистрацию\n\n" - f"Login: {login}\nEmail: {email}\nДата: {created_at}" - ) - reply_markup = { - "inline_keyboard": [[ - {"text": "✅ Одобрить", "callback_data": f"approve:{reg_id}"}, - {"text": "❌ Отклонить", "callback_data": f"reject:{reg_id}"}, - ]] - } - try: - async with httpx.AsyncClient(timeout=10) as client: - resp = await client.post( - url, - json={ - "chat_id": config.ADMIN_CHAT_ID, - "text": text, - "reply_markup": reply_markup, - }, - ) - if resp.status_code != 200: - logger.error( - "send_registration_notification failed %s: %s", - resp.status_code, - resp.text, - ) - except Exception as exc: - # Do not log exc directly — httpx exceptions embed the full API URL with BOT_TOKEN - logger.error("send_registration_notification error: %s", type(exc).__name__) - - -async def answer_callback_query(callback_query_id: str) -> None: - """Answer a Telegram callback query. Swallows all errors.""" - url = _TELEGRAM_API.format(token=config.BOT_TOKEN, method="answerCallbackQuery") - try: - async with httpx.AsyncClient(timeout=10) as client: - resp = await client.post(url, json={"callback_query_id": callback_query_id}) - if resp.status_code != 200: - logger.error("answerCallbackQuery failed %s: %s", resp.status_code, resp.text) - except Exception as exc: - # Do not log exc directly — httpx exceptions embed the full API URL with BOT_TOKEN - logger.error("answerCallbackQuery error: %s", type(exc).__name__) - - -async def edit_message_text(chat_id: str | int, message_id: int, text: str) -> None: - """Edit a Telegram message text. Swallows all errors.""" - url = _TELEGRAM_API.format(token=config.BOT_TOKEN, method="editMessageText") - try: - async with httpx.AsyncClient(timeout=10) as client: - resp = await client.post( - url, json={"chat_id": chat_id, "message_id": message_id, "text": text} - ) - if resp.status_code != 200: - logger.error("editMessageText failed %s: %s", resp.status_code, resp.text) - except Exception as exc: - # Do not log exc directly — httpx exceptions embed the full API URL with BOT_TOKEN - logger.error("editMessageText error: %s", type(exc).__name__) async def set_webhook(url: str, secret: str) -> None: @@ -143,3 +47,76 @@ async def set_webhook(url: str, secret: str) -> None: raise RuntimeError(f"setWebhook failed: {resp.text}") logger.info("Webhook registered: %s", url) + +# v2.0 feature +class SignalAggregator: + def __init__(self, interval: int = 10) -> None: + self._interval = interval + self._buffer: list[dict] = [] + self._lock = asyncio.Lock() + self._stopped = False + + async def add_signal( + self, + user_uuid: str, + user_name: Optional[str], + timestamp: int, + geo: Optional[dict], + signal_id: int, + ) -> None: + async with self._lock: + self._buffer.append( + { + "user_uuid": user_uuid, + "user_name": user_name, + "timestamp": timestamp, + "geo": geo, + "signal_id": signal_id, + } + ) + + async def flush(self) -> None: + async with self._lock: + if not self._buffer: + return + items = self._buffer[:] + self._buffer.clear() + + signal_ids = [item["signal_id"] for item in items] + timestamps = [item["timestamp"] for item in items] + ts_start = datetime.fromtimestamp(min(timestamps) / 1000, tz=timezone.utc) + ts_end = datetime.fromtimestamp(max(timestamps) / 1000, tz=timezone.utc) + t_fmt = "%H:%M:%S" + + names = [] + for item in items: + name = item["user_name"] + label = name if name else item["user_uuid"][:8] + names.append(label) + + geo_count = sum(1 for item in items if item["geo"]) + n = len(items) + + text = ( + f"\U0001f6a8 Получено {n} сигнал{'ов' if n != 1 else ''} " + f"[{ts_start.strftime(t_fmt)}—{ts_end.strftime(t_fmt)}]\n" + f"Пользователи: {', '.join(names)}\n" + f"\U0001f4cd С геолокацией: {geo_count} из {n}" + ) + + try: + await send_message(text) + await db.save_telegram_batch(text, n, signal_ids) + # rate-limit: 1 msg/sec max (#1014) + await asyncio.sleep(1) + except Exception: + logger.exception("Failed to flush aggregator batch") + + async def run(self) -> None: + while not self._stopped: + await asyncio.sleep(self._interval) + if self._buffer: + await self.flush() + + def stop(self) -> None: + self._stopped = True diff --git a/deploy/baton.service b/deploy/baton.service index ec8be1f..141d6b6 100644 --- a/deploy/baton.service +++ b/deploy/baton.service @@ -8,7 +8,6 @@ Type=simple User=www-data WorkingDirectory=/opt/baton EnvironmentFile=/opt/baton/.env -ExecStartPre=/opt/baton/venv/bin/pip install -r /opt/baton/requirements.txt -q ExecStart=/opt/baton/venv/bin/uvicorn backend.main:app --host 127.0.0.1 --port 8000 Restart=on-failure RestartSec=5s diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index f3a1680..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,23 +0,0 @@ -services: - backend: - build: . - restart: unless-stopped - env_file: .env - environment: - DB_PATH: /data/baton.db - volumes: - - db_data:/data - - nginx: - image: nginx:alpine - restart: unless-stopped - ports: - - "127.0.0.1:8080:80" - volumes: - - ./frontend:/usr/share/nginx/html:ro - - ./nginx/docker.conf:/etc/nginx/conf.d/default.conf:ro - depends_on: - - backend - -volumes: - db_data: diff --git a/docs/DESIGN_BATON008.md b/docs/DESIGN_BATON008.md deleted file mode 100644 index 6bf2a49..0000000 --- a/docs/DESIGN_BATON008.md +++ /dev/null @@ -1,300 +0,0 @@ -# DESIGN_BATON008 — Регистрационный flow с Telegram-апрувом - -## Flow диаграмма - -``` -Пользователь Backend Telegram PWA / Service Worker - | | | | - |-- POST /api/auth/-------->| | | - | register | | | - | {email,login,pwd,push} | | | - | |-- validate input | | - | |-- hash password (PBKDF2) | | - | |-- INSERT registrations | | - | | (status=pending) | | - |<-- 201 {status:pending} --| | | - | |-- create_task ─────────────>| | - | | send_registration_ | | - | | notification() | | - | | | | - | | [Admin видит сообщение с кнопками] | - | | [✅ Одобрить / ❌ Отклонить] | - | | | | - | |<-- POST /api/webhook/ -----| (callback_query) | - | | telegram | | - | |-- parse callback_data | | - | |-- UPDATE registrations | | - | | SET status='approved' | | - | |-- answerCallbackQuery ─────>| | - | |-- editMessageText ─────────>| | - | |-- create_task | | - | | send_push() ─────────────────────────────────────>| - | | | [Push: "Одобрен!"] | - |<-- 200 {"ok": True} ------| | | -``` - -## API контракт - -### POST /api/auth/register - -**Request body:** -```json -{ - "email": "user@example.com", - "login": "user_name", - "password": "securepass", - "push_subscription": { - "endpoint": "https://fcm.googleapis.com/fcm/send/...", - "keys": { - "p256dh": "BNcR...", - "auth": "tBHI..." - } - } -} -``` -`push_subscription` — nullable. Если null, push при одобрении не отправляется. - -**Validation:** -- `email`: формат email (Pydantic EmailStr или regex `[^@]+@[^@]+\.[^@]+`) -- `login`: 3–30 символов, `[a-zA-Z0-9_-]` -- `password`: минимум 8 символов -- `push_subscription`: nullable object - -**Response 201:** -```json -{"status": "pending", "message": "Заявка отправлена на рассмотрение"} -``` - -**Response 409 (дубль email или login):** -```json -{"detail": "Пользователь с таким email или логином уже существует"} -``` - -**Response 429:** rate limit (через существующий `rate_limit_register` middleware) - -**Response 422:** невалидные поля (Pydantic автоматически) - ---- - -### POST /api/webhook/telegram (расширение) - -Существующий эндпоинт. Добавляется ветка обработки `callback_query`: - -**Входящий update (approve):** -```json -{ - "callback_query": { - "id": "123456789", - "data": "approve:42", - "message": { - "message_id": 777, - "chat": {"id": 5694335584} - } - } -} -``` - -**Поведение при `approve:{id}`:** -1. `UPDATE registrations SET status='approved' WHERE id=?` -2. Fetch registration row (для получения login и push_subscription) -3. `answerCallbackQuery(callback_query_id)` -4. `editMessageText(chat_id, message_id, "✅ Пользователь {login} одобрен")` -5. Если `push_subscription IS NOT NULL` → `create_task(send_push(...))` -6. Вернуть `{"ok": True}` - -**Поведение при `reject:{id}`:** -1. `UPDATE registrations SET status='rejected' WHERE id=?` -2. `answerCallbackQuery(callback_query_id)` -3. `editMessageText(chat_id, message_id, "❌ Пользователь {login} отклонён")` -4. Push НЕ отправляется -5. Вернуть `{"ok": True}` - ---- - -## SQL миграция - -```sql --- В init_db(), добавить в executescript: -CREATE TABLE IF NOT EXISTS registrations ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - email TEXT UNIQUE NOT NULL, - login TEXT UNIQUE NOT NULL, - password_hash TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'pending', - push_subscription TEXT DEFAULT NULL, - created_at TEXT DEFAULT (datetime('now')) -); - -CREATE UNIQUE INDEX IF NOT EXISTS idx_registrations_email - ON registrations(email); -CREATE UNIQUE INDEX IF NOT EXISTS idx_registrations_login - ON registrations(login); -CREATE INDEX IF NOT EXISTS idx_registrations_status - ON registrations(status); -``` - -Таблица создаётся через `CREATE TABLE IF NOT EXISTS` — backward compatible, не ломает существующие БД. - ---- - -## Список изменяемых файлов - -| Файл | Тип изменения | Суть | -|------|--------------|------| -| `backend/db.py` | Modify | Добавить таблицу `registrations` в `init_db()` + 3 функции CRUD | -| `backend/config.py` | Modify | Добавить `ADMIN_CHAT_ID`, `VAPID_PRIVATE_KEY`, `VAPID_PUBLIC_KEY`, `VAPID_CLAIMS_EMAIL` | -| `backend/models.py` | Modify | Добавить `PushKeys`, `PushSubscription`, `AuthRegisterRequest`, `AuthRegisterResponse` | -| `backend/telegram.py` | Modify | Добавить `send_registration_notification()`, `answer_callback_query()`, `edit_message_text()` | -| `backend/main.py` | Modify | Добавить `POST /api/auth/register` + callback_query ветку в webhook | -| `backend/push.py` | **New** | Отправка Web Push через pywebpush | -| `requirements.txt` | Modify | Добавить `pywebpush>=2.0.0` | -| `tests/test_baton_008.py` | **New** | Тесты для нового flow | - -**НЕ трогать:** `backend/middleware.py`, `/api/register`, `users` таблица. - -**Замечание:** CORS `allow_headers` уже содержит `Authorization` в `main.py:122` — изменение не требуется. - ---- - -## Интеграционные точки с существующим кодом - -### 1. `_hash_password()` в `main.py` -Функция уже существует (строки 41–48). Dev agent должен **переиспользовать её напрямую** в новом endpoint `POST /api/auth/register`, не дублируя логику. - -### 2. `rate_limit_register` middleware -Существующий middleware из `backend/middleware.py` может быть подключён к новому endpoint как `Depends(rate_limit_register)` — тот же ключ `reg:{ip}`, та же логика. - -### 3. `telegram.send_message()` — не модифицировать -Существующая функция использует `config.CHAT_ID` для SOS-сигналов. Для регистрационных уведомлений создаётся отдельная функция `send_registration_notification()`, которая использует `config.ADMIN_CHAT_ID`. Это разделяет два потока уведомлений. - -### 4. Webhook handler (строки 223–242 в `main.py`) -Добавляется ветка в начало функции (до `message = update.get("message", {})`): -```python -callback_query = update.get("callback_query") -if callback_query: - asyncio.create_task(_handle_callback_query(callback_query)) - return {"ok": True} -``` -Существующая логика `/start` остаётся нетронутой. - -### 5. `lifespan` в `main.py` -Никаких изменений — VAPID-ключи не требуют startup validation (unlike BOT_TOKEN), так как их инвалидация некритична для работы сервиса в целом. - ---- - -## Спецификация новых компонентов - -### `backend/db.py` — 3 новые функции - -``` -create_registration(email, login, password_hash, push_subscription) -> dict | None - INSERT INTO registrations ... - ON CONFLICT → raise aiosqlite.IntegrityError (caller catches → 409) - Returns: {"id", "email", "login", "created_at"} - -get_registration_by_id(reg_id: int) -> dict | None - SELECT id, email, login, status, push_subscription FROM registrations WHERE id=? - -update_registration_status(reg_id: int, status: str) -> dict | None - UPDATE registrations SET status=? WHERE id=? - Returns registration dict or None if not found -``` - -### `backend/config.py` — новые переменные - -```python -ADMIN_CHAT_ID: str = os.getenv("ADMIN_CHAT_ID", "5694335584") -VAPID_PRIVATE_KEY: str = os.getenv("VAPID_PRIVATE_KEY", "") -VAPID_PUBLIC_KEY: str = os.getenv("VAPID_PUBLIC_KEY", "") -VAPID_CLAIMS_EMAIL: str = os.getenv("VAPID_CLAIMS_EMAIL", "") -``` - -Все optional (не `_require`) — отсутствие VAPID только отключает Web Push, не ломает сервис. - -### `backend/models.py` — новые Pydantic модели - -```python -class PushKeys(BaseModel): - p256dh: str - auth: str - -class PushSubscription(BaseModel): - endpoint: str - keys: PushKeys - -class AuthRegisterRequest(BaseModel): - email: str = Field(..., pattern=r'^[^@\s]+@[^@\s]+\.[^@\s]+$') - login: str = Field(..., min_length=3, max_length=30, pattern=r'^[a-zA-Z0-9_-]+$') - password: str = Field(..., min_length=8) - push_subscription: Optional[PushSubscription] = None - -class AuthRegisterResponse(BaseModel): - status: str - message: str -``` - -### `backend/telegram.py` — 3 новые функции - -``` -send_registration_notification(login, email, reg_id, created_at) -> None - POST sendMessage с reply_markup=InlineKeyboardMarkup - chat_id = config.ADMIN_CHAT_ID - Swallows все ошибки (decision #1215) - -answer_callback_query(callback_query_id: str, text: str = "") -> None - POST answerCallbackQuery - Swallows все ошибки - -edit_message_text(chat_id: str | int, message_id: int, text: str) -> None - POST editMessageText - Swallows все ошибки -``` - -Все три используют тот же паттерн retry (3 попытки, 429/5xx) что и `send_message()`. - -### `backend/push.py` — новый файл - -``` -send_push(subscription_json: str, title: str, body: str) -> None - Парсит subscription_json → dict - webpush( - subscription_info=subscription_dict, - data=json.dumps({"title": title, "body": body, "icon": "/icon-192.png"}), - vapid_private_key=config.VAPID_PRIVATE_KEY, - vapid_claims={"sub": f"mailto:{config.VAPID_CLAIMS_EMAIL}"} - ) - Если VAPID_PRIVATE_KEY пустой → log warning, return (push disabled) - Swallows WebPushException и все прочие ошибки -``` - ---- - -## Edge Cases и решения - -| Кейс | Решение | -|------|---------| -| Email уже зарегистрирован | `IntegrityError` → HTTP 409 | -| Login уже занят | `IntegrityError` → HTTP 409 | -| Rejected пользователь пытается зарегистрироваться заново | 409 (статус не учитывается — оба поля UNIQUE) | -| push_subscription = null при approve | `if reg["push_subscription"]: send_push(...)` — skip gracefully | -| Истёкший/невалидный push endpoint | pywebpush raises → `logger.warning()` → swallow | -| Двойной клик Одобрить (admin кликает дважды) | UPDATE выполняется (idempotent), editMessageText может вернуть ошибку (уже отредактировано) → swallow | -| reg_id не существует в callback | `get_registration_by_id` returns None → log warning, answerCallbackQuery всё равно вызвать | -| VAPID ключи не настроены | Push не отправляется, log warning, сервис работает | -| Telegram недоступен при регистрации | Fire-and-forget + swallow — пользователь получает 201, уведомление теряется | - ---- - -## Решения по open questions (из context_packet) - -**VAPID ключи не сгенерированы:** Dev agent добавляет в README инструкцию по генерации: -```bash -python -c "from py_vapid import Vapid; v = Vapid(); v.generate_keys(); print(v.private_key, v.public_key)" -``` -Ключи добавляются в `.env` вручную оператором перед деплоем. - -**Повторный approve/reject:** Операция idempotent — UPDATE всегда выполняется без проверки текущего статуса. EditMessageText вернёт ошибку при повторном вызове — swallow. - -**Service Worker:** Фронтенд вне скоупа этого тикета. Backend отправляет корректный Web Push payload — обработка на стороне клиента. - -**Login после approve:** Механизм авторизации не входит в BATON-008. Регистрация — отдельный flow от аутентификации. diff --git a/frontend/app.js b/frontend/app.js index 88aa0c0..10c4b1b 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -39,26 +39,26 @@ function _initStorage() { // ========== User identity ========== +function _getOrCreateUserId() { + let id = _storage.getItem('baton_user_id'); + if (!id) { + id = crypto.randomUUID(); + _storage.setItem('baton_user_id', id); + } + return id; +} + function _isRegistered() { - return !!_storage.getItem('baton_auth_token'); + return _storage.getItem('baton_registered') === '1'; } function _getUserName() { - return _storage.getItem('baton_login') || ''; + return _storage.getItem('baton_user_name') || ''; } -function _getAuthToken() { - return _storage.getItem('baton_auth_token') || ''; -} - -function _saveAuth(token, login) { - _storage.setItem('baton_auth_token', token); - _storage.setItem('baton_login', login); -} - -function _clearAuth() { - _storage.removeItem('baton_auth_token'); - _storage.removeItem('baton_login'); +function _saveRegistration(name) { + _storage.setItem('baton_user_name', name); + _storage.setItem('baton_registered', '1'); } function _getInitials(name) { @@ -87,29 +87,6 @@ function _setStatus(msg, cls) { el.hidden = !msg; } -function _setRegStatus(msg, cls) { - const el = document.getElementById('reg-status'); - if (!el) return; - el.textContent = msg; - el.className = 'reg-status' + (cls ? ' reg-status--' + cls : ''); - el.hidden = !msg; -} - -function _setLoginStatus(msg, cls) { - const el = document.getElementById('login-status'); - if (!el) return; - el.textContent = msg; - el.className = 'reg-status' + (cls ? ' reg-status--' + cls : ''); - el.hidden = !msg; -} - -function _showView(id) { - ['view-login', 'view-register'].forEach((vid) => { - const el = document.getElementById(vid); - if (el) el.hidden = vid !== id; - }); -} - function _updateNetworkIndicator() { const el = document.getElementById('indicator-network'); if (!el) return; @@ -125,17 +102,15 @@ function _updateUserAvatar() { // ========== API calls ========== -async function _apiPost(path, body, extraHeaders) { +async function _apiPost(path, body) { const res = await fetch(path, { method: 'POST', - headers: { 'Content-Type': 'application/json', ...extraHeaders }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!res.ok) { const text = await res.text().catch(() => ''); - const err = new Error('HTTP ' + res.status + (text ? ': ' + text : '')); - err.status = res.status; - throw err; + throw new Error('HTTP ' + res.status + (text ? ': ' + text : '')); } return res.json(); } @@ -160,38 +135,23 @@ function _getGeo() { // ========== Handlers ========== -async function _handleLogin() { - const loginInput = document.getElementById('login-input'); - const passInput = document.getElementById('login-password'); - const btn = document.getElementById('btn-login'); - const login = loginInput.value.trim(); - const password = passInput.value; - if (!login || !password) return; +async function _handleRegister() { + const input = document.getElementById('name-input'); + const btn = document.getElementById('btn-confirm'); + const name = input.value.trim(); + if (!name) return; btn.disabled = true; - _setLoginStatus('', ''); + _setStatus('', ''); try { - const data = await _apiPost('/api/auth/login', { - login_or_email: login, - password: password, - }); - _saveAuth(data.token, data.login); - passInput.value = ''; + const uuid = _getOrCreateUserId(); + await _apiPost('/api/register', { uuid, name }); + _saveRegistration(name); _updateUserAvatar(); _showMain(); - } catch (err) { - let msg = 'Ошибка входа. Попробуйте ещё раз.'; - if (err && err.message) { - const colonIdx = err.message.indexOf(': '); - if (colonIdx !== -1) { - try { - const parsed = JSON.parse(err.message.slice(colonIdx + 2)); - if (parsed.detail) msg = parsed.detail; - } catch (_) {} - } - } - _setLoginStatus(msg, 'error'); + } catch (_) { + _setStatus('Error. Please try again.', 'error'); btn.disabled = false; } } @@ -203,43 +163,10 @@ function _setSosState(state) { btn.disabled = state === 'sending'; } -async function _handleTestSignal() { - if (!navigator.onLine) { - _setStatus('Нет соединения.', 'error'); - return; - } - const token = _getAuthToken(); - if (!token) return; - - _setStatus('', ''); - try { - const geo = await _getGeo(); - const body = { timestamp: Date.now(), is_test: true }; - if (geo) body.geo = geo; - await _apiPost('/api/signal', body, { Authorization: 'Bearer ' + token }); - _setStatus('Тест отправлен', 'success'); - setTimeout(() => _setStatus('', ''), 1500); - } catch (err) { - if (err && err.status === 401) { - _clearAuth(); - _setStatus('Сессия истекла. Войдите заново.', 'error'); - setTimeout(() => _showOnboarding(), 1500); - } else { - _setStatus('Ошибка отправки.', 'error'); - } - } -} - async function _handleSignal() { + // v1: no offline queue — show error and return (decision #1019) if (!navigator.onLine) { - _setStatus('Нет соединения. Проверьте сеть и попробуйте снова.', 'error'); - return; - } - - const token = _getAuthToken(); - if (!token) { - _clearAuth(); - _showOnboarding(); + _setStatus('No connection. Check your network and try again.', 'error'); return; } @@ -248,26 +175,21 @@ async function _handleSignal() { try { const geo = await _getGeo(); - const body = { timestamp: Date.now() }; + const uuid = _getOrCreateUserId(); + const body = { user_id: uuid, timestamp: Date.now() }; if (geo) body.geo = geo; - await _apiPost('/api/signal', body, { Authorization: 'Bearer ' + token }); + await _apiPost('/api/signal', body); _setSosState('success'); - _setStatus('Сигнал отправлен!', 'success'); + _setStatus('Signal sent!', 'success'); setTimeout(() => { _setSosState('default'); _setStatus('', ''); }, 2000); - } catch (err) { + } catch (_) { _setSosState('default'); - if (err && err.status === 401) { - _clearAuth(); - _setStatus('Сессия истекла. Войдите заново.', 'error'); - setTimeout(() => _showOnboarding(), 1500); - } else { - _setStatus('Ошибка отправки. Попробуйте ещё раз.', 'error'); - } + _setStatus('Error sending. Try again.', 'error'); } } @@ -275,44 +197,17 @@ async function _handleSignal() { function _showOnboarding() { _showScreen('screen-onboarding'); - _showView('view-login'); - const loginInput = document.getElementById('login-input'); - const passInput = document.getElementById('login-password'); - const btnLogin = document.getElementById('btn-login'); + const input = document.getElementById('name-input'); + const btn = document.getElementById('btn-confirm'); - function _updateLoginBtn() { - btnLogin.disabled = !loginInput.value.trim() || !passInput.value; - } - - loginInput.addEventListener('input', _updateLoginBtn); - passInput.addEventListener('input', _updateLoginBtn); - passInput.addEventListener('keydown', (e) => { - if (e.key === 'Enter' && !btnLogin.disabled) _handleLogin(); + input.addEventListener('input', () => { + btn.disabled = input.value.trim().length === 0; }); - btnLogin.addEventListener('click', _handleLogin); - - const btnToRegister = document.getElementById('btn-switch-to-register'); - if (btnToRegister) { - btnToRegister.addEventListener('click', () => { - _setRegStatus('', ''); - _setLoginStatus('', ''); - _showView('view-register'); - }); - } - - const btnToLogin = document.getElementById('btn-switch-to-login'); - if (btnToLogin) { - btnToLogin.addEventListener('click', () => { - _setLoginStatus('', ''); - _showView('view-login'); - }); - } - - const btnRegister = document.getElementById('btn-register'); - if (btnRegister) { - btnRegister.addEventListener('click', _handleSignUp); - } + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !btn.disabled) _handleRegister(); + }); + btn.addEventListener('click', _handleRegister); } function _showMain() { @@ -324,20 +219,6 @@ function _showMain() { btn.addEventListener('click', _handleSignal); btn.dataset.listenerAttached = '1'; } - - // Avatar and network indicator → test signal (only on main screen) - const avatar = document.getElementById('user-avatar'); - if (avatar && !avatar.dataset.testAttached) { - avatar.addEventListener('click', _handleTestSignal); - avatar.dataset.testAttached = '1'; - avatar.style.cursor = 'pointer'; - } - const indicator = document.getElementById('indicator-network'); - if (indicator && !indicator.dataset.testAttached) { - indicator.addEventListener('click', _handleTestSignal); - indicator.dataset.testAttached = '1'; - indicator.style.cursor = 'pointer'; - } } // ========== Service Worker ========== @@ -349,150 +230,16 @@ function _registerSW() { }); } -// ========== VAPID / Push subscription ========== - -async function _fetchVapidPublicKey() { - try { - const res = await fetch('/api/push/public-key'); - if (!res.ok) { - console.warn('[baton] /api/push/public-key returned', res.status); - return null; - } - const data = await res.json(); - return data.vapid_public_key || null; - } catch (err) { - console.warn('[baton] Failed to fetch VAPID public key:', err); - return null; - } -} - -function _urlBase64ToUint8Array(base64String) { - const padding = '='.repeat((4 - (base64String.length % 4)) % 4); - const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); - const raw = atob(base64); - const output = new Uint8Array(raw.length); - for (let i = 0; i < raw.length; i++) { - output[i] = raw.charCodeAt(i); - } - return output; -} - -async function _initPushSubscription(vapidPublicKey) { - if (!vapidPublicKey) { - console.warn('[baton] VAPID public key not available — push subscription skipped'); - return; - } - if (!('serviceWorker' in navigator) || !('PushManager' in window)) { - return; - } - try { - const registration = await navigator.serviceWorker.ready; - const existing = await registration.pushManager.getSubscription(); - if (existing) return; - const applicationServerKey = _urlBase64ToUint8Array(vapidPublicKey); - const subscription = await registration.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey, - }); - _storage.setItem('baton_push_subscription', JSON.stringify(subscription)); - console.info('[baton] Push subscription created'); - } catch (err) { - console.warn('[baton] Push subscription failed:', err); - } -} - -// ========== Registration (account sign-up) ========== - -async function _getPushSubscriptionForReg() { - if (!('serviceWorker' in navigator) || !('PushManager' in window)) return null; - try { - const vapidKey = await _fetchVapidPublicKey(); - if (!vapidKey) return null; - const registration = await navigator.serviceWorker.ready; - const existing = await registration.pushManager.getSubscription(); - if (existing) return existing.toJSON(); - const applicationServerKey = _urlBase64ToUint8Array(vapidKey); - const subscription = await registration.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey, - }); - return subscription.toJSON(); - } catch (err) { - console.warn('[baton] Push subscription for registration failed:', err); - return null; - } -} - -async function _handleSignUp() { - const emailInput = document.getElementById('reg-email'); - const loginInput = document.getElementById('reg-login'); - const passwordInput = document.getElementById('reg-password'); - const btn = document.getElementById('btn-register'); - if (!emailInput || !loginInput || !passwordInput || !btn) return; - - const email = emailInput.value.trim(); - const login = loginInput.value.trim(); - const password = passwordInput.value; - - if (!email || !login || !password) { - _setRegStatus('Заполните все поля.', 'error'); - return; - } - if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { - _setRegStatus('Введите корректный email.', 'error'); - return; - } - - btn.disabled = true; - const originalText = btn.textContent.trim(); - btn.textContent = '...'; - _setRegStatus('', ''); - - try { - const push_subscription = await _getPushSubscriptionForReg().catch(() => null); - await _apiPost('/api/auth/register', { email, login, password, push_subscription }); - passwordInput.value = ''; - _setRegStatus('Заявка отправлена. Ожидайте подтверждения администратора.', 'success'); - } catch (err) { - let msg = 'Ошибка. Попробуйте ещё раз.'; - if (err && err.message) { - const colonIdx = err.message.indexOf(': '); - if (colonIdx !== -1) { - try { - const parsed = JSON.parse(err.message.slice(colonIdx + 2)); - if (parsed.detail) msg = parsed.detail; - } catch (_) {} - } - } - if (err && err.status === 403 && msg !== 'Ошибка. Попробуйте ещё раз.') { - _showBlockScreen(msg); - } else { - _setRegStatus(msg, 'error'); - btn.disabled = false; - btn.textContent = originalText; - } - } -} - -function _showBlockScreen(msg) { - const screen = document.getElementById('screen-onboarding'); - if (!screen) return; - screen.innerHTML = - '
' + - '

' + msg + '

' + - '' + - '
'; - document.getElementById('btn-block-ok').addEventListener('click', () => { - location.reload(); - }); -} - // ========== Init ========== function _init() { _initStorage(); - // Private mode graceful degradation (decision #1041) + // Pre-generate and persist UUID on first visit (per arch spec flow) + _getOrCreateUserId(); + + // Private mode graceful degradation (decision #1041): + // show inline banner with explicit action guidance when localStorage is unavailable if (_storageType !== 'local') { const banner = document.getElementById('private-mode-banner'); if (banner) banner.hidden = false; @@ -503,17 +250,12 @@ function _init() { window.addEventListener('online', _updateNetworkIndicator); window.addEventListener('offline', _updateNetworkIndicator); - // Route to correct screen based on JWT token presence + // Route to correct screen if (_isRegistered()) { _showMain(); } else { _showOnboarding(); } - - // Fire-and-forget: fetch VAPID key from API and subscribe to push (non-blocking) - _fetchVapidPublicKey().then(_initPushSubscription).catch((err) => { - console.warn('[baton] Push init error:', err); - }); } document.addEventListener('DOMContentLoaded', () => { diff --git a/frontend/index.html b/frontend/index.html index 0294a32..e5fe30e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,7 +2,7 @@ - + @@ -36,80 +36,23 @@
- - -
+
- - -
-
- - -
- - - - - -
-
-
diff --git a/frontend/style.css b/frontend/style.css index e07e53a..487a443 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -28,14 +28,14 @@ html, body { -webkit-tap-highlight-color: transparent; overscroll-behavior: none; user-select: none; - overflow: hidden; } body { display: flex; flex-direction: column; - height: 100vh; - height: 100dvh; + min-height: 100vh; + /* Use dynamic viewport height on mobile to account for browser chrome */ + min-height: 100dvh; } /* ===== Private mode banner (decision #1041) ===== */ @@ -59,7 +59,6 @@ body { justify-content: space-between; align-items: center; padding: 16px 20px; - padding-top: calc(env(safe-area-inset-top, 0px) + 16px); flex-shrink: 0; } @@ -149,8 +148,10 @@ body { /* ===== SOS button (min 60vmin × 60vmin per UX spec) ===== */ .btn-sos { - width: min(60vmin, 70vw, 300px); - height: min(60vmin, 70vw, 300px); + width: 60vmin; + height: 60vmin; + min-width: 180px; + min-height: 180px; border-radius: 50%; border: none; background: var(--sos); @@ -197,44 +198,3 @@ body { .status[hidden] { display: none; } .status--error { color: #f87171; } .status--success { color: #4ade80; } - -/* ===== Registration form ===== */ - -/* Override display:flex so [hidden] works on screen-content divs */ -.screen-content[hidden] { display: none; } - -.btn-link { - background: none; - border: none; - color: var(--muted); - font-size: 14px; - cursor: pointer; - padding: 4px 0; - text-decoration: underline; - text-underline-offset: 2px; - -webkit-tap-highlight-color: transparent; -} - -.btn-link:active { color: var(--text); } - -.reg-status { - width: 100%; - max-width: 320px; - font-size: 14px; - text-align: center; - line-height: 1.5; - padding: 4px 0; -} - -.reg-status[hidden] { display: none; } -.reg-status--error { color: #f87171; } -.reg-status--success { color: #4ade80; } - -.block-message { - color: #f87171; - font-size: 16px; - text-align: center; - line-height: 1.6; - padding: 20px; - max-width: 320px; -} diff --git a/frontend/sw.js b/frontend/sw.js index 79d89da..e37d2fa 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,6 +1,6 @@ 'use strict'; -const CACHE_NAME = 'baton-v4'; +const CACHE_NAME = 'baton-v1'; // App shell assets to precache const APP_SHELL = [ diff --git a/nginx/baton.conf b/nginx/baton.conf index 8afbf2f..e148729 100644 --- a/nginx/baton.conf +++ b/nginx/baton.conf @@ -91,27 +91,9 @@ server { proxy_connect_timeout 5s; } - # --------------------------------------------------------------------------- - # Security headers - # IMPORTANT: must be repeated in every location block that uses add_header, - # because nginx does not inherit parent add_header when child block defines its own. - # --------------------------------------------------------------------------- - add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; - add_header X-Content-Type-Options nosniff always; - add_header X-Frame-Options DENY always; - add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'" always; - # Статика фронтенда (SPA) location / { root /opt/baton/frontend; try_files $uri /index.html; - expires 1h; - # Security headers repeated here because add_header in location blocks - # overrides parent-level add_header directives (nginx inheritance rule) - add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; - add_header X-Content-Type-Options nosniff always; - add_header X-Frame-Options DENY always; - add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'" always; - add_header Cache-Control "public" always; } } diff --git a/nginx/docker.conf b/nginx/docker.conf deleted file mode 100644 index 54df415..0000000 --- a/nginx/docker.conf +++ /dev/null @@ -1,61 +0,0 @@ -map $request_uri $masked_uri { - default $request_uri; - "~^(/bot)[^/]+(/.*)$" "$1[REDACTED]$2"; -} - -log_format baton_secure '$remote_addr - $remote_user [$time_local] ' - '"$request_method $masked_uri $server_protocol" ' - '$status $body_bytes_sent ' - '"$http_referer" "$http_user_agent"'; - -server { - listen 80; - server_name _; - - access_log /var/log/nginx/baton_access.log baton_secure; - error_log /var/log/nginx/baton_error.log warn; - - 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; - - # API + health + admin → backend - location /api/ { - proxy_pass http://backend: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; - } - - location /health { - proxy_pass http://backend: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; - } - - location /admin/users { - proxy_pass http://backend: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; - } - - # Frontend static - location / { - root /usr/share/nginx/html; - try_files $uri /index.html; - expires 1h; - add_header Cache-Control "public" 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; - } -} diff --git a/requirements.txt b/requirements.txt index 9876432..c992449 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,5 +4,3 @@ aiosqlite>=0.20.0 httpx>=0.27.0 python-dotenv>=1.0.0 pydantic>=2.0 -email-validator>=2.0.0 -pywebpush>=2.0.0 diff --git a/tests/conftest.py b/tests/conftest.py index 7f47b12..24b0ff3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -22,7 +22,6 @@ 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") -os.environ.setdefault("ADMIN_CHAT_ID", "5694335584") # ── 2. aiosqlite monkey-patch ──────────────────────────────────────────────── import aiosqlite @@ -70,25 +69,16 @@ def temp_db(): # ── 5. App client factory ──────────────────────────────────────────────────── -def make_app_client(capture_send_requests: list | None = None): +def make_app_client(): """ Async context manager that: 1. Assigns a fresh temp-file DB path - 2. Mocks Telegram setWebhook, sendMessage, answerCallbackQuery, editMessageText + 2. Mocks Telegram setWebhook and sendMessage 3. Runs the FastAPI lifespan (startup → test → shutdown) 4. Yields an httpx.AsyncClient wired to the app - - Args: - capture_send_requests: if provided, each sendMessage request body (dict) is - appended to this list, enabling HTTP-level assertions on chat_id, text, etc. """ - import json as _json - 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" - answer_cb_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/answerCallbackQuery" - edit_msg_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/editMessageText" @contextlib.asynccontextmanager async def _ctx(): @@ -96,28 +86,10 @@ def make_app_client(capture_send_requests: list | None = None): 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}) ) - if capture_send_requests is not None: - def _capture_send(request: httpx.Request) -> httpx.Response: - try: - capture_send_requests.append(_json.loads(request.content)) - except Exception: - pass - return httpx.Response(200, json={"ok": True}) - mock_router.post(send_url).mock(side_effect=_capture_send) - else: - mock_router.post(send_url).mock( - return_value=httpx.Response(200, json={"ok": True}) - ) - mock_router.post(answer_cb_url).mock( - return_value=httpx.Response(200, json={"ok": True}) - ) - mock_router.post(edit_msg_url).mock( + mock_router.post(send_url).mock( return_value=httpx.Response(200, json={"ok": True}) ) diff --git a/tests/test_arch_002.py b/tests/test_arch_002.py index 0b5c681..a89dfa5 100644 --- a/tests/test_arch_002.py +++ b/tests/test_arch_002.py @@ -5,10 +5,6 @@ Acceptance criteria: 1. No asyncio task for the aggregator is created at lifespan startup. 2. POST /api/signal calls telegram.send_message directly (no aggregator intermediary). 3. SignalAggregator class in telegram.py is preserved with '# v2.0 feature' marker. - -UUID notes: all UUIDs satisfy the UUID v4 pattern. -BATON-SEC-003: POST /api/signal now requires Authorization: Bearer . -Tests that send signals register first and use the returned api_key. """ from __future__ import annotations @@ -19,7 +15,6 @@ os.environ.setdefault("CHAT_ID", "-1001234567890") os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret") os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram") os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000") -os.environ.setdefault("ADMIN_TOKEN", "test-admin-token") from pathlib import Path from unittest.mock import AsyncMock, patch @@ -30,20 +25,6 @@ from tests.conftest import make_app_client _BACKEND_DIR = Path(__file__).parent.parent / "backend" -# Valid UUID v4 constants -_UUID_S1 = "a0100001-0000-4000-8000-000000000001" -_UUID_S2 = "a0100002-0000-4000-8000-000000000002" -_UUID_S3 = "a0100003-0000-4000-8000-000000000003" -_UUID_S4 = "a0100004-0000-4000-8000-000000000004" -_UUID_S5 = "a0100005-0000-4000-8000-000000000005" - - -async def _register(client, uuid: str, name: str) -> str: - """Register user and return api_key.""" - r = await client.post("/api/register", json={"uuid": uuid, "name": name}) - assert r.status_code == 200 - return r.json()["api_key"] - # --------------------------------------------------------------------------- # Criterion 1 — No asyncio task for aggregator created at startup (static) @@ -91,12 +72,11 @@ def test_aggregator_instantiation_commented_out_in_main(): async def test_signal_calls_telegram_send_message_directly(): """POST /api/signal must call telegram.send_message directly, not via aggregator (ADR-004).""" async with make_app_client() as client: - api_key = await _register(client, _UUID_S1, "Tester") + await client.post("/api/register", json={"uuid": "adr-uuid-s1", "name": "Tester"}) with patch("backend.telegram.send_message", new_callable=AsyncMock) as mock_send: resp = await client.post( "/api/signal", - json={"user_id": _UUID_S1, "timestamp": 1742478000000}, - headers={"Authorization": f"Bearer {api_key}"}, + json={"user_id": "adr-uuid-s1", "timestamp": 1742478000000}, ) assert resp.status_code == 200 mock_send.assert_called_once() @@ -106,12 +86,11 @@ async def test_signal_calls_telegram_send_message_directly(): async def test_signal_message_contains_registered_username(): """Message passed to send_message must include the registered user's name.""" async with make_app_client() as client: - api_key = await _register(client, _UUID_S2, "Alice") + await client.post("/api/register", json={"uuid": "adr-uuid-s2", "name": "Alice"}) with patch("backend.telegram.send_message", new_callable=AsyncMock) as mock_send: await client.post( "/api/signal", - json={"user_id": _UUID_S2, "timestamp": 1742478000000}, - headers={"Authorization": f"Bearer {api_key}"}, + json={"user_id": "adr-uuid-s2", "timestamp": 1742478000000}, ) text = mock_send.call_args[0][0] assert "Alice" in text @@ -119,33 +98,31 @@ async def test_signal_message_contains_registered_username(): @pytest.mark.asyncio async def test_signal_message_without_geo_contains_bez_geolocatsii(): - """When geo is None, message must contain 'Гео нету'.""" + """When geo is None, message must contain 'Без геолокации'.""" async with make_app_client() as client: - api_key = await _register(client, _UUID_S3, "Bob") + await client.post("/api/register", json={"uuid": "adr-uuid-s3", "name": "Bob"}) with patch("backend.telegram.send_message", new_callable=AsyncMock) as mock_send: await client.post( "/api/signal", - json={"user_id": _UUID_S3, "timestamp": 1742478000000, "geo": None}, - headers={"Authorization": f"Bearer {api_key}"}, + json={"user_id": "adr-uuid-s3", "timestamp": 1742478000000, "geo": None}, ) text = mock_send.call_args[0][0] - assert "Гео нету" in text + assert "Без геолокации" in text @pytest.mark.asyncio async def test_signal_message_with_geo_contains_coordinates(): """When geo is provided, message must contain lat and lon values.""" async with make_app_client() as client: - api_key = await _register(client, _UUID_S4, "Charlie") + await client.post("/api/register", json={"uuid": "adr-uuid-s4", "name": "Charlie"}) with patch("backend.telegram.send_message", new_callable=AsyncMock) as mock_send: await client.post( "/api/signal", json={ - "user_id": _UUID_S4, + "user_id": "adr-uuid-s4", "timestamp": 1742478000000, "geo": {"lat": 55.7558, "lon": 37.6173, "accuracy": 15.0}, }, - headers={"Authorization": f"Bearer {api_key}"}, ) text = mock_send.call_args[0][0] assert "55.7558" in text @@ -156,29 +133,49 @@ async def test_signal_message_with_geo_contains_coordinates(): async def test_signal_message_contains_utc_marker(): """Message passed to send_message must contain 'UTC' timestamp marker.""" async with make_app_client() as client: - api_key = await _register(client, _UUID_S5, "Dave") + await client.post("/api/register", json={"uuid": "adr-uuid-s5", "name": "Dave"}) with patch("backend.telegram.send_message", new_callable=AsyncMock) as mock_send: await client.post( "/api/signal", - json={"user_id": _UUID_S5, "timestamp": 1742478000000}, - headers={"Authorization": f"Bearer {api_key}"}, + json={"user_id": "adr-uuid-s5", "timestamp": 1742478000000}, ) text = mock_send.call_args[0][0] assert "UTC" in text +@pytest.mark.asyncio +async def test_signal_unknown_user_message_uses_uuid_prefix(): + """When user is not registered, message uses first 8 chars of uuid as name.""" + async with make_app_client() as client: + with patch("backend.telegram.send_message", new_callable=AsyncMock) as mock_send: + await client.post( + "/api/signal", + json={"user_id": "unknown-uuid-xyz", "timestamp": 1742478000000}, + ) + text = mock_send.call_args[0][0] + assert "unknown-" in text # "unknown-uuid-xyz"[:8] == "unknown-" + + # --------------------------------------------------------------------------- -# Criterion 3 — SignalAggregator removed (BATON-BIZ-004: dead code cleanup) +# Criterion 3 — SignalAggregator preserved with '# v2.0 feature' marker (static) # --------------------------------------------------------------------------- -def test_signal_aggregator_class_removed_from_telegram(): - """SignalAggregator must be removed from telegram.py (BATON-BIZ-004).""" +def test_signal_aggregator_class_preserved_in_telegram(): + """SignalAggregator class must still exist in telegram.py (ADR-004, reserved for v2).""" source = (_BACKEND_DIR / "telegram.py").read_text() - assert "class SignalAggregator" not in source + assert "class SignalAggregator" in source -def test_signal_aggregator_not_referenced_in_telegram(): - """telegram.py must not reference SignalAggregator at all (BATON-BIZ-004).""" - source = (_BACKEND_DIR / "telegram.py").read_text() - assert "SignalAggregator" not in source +def test_signal_aggregator_has_v2_feature_comment(): + """The line immediately before 'class SignalAggregator' must contain '# v2.0 feature'.""" + lines = (_BACKEND_DIR / "telegram.py").read_text().splitlines() + class_line_idx = next( + (i for i, line in enumerate(lines) if "class SignalAggregator" in line), None + ) + assert class_line_idx is not None, "class SignalAggregator not found in telegram.py" + assert class_line_idx > 0, "SignalAggregator is on the first line — no preceding comment line" + preceding_line = lines[class_line_idx - 1] + assert "# v2.0 feature" in preceding_line, ( + f"Expected '# v2.0 feature' on line before class SignalAggregator, got: {preceding_line!r}" + ) diff --git a/tests/test_arch_003.py b/tests/test_arch_003.py index ee221b8..248086f 100644 --- a/tests/test_arch_003.py +++ b/tests/test_arch_003.py @@ -6,9 +6,6 @@ Acceptance criteria: 5 requests pass (200), 6th returns 429; counter resets after the 10-minute window. 2. Token comparison is timing-safe: secrets.compare_digest is used in middleware.py (no == / != for token comparison). - -UUID notes: RegisterRequest.uuid requires a valid UUID v4 pattern. -All UUID constants below satisfy this constraint. """ from __future__ import annotations @@ -23,7 +20,6 @@ os.environ.setdefault("CHAT_ID", "-1001234567890") os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret") os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram") os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000") -os.environ.setdefault("ADMIN_TOKEN", "test-admin-token") import pytest from tests.conftest import make_app_client @@ -42,24 +38,6 @@ _SAMPLE_UPDATE = { }, } -# Valid UUID v4 constants for rate-limit tests -# Pattern: [0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12} -_UUIDS_OK = [ - f"d0{i:06d}-0000-4000-8000-000000000001" - for i in range(10) -] -_UUIDS_BLK = [ - f"d1{i:06d}-0000-4000-8000-000000000001" - for i in range(10) -] -_UUIDS_EXP = [ - f"d2{i:06d}-0000-4000-8000-000000000001" - for i in range(10) -] -_UUID_BLK_999 = "d1000999-0000-4000-8000-000000000001" -_UUID_EXP_BLK = "d2000999-0000-4000-8000-000000000001" -_UUID_EXP_AFTER = "d2001000-0000-4000-8000-000000000001" - # --------------------------------------------------------------------------- # Criterion 1 — Rate limiting: first 5 requests pass @@ -73,7 +51,7 @@ async def test_register_rate_limit_allows_five_requests(): for i in range(5): resp = await client.post( "/api/register", - json={"uuid": _UUIDS_OK[i], "name": f"User{i}"}, + json={"uuid": f"rl-ok-{i:03d}", "name": f"User{i}"}, ) assert resp.status_code == 200, ( f"Request {i + 1}/5 unexpectedly returned {resp.status_code}" @@ -92,11 +70,11 @@ async def test_register_rate_limit_blocks_sixth_request(): for i in range(5): await client.post( "/api/register", - json={"uuid": _UUIDS_BLK[i], "name": f"User{i}"}, + json={"uuid": f"rl-blk-{i:03d}", "name": f"User{i}"}, ) resp = await client.post( "/api/register", - json={"uuid": _UUID_BLK_999, "name": "Attacker"}, + json={"uuid": "rl-blk-999", "name": "Attacker"}, ) assert resp.status_code == 429 @@ -116,13 +94,13 @@ async def test_register_rate_limit_resets_after_window_expires(): for i in range(5): await client.post( "/api/register", - json={"uuid": _UUIDS_EXP[i], "name": f"User{i}"}, + json={"uuid": f"rl-exp-{i:03d}", "name": f"User{i}"}, ) # Verify the 6th is blocked before window expiry blocked = await client.post( "/api/register", - json={"uuid": _UUID_EXP_BLK, "name": "Attacker"}, + json={"uuid": "rl-exp-blk", "name": "Attacker"}, ) assert blocked.status_code == 429, ( "Expected 429 after exhausting rate limit, got " + str(blocked.status_code) @@ -132,7 +110,7 @@ async def test_register_rate_limit_resets_after_window_expires(): with patch("time.time", return_value=base_time + 601): resp_after = await client.post( "/api/register", - json={"uuid": _UUID_EXP_AFTER, "name": "Legit"}, + json={"uuid": "rl-exp-after", "name": "Legit"}, ) assert resp_after.status_code == 200, ( diff --git a/tests/test_arch_009.py b/tests/test_arch_009.py index 01ba1c4..9457374 100644 --- a/tests/test_arch_009.py +++ b/tests/test_arch_009.py @@ -222,9 +222,9 @@ def test_html_loads_app_js() -> None: assert "/app.js" in _html() -def test_html_has_login_input() -> None: - """index.html must have login input field for onboarding.""" - assert 'id="login-input"' in _html() +def test_html_has_name_input() -> None: + """index.html must have name input field for onboarding.""" + assert 'id="name-input"' in _html() # --------------------------------------------------------------------------- @@ -316,19 +316,31 @@ def _app_js() -> str: return (FRONTEND / "app.js").read_text(encoding="utf-8") -def test_app_posts_to_auth_login() -> None: - """app.js must send POST to /api/auth/login during login.""" - assert "/api/auth/login" in _app_js() +def test_app_uses_crypto_random_uuid() -> None: + """app.js must generate UUID via crypto.randomUUID().""" + assert "crypto.randomUUID()" in _app_js() -def test_app_posts_to_auth_register() -> None: - """app.js must send POST to /api/auth/register during registration.""" - assert "/api/auth/register" in _app_js() +def test_app_posts_to_api_register() -> None: + """app.js must send POST to /api/register during onboarding.""" + assert "/api/register" in _app_js() -def test_app_stores_auth_token() -> None: - """app.js must persist JWT token to storage.""" - assert "baton_auth_token" in _app_js() +def test_app_register_sends_uuid() -> None: + """app.js must include uuid in the /api/register request body.""" + app = _app_js() + # The register call must include uuid in the payload + register_section = re.search( + r"_apiPost\(['\"]\/api\/register['\"].*?\)", app, re.DOTALL + ) + assert register_section, "No _apiPost('/api/register') call found" + assert "uuid" in register_section.group(0), \ + "uuid not included in /api/register call" + + +def test_app_uuid_saved_to_storage() -> None: + """app.js must persist UUID to storage (baton_user_id key).""" + assert "baton_user_id" in _app_js() assert "setItem" in _app_js() @@ -422,14 +434,16 @@ def test_app_posts_to_api_signal() -> None: assert "/api/signal" in _app_js() -def test_app_signal_sends_auth_header() -> None: - """app.js must include Authorization Bearer header in /api/signal request.""" +def test_app_signal_sends_user_id() -> None: + """app.js must include user_id (UUID) in the /api/signal request body.""" app = _app_js() + # The signal body may be built in a variable before passing to _apiPost + # Look for user_id key in the context around /api/signal signal_area = re.search( - r"_apiPost\(['\"]\/api\/signal['\"].*Authorization.*Bearer", app, re.DOTALL + r"user_id.*?_apiPost\(['\"]\/api\/signal", app, re.DOTALL ) assert signal_area, \ - "Authorization Bearer header must be set in _apiPost('/api/signal') call" + "user_id must be set in the request body before calling _apiPost('/api/signal')" def test_app_sos_button_click_calls_handle_signal() -> None: @@ -442,15 +456,15 @@ def test_app_sos_button_click_calls_handle_signal() -> None: "btn-sos must be connected to _handleSignal" -def test_app_signal_uses_token_from_storage() -> None: - """app.js must retrieve auth token from storage before sending signal.""" +def test_app_signal_uses_uuid_from_storage() -> None: + """app.js must retrieve UUID from storage (_getOrCreateUserId) before sending signal.""" app = _app_js() handle_signal = re.search( r"async function _handleSignal\(\).*?^}", app, re.MULTILINE | re.DOTALL ) assert handle_signal, "_handleSignal function not found" - assert "_getAuthToken" in handle_signal.group(0), \ - "_handleSignal must call _getAuthToken() to get JWT token" + assert "_getOrCreateUserId" in handle_signal.group(0), \ + "_handleSignal must call _getOrCreateUserId() to get UUID" # --------------------------------------------------------------------------- diff --git a/tests/test_arch_013.py b/tests/test_arch_013.py index 307ffbc..b70c682 100644 --- a/tests/test_arch_013.py +++ b/tests/test_arch_013.py @@ -54,14 +54,14 @@ async def test_health_returns_status_ok(): @pytest.mark.asyncio -async def test_health_no_timestamp(): - """GET /health не должен возвращать поле timestamp (устраняет time-based fingerprinting).""" +async def test_health_returns_timestamp(): + """GET /health должен вернуть поле timestamp в JSON.""" async with make_app_client() as client: response = await client.get("/health") data = response.json() - assert "timestamp" not in data - assert data == {"status": "ok"} + assert "timestamp" in data + assert isinstance(data["timestamp"], int) @pytest.mark.asyncio diff --git a/tests/test_baton_005.py b/tests/test_baton_005.py index 117e95d..1e43810 100644 --- a/tests/test_baton_005.py +++ b/tests/test_baton_005.py @@ -9,10 +9,6 @@ Acceptance criteria: 5. Удаление — пользователь исчезает из GET /admin/users, возвращается 204 6. Защита: неавторизованный запрос к /admin/* возвращает 401 7. Отсутствие регрессии с основным функционалом - -BATON-SEC-003: POST /api/signal now requires Authorization: Bearer . -Tests 3 and 4 (block/unblock + signal) use /api/register to obtain an api_key, -then admin block/unblock the user by their DB id. """ from __future__ import annotations @@ -37,24 +33,6 @@ NGINX_CONF = PROJECT_ROOT / "nginx" / "baton.conf" ADMIN_HEADERS = {"Authorization": "Bearer test-admin-token"} WRONG_HEADERS = {"Authorization": "Bearer wrong-token"} -# Valid UUID v4 for signal-related tests (registered via /api/register) -_UUID_BLOCK = "f0000001-0000-4000-8000-000000000001" -_UUID_UNBLOCK = "f0000002-0000-4000-8000-000000000002" -_UUID_SIG_OK = "f0000003-0000-4000-8000-000000000003" - -# Valid UUID v4 for admin-only tests (POST /admin/users, no /api/register call) -_UUID_ADM_UNAUTH = "e0000000-0000-4000-8000-000000000000" -_UUID_ADM_CREATE_1 = "e0000001-0000-4000-8000-000000000001" -_UUID_ADM_CREATE_2 = "e0000002-0000-4000-8000-000000000002" -_UUID_ADM_CREATE_3 = "e0000003-0000-4000-8000-000000000003" -_UUID_ADM_PASS_1 = "e0000004-0000-4000-8000-000000000004" -_UUID_ADM_PASS_2 = "e0000005-0000-4000-8000-000000000005" -_UUID_ADM_BLOCK = "e0000006-0000-4000-8000-000000000006" -_UUID_ADM_UNBLOCK = "e0000007-0000-4000-8000-000000000007" -_UUID_ADM_DELETE_1 = "e0000008-0000-4000-8000-000000000008" -_UUID_ADM_DELETE_2 = "e0000009-0000-4000-8000-000000000009" -_UUID_ADM_REGRESS = "e000000a-0000-4000-8000-000000000010" - # --------------------------------------------------------------------------- # Criterion 6 — Unauthorised requests to /admin/* return 401 @@ -83,7 +61,7 @@ async def test_admin_create_user_without_token_returns_401() -> None: async with make_app_client() as client: resp = await client.post( "/admin/users", - json={"uuid": _UUID_ADM_UNAUTH, "name": "Ghost"}, + json={"uuid": "unauth-uuid-001", "name": "Ghost"}, ) assert resp.status_code == 401 @@ -129,12 +107,12 @@ async def test_admin_create_user_returns_201_with_user_data() -> None: async with make_app_client() as client: resp = await client.post( "/admin/users", - json={"uuid": _UUID_ADM_CREATE_1, "name": "Alice Admin"}, + json={"uuid": "create-uuid-001", "name": "Alice Admin"}, headers=ADMIN_HEADERS, ) assert resp.status_code == 201 data = resp.json() - assert data["uuid"] == _UUID_ADM_CREATE_1 + assert data["uuid"] == "create-uuid-001" assert data["name"] == "Alice Admin" assert data["id"] > 0 assert data["is_blocked"] is False @@ -146,7 +124,7 @@ async def test_admin_create_user_appears_in_list() -> None: async with make_app_client() as client: await client.post( "/admin/users", - json={"uuid": _UUID_ADM_CREATE_2, "name": "Bob Admin"}, + json={"uuid": "create-uuid-002", "name": "Bob Admin"}, headers=ADMIN_HEADERS, ) resp = await client.get("/admin/users", headers=ADMIN_HEADERS) @@ -154,7 +132,7 @@ async def test_admin_create_user_appears_in_list() -> None: assert resp.status_code == 200 users = resp.json() uuids = [u["uuid"] for u in users] - assert _UUID_ADM_CREATE_2 in uuids + assert "create-uuid-002" in uuids @pytest.mark.asyncio @@ -163,12 +141,12 @@ async def test_admin_create_user_duplicate_uuid_returns_409() -> None: async with make_app_client() as client: await client.post( "/admin/users", - json={"uuid": _UUID_ADM_CREATE_3, "name": "Carol"}, + json={"uuid": "create-uuid-003", "name": "Carol"}, headers=ADMIN_HEADERS, ) resp = await client.post( "/admin/users", - json={"uuid": _UUID_ADM_CREATE_3, "name": "Carol Duplicate"}, + json={"uuid": "create-uuid-003", "name": "Carol Duplicate"}, headers=ADMIN_HEADERS, ) assert resp.status_code == 409 @@ -194,7 +172,7 @@ async def test_admin_set_password_returns_ok() -> None: async with make_app_client() as client: create_resp = await client.post( "/admin/users", - json={"uuid": _UUID_ADM_PASS_1, "name": "PassUser"}, + json={"uuid": "pass-uuid-001", "name": "PassUser"}, headers=ADMIN_HEADERS, ) user_id = create_resp.json()["id"] @@ -226,7 +204,7 @@ async def test_admin_set_password_user_still_accessible_after_change() -> None: async with make_app_client() as client: create_resp = await client.post( "/admin/users", - json={"uuid": _UUID_ADM_PASS_2, "name": "PassUser2"}, + json={"uuid": "pass-uuid-002", "name": "PassUser2"}, headers=ADMIN_HEADERS, ) user_id = create_resp.json()["id"] @@ -240,7 +218,7 @@ async def test_admin_set_password_user_still_accessible_after_change() -> None: list_resp = await client.get("/admin/users", headers=ADMIN_HEADERS) uuids = [u["uuid"] for u in list_resp.json()] - assert _UUID_ADM_PASS_2 in uuids + assert "pass-uuid-002" in uuids # --------------------------------------------------------------------------- @@ -254,7 +232,7 @@ async def test_admin_block_user_returns_is_blocked_true() -> None: async with make_app_client() as client: create_resp = await client.post( "/admin/users", - json={"uuid": _UUID_ADM_BLOCK, "name": "BlockUser"}, + json={"uuid": "block-uuid-001", "name": "BlockUser"}, headers=ADMIN_HEADERS, ) user_id = create_resp.json()["id"] @@ -272,32 +250,23 @@ async def test_admin_block_user_returns_is_blocked_true() -> None: async def test_admin_block_user_prevents_signal() -> None: """Заблокированный пользователь не может отправить сигнал — /api/signal возвращает 403.""" async with make_app_client() as client: - # Регистрируем через /api/register чтобы получить api_key - reg_resp = await client.post( - "/api/register", - json={"uuid": _UUID_BLOCK, "name": "BlockSignalUser"}, + create_resp = await client.post( + "/admin/users", + json={"uuid": "block-uuid-002", "name": "BlockSignalUser"}, + headers=ADMIN_HEADERS, ) - assert reg_resp.status_code == 200 - api_key = reg_resp.json()["api_key"] - user_uuid = reg_resp.json()["uuid"] + user_id = create_resp.json()["id"] + user_uuid = create_resp.json()["uuid"] - # Находим ID пользователя - users_resp = await client.get("/admin/users", headers=ADMIN_HEADERS) - user = next(u for u in users_resp.json() if u["uuid"] == user_uuid) - user_id = user["id"] - - # Блокируем await client.put( f"/admin/users/{user_id}/block", json={"is_blocked": True}, headers=ADMIN_HEADERS, ) - # Заблокированный пользователь должен получить 403 signal_resp = await client.post( "/api/signal", json={"user_id": user_uuid, "timestamp": 1700000000000, "geo": None}, - headers={"Authorization": f"Bearer {api_key}"}, ) assert signal_resp.status_code == 403 @@ -325,7 +294,7 @@ async def test_admin_unblock_user_returns_is_blocked_false() -> None: async with make_app_client() as client: create_resp = await client.post( "/admin/users", - json={"uuid": _UUID_ADM_UNBLOCK, "name": "UnblockUser"}, + json={"uuid": "unblock-uuid-001", "name": "UnblockUser"}, headers=ADMIN_HEADERS, ) user_id = create_resp.json()["id"] @@ -349,19 +318,13 @@ async def test_admin_unblock_user_returns_is_blocked_false() -> None: async def test_admin_unblock_user_restores_signal_access() -> None: """После разблокировки пользователь снова может отправить сигнал (200).""" async with make_app_client() as client: - # Регистрируем через /api/register чтобы получить api_key - reg_resp = await client.post( - "/api/register", - json={"uuid": _UUID_UNBLOCK, "name": "UnblockSignalUser"}, + create_resp = await client.post( + "/admin/users", + json={"uuid": "unblock-uuid-002", "name": "UnblockSignalUser"}, + headers=ADMIN_HEADERS, ) - assert reg_resp.status_code == 200 - api_key = reg_resp.json()["api_key"] - user_uuid = reg_resp.json()["uuid"] - - # Находим ID - users_resp = await client.get("/admin/users", headers=ADMIN_HEADERS) - user = next(u for u in users_resp.json() if u["uuid"] == user_uuid) - user_id = user["id"] + user_id = create_resp.json()["id"] + user_uuid = create_resp.json()["uuid"] # Блокируем await client.put( @@ -381,7 +344,6 @@ async def test_admin_unblock_user_restores_signal_access() -> None: signal_resp = await client.post( "/api/signal", json={"user_id": user_uuid, "timestamp": 1700000000000, "geo": None}, - headers={"Authorization": f"Bearer {api_key}"}, ) assert signal_resp.status_code == 200 assert signal_resp.json()["status"] == "ok" @@ -398,7 +360,7 @@ async def test_admin_delete_user_returns_204() -> None: async with make_app_client() as client: create_resp = await client.post( "/admin/users", - json={"uuid": _UUID_ADM_DELETE_1, "name": "DeleteUser"}, + json={"uuid": "delete-uuid-001", "name": "DeleteUser"}, headers=ADMIN_HEADERS, ) user_id = create_resp.json()["id"] @@ -416,7 +378,7 @@ async def test_admin_delete_user_disappears_from_list() -> None: async with make_app_client() as client: create_resp = await client.post( "/admin/users", - json={"uuid": _UUID_ADM_DELETE_2, "name": "DeleteUser2"}, + json={"uuid": "delete-uuid-002", "name": "DeleteUser2"}, headers=ADMIN_HEADERS, ) user_id = create_resp.json()["id"] @@ -429,7 +391,7 @@ async def test_admin_delete_user_disappears_from_list() -> None: list_resp = await client.get("/admin/users", headers=ADMIN_HEADERS) uuids = [u["uuid"] for u in list_resp.json()] - assert _UUID_ADM_DELETE_2 not in uuids + assert "delete-uuid-002" not in uuids @pytest.mark.asyncio @@ -493,35 +455,33 @@ async def test_register_not_broken_after_admin_operations() -> None: # Admin операции await client.post( "/admin/users", - json={"uuid": _UUID_ADM_REGRESS, "name": "AdminCreated"}, + json={"uuid": "regress-admin-uuid-001", "name": "AdminCreated"}, headers=ADMIN_HEADERS, ) # Основной функционал resp = await client.post( "/api/register", - json={"uuid": _UUID_SIG_OK, "name": "RegularUser"}, + json={"uuid": "regress-user-uuid-001", "name": "RegularUser"}, ) assert resp.status_code == 200 - assert resp.json()["uuid"] == _UUID_SIG_OK + assert resp.json()["uuid"] == "regress-user-uuid-001" @pytest.mark.asyncio -async def test_signal_from_registered_unblocked_user_succeeds() -> None: - """Зарегистрированный незаблокированный пользователь может отправить сигнал.""" +async def test_signal_from_unblocked_user_succeeds() -> None: + """Незаблокированный пользователь, созданный через admin API, может отправить сигнал.""" async with make_app_client() as client: - reg_resp = await client.post( - "/api/register", - json={"uuid": _UUID_SIG_OK, "name": "SignalUser"}, + create_resp = await client.post( + "/admin/users", + json={"uuid": "regress-signal-uuid-001", "name": "SignalUser"}, + headers=ADMIN_HEADERS, ) - assert reg_resp.status_code == 200 - api_key = reg_resp.json()["api_key"] - user_uuid = reg_resp.json()["uuid"] + user_uuid = create_resp.json()["uuid"] signal_resp = await client.post( "/api/signal", json={"user_id": user_uuid, "timestamp": 1700000000000, "geo": None}, - headers={"Authorization": f"Bearer {api_key}"}, ) assert signal_resp.status_code == 200 assert signal_resp.json()["status"] == "ok" diff --git a/tests/test_baton_006.py b/tests/test_baton_006.py index b76681e..72ec197 100644 --- a/tests/test_baton_006.py +++ b/tests/test_baton_006.py @@ -11,9 +11,6 @@ Acceptance criteria: 6. POST /api/register возвращает 200 (FastAPI-маршрут не сломан). 7. POST /api/signal возвращает 200 (FastAPI-маршрут не сломан). 8. POST /api/webhook/telegram возвращает 200 с корректным секретом. - -BATON-SEC-003: POST /api/signal now requires Authorization: Bearer . -UUID constants satisfy the UUID v4 pattern. """ from __future__ import annotations @@ -26,7 +23,6 @@ os.environ.setdefault("CHAT_ID", "-1001234567890") os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret") os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram") os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000") -os.environ.setdefault("ADMIN_TOKEN", "test-admin-token") import pytest @@ -35,10 +31,6 @@ from tests.conftest import make_app_client PROJECT_ROOT = Path(__file__).parent.parent NGINX_CONF = PROJECT_ROOT / "nginx" / "baton.conf" -# Valid UUID v4 constants -_UUID_REG = "e0000001-0000-4000-8000-000000000001" -_UUID_SIG = "e0000002-0000-4000-8000-000000000002" - # --------------------------------------------------------------------------- # Criterion 1 — location /api/ proxies to FastAPI # --------------------------------------------------------------------------- @@ -60,6 +52,7 @@ def test_nginx_conf_has_api_location_block() -> None: def test_nginx_conf_api_location_proxies_to_fastapi() -> None: """Блок location /api/ должен делать proxy_pass на 127.0.0.1:8000.""" content = NGINX_CONF.read_text(encoding="utf-8") + # Ищем блок api и proxy_pass внутри api_block = re.search( r"location\s+/api/\s*\{([^}]+)\}", content, re.DOTALL ) @@ -102,6 +95,7 @@ def test_nginx_conf_health_location_proxies_to_fastapi() -> None: def test_nginx_conf_root_location_has_root_directive() -> None: """location / в nginx.conf должен содержать директиву root (статика).""" content = NGINX_CONF.read_text(encoding="utf-8") + # Ищем последний блок location / (не /api/, не /health) root_block = re.search( r"location\s+/\s*\{([^}]+)\}", content, re.DOTALL ) @@ -185,14 +179,13 @@ async def test_api_register_not_broken_after_nginx_change() -> None: async with make_app_client() as client: response = await client.post( "/api/register", - json={"uuid": _UUID_REG, "name": "TestUser"}, + json={"uuid": "baton-006-uuid-001", "name": "TestUser"}, ) assert response.status_code == 200 data = response.json() assert data["user_id"] > 0 - assert data["uuid"] == _UUID_REG - assert "api_key" in data + assert data["uuid"] == "baton-006-uuid-001" # --------------------------------------------------------------------------- @@ -204,21 +197,19 @@ async def test_api_register_not_broken_after_nginx_change() -> None: async def test_api_signal_not_broken_after_nginx_change() -> None: """POST /api/signal должен вернуть 200 — функция не сломана изменением nginx.""" async with make_app_client() as client: - reg_resp = await client.post( + # Сначала регистрируем пользователя + await client.post( "/api/register", - json={"uuid": _UUID_SIG, "name": "SignalUser"}, + json={"uuid": "baton-006-uuid-002", "name": "SignalUser"}, ) - assert reg_resp.status_code == 200 - api_key = reg_resp.json()["api_key"] - + # Отправляем сигнал response = await client.post( "/api/signal", json={ - "user_id": _UUID_SIG, + "user_id": "baton-006-uuid-002", "timestamp": 1700000000000, "geo": None, }, - headers={"Authorization": f"Bearer {api_key}"}, ) assert response.status_code == 200 diff --git a/tests/test_baton_007.py b/tests/test_baton_007.py deleted file mode 100644 index 2be7818..0000000 --- a/tests/test_baton_007.py +++ /dev/null @@ -1,381 +0,0 @@ -""" -Tests for BATON-007: Verifying real Telegram delivery when a signal is sent. - -Acceptance criteria: -1. After pressing the button, a message physically appears in the Telegram group. - (verified: send_message is called with correct content containing user name) -2. journalctl -u baton does NOT throw ERROR during send. - (verified: no exception is raised when Telegram returns 200) -3. A repeated request is also delivered. - (verified: two consecutive signals each trigger send_message) - -NOTE: These tests verify that send_message is called with correct parameters. -Physical delivery to an actual Telegram group is outside unit test scope. -""" -from __future__ import annotations - -import asyncio -import 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") - -import json -from unittest.mock import AsyncMock, patch - -import httpx -import pytest -import respx -from httpx import AsyncClient, ASGITransport - -from tests.conftest import make_app_client, temp_db - -# 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" -_UUID_F = "d0000006-0000-4000-8000-000000000006" - - -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" - ) - - -# --------------------------------------------------------------------------- -# Director revision: regression #1214, #1226 -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_send_message_uses_negative_chat_id_from_config(): - """Regression #1226: send_message must POST to Telegram with a negative chat_id. - - Root cause of BATON-007: CHAT_ID=5190015988 (positive = user ID) was set in .env - instead of -5190015988 (negative = group ID). This test inspects the actual - chat_id value in the HTTP request body — not just call_count. - """ - 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("regression #1226") - - assert route.called - body = json.loads(route.calls[0].request.content) - chat_id = body["chat_id"] - assert chat_id == _cfg.CHAT_ID, ( - f"Expected chat_id={_cfg.CHAT_ID!r}, got {chat_id!r}" - ) - assert str(chat_id).startswith("-"), ( - f"Regression #1226: chat_id must be negative (group ID), got: {chat_id!r}. " - "Positive chat_id is a user ID, not a Telegram group." - ) - - -@pytest.mark.asyncio -async def test_send_message_4xx_does_not_trigger_retry_loop(): - """Regression #1214: on Telegram 4xx (wrong chat_id), retry loop must NOT run. - - Only one HTTP call should be made. Retrying a 4xx is pointless — it will - keep failing. send_message must break immediately on any 4xx response. - """ - 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( - 400, - json={"ok": False, "error_code": 400, "description": "Bad Request: chat not found"}, - ) - ) - await send_message("retry test #1214") - - assert route.call_count == 1, ( - f"Regression #1214: expected exactly 1 HTTP call on 4xx, got {route.call_count}. " - "send_message must break immediately on client errors — no retry loop." - ) - - -@pytest.mark.asyncio -async def test_signal_endpoint_returns_200_on_telegram_4xx(caplog): - """Regression: /api/signal must return 200 even when Telegram Bot API returns 4xx. - - When CHAT_ID is wrong (or any Telegram 4xx), the error must be logged by - send_message but the /api/signal endpoint must still return 200 — the signal - was saved to DB, only the Telegram notification failed. - """ - from backend import config as _cfg - from backend.main import app - - send_url = f"https://api.telegram.org/bot{_cfg.BOT_TOKEN}/sendMessage" - tg_set_url = f"https://api.telegram.org/bot{_cfg.BOT_TOKEN}/setWebhook" - get_me_url = f"https://api.telegram.org/bot{_cfg.BOT_TOKEN}/getMe" - - with temp_db(): - with respx.mock(assert_all_called=False) as mock_tg: - mock_tg.get(get_me_url).mock( - return_value=httpx.Response(200, json={"ok": True, "result": {"username": "testbot"}}) - ) - mock_tg.post(tg_set_url).mock( - return_value=httpx.Response(200, json={"ok": True, "result": True}) - ) - mock_tg.post(send_url).mock( - return_value=httpx.Response( - 400, - json={"ok": False, "error_code": 400, "description": "Bad Request: chat not found"}, - ) - ) - - 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_F, "name": "Tg4xxUser"}) - assert reg.status_code == 200, f"Register failed: {reg.text}" - api_key = reg.json()["api_key"] - - with caplog.at_level(logging.ERROR, logger="backend.telegram"): - resp = await client.post( - "/api/signal", - json={"user_id": _UUID_F, "timestamp": 1742478000000}, - headers={"Authorization": f"Bearer {api_key}"}, - ) - await asyncio.sleep(0) - - assert resp.status_code == 200, ( - f"Expected /api/signal to return 200 even when Telegram returns 4xx, got {resp.status_code}" - ) - assert any("400" in r.message for r in caplog.records), ( - "Expected ERROR log containing '400' when Telegram returns 4xx. " - "Error must be logged, not silently swallowed." - ) diff --git a/tests/test_baton_008.py b/tests/test_baton_008.py deleted file mode 100644 index cc597a6..0000000 --- a/tests/test_baton_008.py +++ /dev/null @@ -1,888 +0,0 @@ -""" -Tests for BATON-008: Registration flow with Telegram admin approval. - -Acceptance criteria: -1. POST /api/auth/register returns 201 with status='pending' on valid input -2. POST /api/auth/register returns 409 on email or login conflict -3. POST /api/auth/register returns 422 on invalid email/login/password -4. Telegram notification is fire-and-forget — 201 is returned even if Telegram fails -5. Webhook callback_query approve → db status='approved', push task fired if subscription present -6. Webhook callback_query reject → db status='rejected' -7. Webhook callback_query with unknown reg_id → returns {"ok": True} gracefully -""" -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") -os.environ.setdefault("ADMIN_CHAT_ID", "5694335584") - -from unittest.mock import AsyncMock, patch - -import pytest - -from tests.conftest import make_app_client - -_WEBHOOK_SECRET = "test-webhook-secret" -_WEBHOOK_HEADERS = {"X-Telegram-Bot-Api-Secret-Token": _WEBHOOK_SECRET} - -_VALID_PAYLOAD = { - "email": "user@tutlot.com", - "login": "testuser", - "password": "strongpassword123", -} - - -# --------------------------------------------------------------------------- -# 1. Happy path — 201 -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_auth_register_returns_201_pending(): - """Valid registration request returns 201 with status='pending'.""" - async with make_app_client() as client: - with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock): - resp = await client.post("/api/auth/register", json=_VALID_PAYLOAD) - - assert resp.status_code == 201, f"Expected 201, got {resp.status_code}: {resp.text}" - body = resp.json() - assert body["status"] == "pending" - assert "message" in body - - -@pytest.mark.asyncio -async def test_auth_register_fire_and_forget_telegram_error_still_returns_201(): - """Telegram failure must not break 201 — fire-and-forget pattern.""" - async with make_app_client() as client: - with patch( - "backend.telegram.send_registration_notification", - new_callable=AsyncMock, - side_effect=Exception("Telegram down"), - ): - resp = await client.post( - "/api/auth/register", - json={**_VALID_PAYLOAD, "email": "other@tutlot.com", "login": "otheruser"}, - ) - await asyncio.sleep(0) - - assert resp.status_code == 201, f"Telegram error must not break 201, got {resp.status_code}" - - -# --------------------------------------------------------------------------- -# 2. Conflict — 409 -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_auth_register_409_on_duplicate_email(): - """Duplicate email returns 409.""" - async with make_app_client() as client: - with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock): - r1 = await client.post("/api/auth/register", json=_VALID_PAYLOAD) - assert r1.status_code == 201, f"First registration failed: {r1.text}" - - r2 = await client.post( - "/api/auth/register", - json={**_VALID_PAYLOAD, "login": "differentlogin"}, - ) - - assert r2.status_code == 409, f"Expected 409 on duplicate email, got {r2.status_code}" - - -@pytest.mark.asyncio -async def test_auth_register_409_on_duplicate_login(): - """Duplicate login returns 409.""" - async with make_app_client() as client: - with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock): - r1 = await client.post("/api/auth/register", json=_VALID_PAYLOAD) - assert r1.status_code == 201, f"First registration failed: {r1.text}" - - r2 = await client.post( - "/api/auth/register", - json={**_VALID_PAYLOAD, "email": "different@tutlot.com"}, - ) - - assert r2.status_code == 409, f"Expected 409 on duplicate login, got {r2.status_code}" - - -# --------------------------------------------------------------------------- -# 3. Validation — 422 -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_auth_register_422_invalid_email(): - """Invalid email format returns 422.""" - async with make_app_client() as client: - resp = await client.post( - "/api/auth/register", - json={**_VALID_PAYLOAD, "email": "not-an-email"}, - ) - assert resp.status_code == 422, f"Expected 422 on invalid email, got {resp.status_code}" - - -@pytest.mark.asyncio -async def test_auth_register_422_short_login(): - """Login shorter than 3 chars returns 422.""" - async with make_app_client() as client: - resp = await client.post( - "/api/auth/register", - json={**_VALID_PAYLOAD, "login": "ab"}, - ) - assert resp.status_code == 422, f"Expected 422 on short login, got {resp.status_code}" - - -@pytest.mark.asyncio -async def test_auth_register_422_login_invalid_chars(): - """Login with spaces/special chars returns 422.""" - async with make_app_client() as client: - resp = await client.post( - "/api/auth/register", - json={**_VALID_PAYLOAD, "login": "invalid login!"}, - ) - assert resp.status_code == 422, f"Expected 422 on login with spaces, got {resp.status_code}" - - -@pytest.mark.asyncio -async def test_auth_register_422_short_password(): - """Password shorter than 8 chars returns 422.""" - async with make_app_client() as client: - resp = await client.post( - "/api/auth/register", - json={**_VALID_PAYLOAD, "password": "short"}, - ) - assert resp.status_code == 422, f"Expected 422 on short password, got {resp.status_code}" - - -# --------------------------------------------------------------------------- -# 4. Telegram notification is sent to ADMIN_CHAT_ID -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_auth_register_sends_notification_to_admin(): - """Registration triggers real HTTP sendMessage to ADMIN_CHAT_ID with correct login/email.""" - from backend import config as _cfg - - captured: list[dict] = [] - async with make_app_client(capture_send_requests=captured) as client: - resp = await client.post("/api/auth/register", json=_VALID_PAYLOAD) - assert resp.status_code == 201 - await asyncio.sleep(0) - - admin_chat_id = str(_cfg.ADMIN_CHAT_ID) - admin_msgs = [r for r in captured if str(r.get("chat_id")) == admin_chat_id] - assert len(admin_msgs) >= 1, ( - f"Expected sendMessage to ADMIN_CHAT_ID={admin_chat_id!r}, captured: {captured}" - ) - text = admin_msgs[0].get("text", "") - assert _VALID_PAYLOAD["login"] in text, f"Expected login in text: {text!r}" - assert _VALID_PAYLOAD["email"] in text, f"Expected email in text: {text!r}" - - -# --------------------------------------------------------------------------- -# 5. Webhook callback_query — approve -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_webhook_callback_approve_updates_db_status(): - """approve callback updates registration status to 'approved' in DB.""" - from backend import db as _db - - async with make_app_client() as client: - with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock): - reg_resp = await client.post("/api/auth/register", json=_VALID_PAYLOAD) - assert reg_resp.status_code == 201 - - # We need reg_id — get it from DB directly - reg_id = None - from tests.conftest import temp_db as _temp_db # noqa: F401 — already active - from backend import config as _cfg - import aiosqlite - async with aiosqlite.connect(_cfg.DB_PATH) as conn: - conn.row_factory = aiosqlite.Row - async with conn.execute("SELECT id FROM registrations LIMIT 1") as cur: - row = await cur.fetchone() - reg_id = row["id"] if row else None - - assert reg_id is not None, "Registration not found in DB" - - cb_payload = { - "callback_query": { - "id": "cq_001", - "data": f"approve:{reg_id}", - "message": { - "message_id": 42, - "chat": {"id": 5694335584}, - }, - } - } - resp = await client.post( - "/api/webhook/telegram", - json=cb_payload, - headers=_WEBHOOK_HEADERS, - ) - await asyncio.sleep(0) - - assert resp.status_code == 200 - assert resp.json() == {"ok": True} - - # Verify DB status updated - reg = await _db.get_registration(reg_id) - assert reg is not None - assert reg["status"] == "approved", f"Expected status='approved', got {reg['status']!r}" - - -@pytest.mark.asyncio -async def test_webhook_callback_approve_fires_push_when_subscription_present(): - """approve callback triggers send_push when push_subscription is set.""" - push_sub = { - "endpoint": "https://fcm.googleapis.com/fcm/send/test", - "keys": {"p256dh": "BQABC", "auth": "xyz"}, - } - - async with make_app_client() as client: - with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock): - reg_resp = await client.post( - "/api/auth/register", - json={**_VALID_PAYLOAD, "push_subscription": push_sub}, - ) - assert reg_resp.status_code == 201 - - from backend import config as _cfg - import aiosqlite - async with aiosqlite.connect(_cfg.DB_PATH) as conn: - conn.row_factory = aiosqlite.Row - async with conn.execute("SELECT id FROM registrations LIMIT 1") as cur: - row = await cur.fetchone() - reg_id = row["id"] if row else None - assert reg_id is not None - - cb_payload = { - "callback_query": { - "id": "cq_002", - "data": f"approve:{reg_id}", - "message": {"message_id": 43, "chat": {"id": 5694335584}}, - } - } - push_calls: list = [] - - async def _capture_push(sub_json, title, body): - push_calls.append(sub_json) - - with patch("backend.push.send_push", side_effect=_capture_push): - await client.post("/api/webhook/telegram", json=cb_payload, headers=_WEBHOOK_HEADERS) - await asyncio.sleep(0) - - assert len(push_calls) == 1, f"Expected 1 push call, got {len(push_calls)}" - - -# --------------------------------------------------------------------------- -# 6. Webhook callback_query — reject -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_webhook_callback_reject_updates_db_status(): - """reject callback updates registration status to 'rejected' in DB.""" - from backend import db as _db - - async with make_app_client() as client: - with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock): - reg_resp = await client.post("/api/auth/register", json=_VALID_PAYLOAD) - assert reg_resp.status_code == 201 - - from backend import config as _cfg - import aiosqlite - async with aiosqlite.connect(_cfg.DB_PATH) as conn: - conn.row_factory = aiosqlite.Row - async with conn.execute("SELECT id FROM registrations LIMIT 1") as cur: - row = await cur.fetchone() - reg_id = row["id"] if row else None - assert reg_id is not None - - cb_payload = { - "callback_query": { - "id": "cq_003", - "data": f"reject:{reg_id}", - "message": {"message_id": 44, "chat": {"id": 5694335584}}, - } - } - resp = await client.post( - "/api/webhook/telegram", json=cb_payload, headers=_WEBHOOK_HEADERS - ) - await asyncio.sleep(0) - - assert resp.status_code == 200 - - reg = await _db.get_registration(reg_id) - assert reg is not None - assert reg["status"] == "rejected", f"Expected status='rejected', got {reg['status']!r}" - - -# --------------------------------------------------------------------------- -# 7. Unknown reg_id — graceful handling -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_webhook_callback_unknown_reg_id_returns_ok(): - """callback_query with unknown reg_id returns ok without error.""" - async with make_app_client() as client: - cb_payload = { - "callback_query": { - "id": "cq_999", - "data": "approve:99999", - "message": {"message_id": 1, "chat": {"id": 5694335584}}, - } - } - resp = await client.post( - "/api/webhook/telegram", json=cb_payload, headers=_WEBHOOK_HEADERS - ) - await asyncio.sleep(0) - - assert resp.status_code == 200 - assert resp.json() == {"ok": True} - - -# --------------------------------------------------------------------------- -# 8. Registration without push_subscription -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_register_without_push_subscription(): - """Registration with push_subscription=null returns 201.""" - async with make_app_client() as client: - with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock): - resp = await client.post( - "/api/auth/register", - json={**_VALID_PAYLOAD, "email": "nopush@tutlot.com", "login": "nopushuser"}, - ) - assert resp.status_code == 201 - assert resp.json()["status"] == "pending" - - -# --------------------------------------------------------------------------- -# 9. reject does NOT trigger Web Push -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_webhook_callback_reject_does_not_send_push(): - """reject callback does NOT call send_push.""" - async with make_app_client() as client: - with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock): - reg_resp = await client.post("/api/auth/register", json=_VALID_PAYLOAD) - assert reg_resp.status_code == 201 - - from backend import config as _cfg - import aiosqlite - async with aiosqlite.connect(_cfg.DB_PATH) as conn: - conn.row_factory = aiosqlite.Row - async with conn.execute("SELECT id FROM registrations LIMIT 1") as cur: - row = await cur.fetchone() - reg_id = row["id"] if row else None - assert reg_id is not None - - cb_payload = { - "callback_query": { - "id": "cq_r001", - "data": f"reject:{reg_id}", - "message": {"message_id": 50, "chat": {"id": 5694335584}}, - } - } - push_calls: list = [] - - async def _capture_push(sub_json, title, body): - push_calls.append(sub_json) - - with patch("backend.push.send_push", side_effect=_capture_push): - await client.post("/api/webhook/telegram", json=cb_payload, headers=_WEBHOOK_HEADERS) - await asyncio.sleep(0) - - assert len(push_calls) == 0, f"Expected 0 push calls on reject, got {len(push_calls)}" - - -# --------------------------------------------------------------------------- -# 10. approve calls editMessageText with ✅ text -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_webhook_callback_approve_edits_message(): - """approve callback calls editMessageText with '✅ Пользователь ... одобрен'.""" - async with make_app_client() as client: - with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock): - reg_resp = await client.post( - "/api/auth/register", - json={**_VALID_PAYLOAD, "email": "edit@tutlot.com", "login": "edituser"}, - ) - assert reg_resp.status_code == 201 - - from backend import config as _cfg - import aiosqlite - async with aiosqlite.connect(_cfg.DB_PATH) as conn: - conn.row_factory = aiosqlite.Row - async with conn.execute("SELECT id FROM registrations LIMIT 1") as cur: - row = await cur.fetchone() - reg_id = row["id"] if row else None - assert reg_id is not None - - cb_payload = { - "callback_query": { - "id": "cq_e001", - "data": f"approve:{reg_id}", - "message": {"message_id": 51, "chat": {"id": 5694335584}}, - } - } - edit_calls: list[str] = [] - - async def _capture_edit(chat_id, message_id, text): - edit_calls.append(text) - - with patch("backend.telegram.edit_message_text", side_effect=_capture_edit): - await client.post("/api/webhook/telegram", json=cb_payload, headers=_WEBHOOK_HEADERS) - - assert len(edit_calls) == 1, f"Expected 1 editMessageText call, got {len(edit_calls)}" - assert "✅" in edit_calls[0], f"Expected ✅ in edit text, got: {edit_calls[0]!r}" - assert "edituser" in edit_calls[0], f"Expected login in edit text, got: {edit_calls[0]!r}" - - -# --------------------------------------------------------------------------- -# 11. answerCallbackQuery is called after callback processing -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_webhook_callback_answer_sent(): - """answerCallbackQuery is called with the callback_query_id after processing.""" - async with make_app_client() as client: - with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock): - reg_resp = await client.post( - "/api/auth/register", - json={**_VALID_PAYLOAD, "email": "answer@tutlot.com", "login": "answeruser"}, - ) - assert reg_resp.status_code == 201 - - from backend import config as _cfg - import aiosqlite - async with aiosqlite.connect(_cfg.DB_PATH) as conn: - conn.row_factory = aiosqlite.Row - async with conn.execute("SELECT id FROM registrations LIMIT 1") as cur: - row = await cur.fetchone() - reg_id = row["id"] if row else None - assert reg_id is not None - - cb_payload = { - "callback_query": { - "id": "cq_a001", - "data": f"approve:{reg_id}", - "message": {"message_id": 52, "chat": {"id": 5694335584}}, - } - } - answer_calls: list[str] = [] - - async def _capture_answer(callback_query_id): - answer_calls.append(callback_query_id) - - with patch("backend.telegram.answer_callback_query", side_effect=_capture_answer): - await client.post("/api/webhook/telegram", json=cb_payload, headers=_WEBHOOK_HEADERS) - - assert len(answer_calls) == 1, f"Expected 1 answerCallbackQuery call, got {len(answer_calls)}" - assert answer_calls[0] == "cq_a001" - - -# --------------------------------------------------------------------------- -# 12. CORS — Authorization header is allowed -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_cors_authorization_header_allowed(): - """CORS preflight request allows Authorization header.""" - async with make_app_client() as client: - resp = await client.options( - "/api/auth/register", - headers={ - "Origin": "http://localhost:3000", - "Access-Control-Request-Method": "POST", - "Access-Control-Request-Headers": "Authorization", - }, - ) - assert resp.status_code in (200, 204), f"CORS preflight returned {resp.status_code}" - allow_headers = resp.headers.get("access-control-allow-headers", "") - assert "authorization" in allow_headers.lower(), ( - f"Authorization not in Access-Control-Allow-Headers: {allow_headers!r}" - ) - - -# --------------------------------------------------------------------------- -# 13. DB — registrations table exists after init_db -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_registrations_table_created(): - """init_db creates the registrations table with correct schema.""" - from tests.conftest import temp_db - from backend import db as _db, config as _cfg - import aiosqlite - - with temp_db(): - await _db.init_db() - async with aiosqlite.connect(_cfg.DB_PATH) as conn: - async with conn.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name='registrations'" - ) as cur: - row = await cur.fetchone() - assert row is not None, "Table 'registrations' not found after init_db()" - - -# --------------------------------------------------------------------------- -# 14. DB — password_hash uses PBKDF2 '{salt_hex}:{dk_hex}' format -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_password_hash_stored_in_pbkdf2_format(): - """Stored password_hash uses ':' PBKDF2 format.""" - from backend import config as _cfg - import aiosqlite - - async with make_app_client() as client: - with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock): - await client.post( - "/api/auth/register", - json={**_VALID_PAYLOAD, "email": "pbkdf2@tutlot.com", "login": "pbkdf2user"}, - ) - - async with aiosqlite.connect(_cfg.DB_PATH) as conn: - conn.row_factory = aiosqlite.Row - async with conn.execute( - "SELECT password_hash FROM registrations WHERE login = 'pbkdf2user'" - ) as cur: - row = await cur.fetchone() - - assert row is not None, "Registration not found in DB" - password_hash = row["password_hash"] - assert ":" in password_hash, f"Expected 'salt:hash' format, got {password_hash!r}" - parts = password_hash.split(":") - assert len(parts) == 2, f"Expected exactly one colon separator, got {password_hash!r}" - salt_hex, dk_hex = parts - # salt = os.urandom(16) → 32 hex chars; dk = SHA-256 output (32 bytes) → 64 hex chars - assert len(salt_hex) == 32, f"Expected 32-char salt hex, got {len(salt_hex)}" - assert len(dk_hex) == 64, f"Expected 64-char dk hex (SHA-256), got {len(dk_hex)}" - int(salt_hex, 16) # raises ValueError if not valid hex - int(dk_hex, 16) - - -# --------------------------------------------------------------------------- -# 15. State machine — повторное нажатие approve на уже approved -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_webhook_callback_double_approve_does_not_send_push(): - """Second approve on already-approved registration must NOT fire push.""" - push_sub = { - "endpoint": "https://fcm.googleapis.com/fcm/send/test2", - "keys": {"p256dh": "BQDEF", "auth": "abc"}, - } - async with make_app_client() as client: - with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock): - reg_resp = await client.post( - "/api/auth/register", - json={**_VALID_PAYLOAD, "email": "double@tutlot.com", "login": "doubleuser", "push_subscription": push_sub}, - ) - assert reg_resp.status_code == 201 - - from backend import config as _cfg - import aiosqlite - async with aiosqlite.connect(_cfg.DB_PATH) as conn: - conn.row_factory = aiosqlite.Row - async with conn.execute("SELECT id FROM registrations LIMIT 1") as cur: - row = await cur.fetchone() - reg_id = row["id"] if row else None - assert reg_id is not None - - cb_payload = { - "callback_query": { - "id": "cq_d1", - "data": f"approve:{reg_id}", - "message": {"message_id": 60, "chat": {"id": 5694335584}}, - } - } - - # First approve — should succeed - await client.post("/api/webhook/telegram", json=cb_payload, headers=_WEBHOOK_HEADERS) - await asyncio.sleep(0) - - # Second approve — push must NOT be fired - push_calls: list = [] - - async def _capture_push(sub_json, title, body): - push_calls.append(sub_json) - - cb_payload2 = {**cb_payload, "callback_query": {**cb_payload["callback_query"], "id": "cq_d2"}} - with patch("backend.push.send_push", side_effect=_capture_push): - await client.post("/api/webhook/telegram", json=cb_payload2, headers=_WEBHOOK_HEADERS) - await asyncio.sleep(0) - - assert len(push_calls) == 0, f"Second approve must not fire push, got {len(push_calls)} calls" - - # Also verify status is still 'approved' - from backend import db as _db - # Can't check here as client context is closed; DB assertion was covered by state machine logic - - -@pytest.mark.asyncio -async def test_webhook_callback_double_approve_status_stays_approved(): - """Status remains 'approved' after a second approve callback.""" - from backend import db as _db - - async with make_app_client() as client: - with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock): - reg_resp = await client.post( - "/api/auth/register", - json={**_VALID_PAYLOAD, "email": "stay@tutlot.com", "login": "stayuser"}, - ) - assert reg_resp.status_code == 201 - - from backend import config as _cfg - import aiosqlite - async with aiosqlite.connect(_cfg.DB_PATH) as conn: - conn.row_factory = aiosqlite.Row - async with conn.execute("SELECT id FROM registrations LIMIT 1") as cur: - row = await cur.fetchone() - reg_id = row["id"] if row else None - assert reg_id is not None - - cb = { - "callback_query": { - "id": "cq_s1", - "data": f"approve:{reg_id}", - "message": {"message_id": 70, "chat": {"id": 5694335584}}, - } - } - await client.post("/api/webhook/telegram", json=cb, headers=_WEBHOOK_HEADERS) - await asyncio.sleep(0) - - cb2 = {**cb, "callback_query": {**cb["callback_query"], "id": "cq_s2"}} - await client.post("/api/webhook/telegram", json=cb2, headers=_WEBHOOK_HEADERS) - await asyncio.sleep(0) - - reg = await _db.get_registration(reg_id) - assert reg["status"] == "approved", f"Expected 'approved', got {reg['status']!r}" - - -# --------------------------------------------------------------------------- -# 16. State machine — approve после reject -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_webhook_callback_approve_after_reject_status_stays_rejected(): - """Approve after reject must NOT change status — remains 'rejected'.""" - from backend import db as _db - - async with make_app_client() as client: - with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock): - reg_resp = await client.post( - "/api/auth/register", - json={**_VALID_PAYLOAD, "email": "artest@tutlot.com", "login": "artestuser"}, - ) - assert reg_resp.status_code == 201 - - from backend import config as _cfg - import aiosqlite - async with aiosqlite.connect(_cfg.DB_PATH) as conn: - conn.row_factory = aiosqlite.Row - async with conn.execute("SELECT id FROM registrations LIMIT 1") as cur: - row = await cur.fetchone() - reg_id = row["id"] if row else None - assert reg_id is not None - - # First: reject - rej_cb = { - "callback_query": { - "id": "cq_ar1", - "data": f"reject:{reg_id}", - "message": {"message_id": 80, "chat": {"id": 5694335584}}, - } - } - await client.post("/api/webhook/telegram", json=rej_cb, headers=_WEBHOOK_HEADERS) - await asyncio.sleep(0) - - # Then: approve — must be ignored - push_calls: list = [] - - async def _capture_push(sub_json, title, body): - push_calls.append(sub_json) - - app_cb = { - "callback_query": { - "id": "cq_ar2", - "data": f"approve:{reg_id}", - "message": {"message_id": 81, "chat": {"id": 5694335584}}, - } - } - with patch("backend.push.send_push", side_effect=_capture_push): - await client.post("/api/webhook/telegram", json=app_cb, headers=_WEBHOOK_HEADERS) - await asyncio.sleep(0) - - reg = await _db.get_registration(reg_id) - assert reg["status"] == "rejected", f"Expected 'rejected', got {reg['status']!r}" - - assert len(push_calls) == 0, f"Approve after reject must not fire push, got {len(push_calls)}" - - -# --------------------------------------------------------------------------- -# 17. Rate limiting — 4th request returns 429 -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_auth_register_rate_limit_fourth_request_returns_429(): - """4th registration request from same IP within the window returns 429.""" - async with make_app_client() as client: - with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock): - for i in range(3): - r = await client.post( - "/api/auth/register", - json={ - "email": f"ratetest{i}@tutlot.com", - "login": f"ratetest{i}", - "password": "strongpassword123", - }, - ) - assert r.status_code == 201, f"Request {i+1} should succeed, got {r.status_code}" - - # 4th request — must be rate-limited - r4 = await client.post( - "/api/auth/register", - json={ - "email": "ratetest4@tutlot.com", - "login": "ratetest4", - "password": "strongpassword123", - }, - ) - - assert r4.status_code == 429, f"Expected 429 on 4th request, got {r4.status_code}" - - -# --------------------------------------------------------------------------- -# 18. VAPID public key endpoint /api/push/public-key -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_vapid_public_key_new_endpoint_returns_200(): - """GET /api/push/public-key returns 200 with vapid_public_key field.""" - async with make_app_client() as client: - resp = await client.get("/api/push/public-key") - - assert resp.status_code == 200, f"Expected 200, got {resp.status_code}: {resp.text}" - body = resp.json() - assert "vapid_public_key" in body, f"Expected 'vapid_public_key' in response, got {body}" - - -# --------------------------------------------------------------------------- -# 19. Password max length — 129 chars → 422 -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_auth_register_422_password_too_long(): - """Password of 129 characters returns 422.""" - async with make_app_client() as client: - resp = await client.post( - "/api/auth/register", - json={**_VALID_PAYLOAD, "password": "a" * 129}, - ) - assert resp.status_code == 422, f"Expected 422 on 129-char password, got {resp.status_code}" - - -# --------------------------------------------------------------------------- -# 20. Login max length — 31 chars → 422 -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_auth_register_422_login_too_long(): - """Login of 31 characters returns 422.""" - async with make_app_client() as client: - resp = await client.post( - "/api/auth/register", - json={**_VALID_PAYLOAD, "login": "a" * 31}, - ) - assert resp.status_code == 422, f"Expected 422 on 31-char login, got {resp.status_code}" - - -# --------------------------------------------------------------------------- -# 21. Empty body — POST /api/auth/register with {} → 422 -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_auth_register_422_empty_body(): - """Empty JSON body returns 422.""" - async with make_app_client() as client: - resp = await client.post("/api/auth/register", json={}) - assert resp.status_code == 422, f"Expected 422 on empty body, got {resp.status_code}" - - -# --------------------------------------------------------------------------- -# 22. Malformed callback_data — no colon → ok:True without crash -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_webhook_callback_malformed_data_no_colon_returns_ok(): - """callback_query with data='garbage' (no colon) returns ok:True gracefully.""" - async with make_app_client() as client: - cb_payload = { - "callback_query": { - "id": "cq_mal1", - "data": "garbage", - "message": {"message_id": 90, "chat": {"id": 5694335584}}, - } - } - resp = await client.post( - "/api/webhook/telegram", json=cb_payload, headers=_WEBHOOK_HEADERS - ) - - assert resp.status_code == 200, f"Expected 200, got {resp.status_code}" - assert resp.json() == {"ok": True}, f"Expected ok:True, got {resp.json()}" - - -# --------------------------------------------------------------------------- -# 23. Non-numeric reg_id — data='approve:abc' → ok:True without crash -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_webhook_callback_non_numeric_reg_id_returns_ok(): - """callback_query with data='approve:abc' (non-numeric reg_id) returns ok:True.""" - async with make_app_client() as client: - cb_payload = { - "callback_query": { - "id": "cq_nan1", - "data": "approve:abc", - "message": {"message_id": 91, "chat": {"id": 5694335584}}, - } - } - resp = await client.post( - "/api/webhook/telegram", json=cb_payload, headers=_WEBHOOK_HEADERS - ) - - assert resp.status_code == 200, f"Expected 200, got {resp.status_code}" - assert resp.json() == {"ok": True}, f"Expected ok:True, got {resp.json()}" diff --git a/tests/test_baton_008_frontend.py b/tests/test_baton_008_frontend.py deleted file mode 100644 index 5b8eeb2..0000000 --- a/tests/test_baton_008_frontend.py +++ /dev/null @@ -1,439 +0,0 @@ -""" -Tests for BATON-008: Frontend registration module. - -Acceptance criteria: -1. index.html — форма регистрации с полями email, login, password присутствует -2. index.html — НЕТ захардкоженных VAPID-ключей в HTML-атрибутах (decision #1333) -3. app.js — вызов /api/push/public-key (не старый /api/vapid-public-key) (decision #1331) -4. app.js — guard для PushManager (decision #1332) -5. app.js — обработчик для кнопки регистрации (#btn-register → _handleSignUp) -6. app.js — переключение между view-login и view-register -7. app.js — показ ошибок пользователю (_setRegStatus) -8. GET /api/push/public-key → 200 с vapid_public_key (API контракт) -9. POST /api/auth/register с валидными данными → 201 (API контракт) -10. POST /api/auth/register с дублирующим email → 409 -11. POST /api/auth/register с дублирующим login → 409 -12. POST /api/auth/register с невалидным email → 422 -""" -from __future__ import annotations - -import re -from pathlib import Path -from unittest.mock import AsyncMock, patch - -import pytest - -PROJECT_ROOT = Path(__file__).parent.parent -INDEX_HTML = PROJECT_ROOT / "frontend" / "index.html" -APP_JS = PROJECT_ROOT / "frontend" / "app.js" - -from tests.conftest import make_app_client - -_VALID_PAYLOAD = { - "email": "frontend_test@tutlot.com", - "login": "frontenduser", - "password": "strongpassword123", -} - - -# --------------------------------------------------------------------------- -# HTML static analysis — Criterion 1: поля формы регистрации -# --------------------------------------------------------------------------- - - -def test_index_html_has_email_field() -> None: - """index.html должен содержать поле email для регистрации (id=reg-email).""" - content = INDEX_HTML.read_text(encoding="utf-8") - assert 'id="reg-email"' in content, ( - "index.html не содержит поле с id='reg-email'" - ) - - -def test_index_html_has_login_field() -> None: - """index.html должен содержать поле логина для регистрации (id=reg-login).""" - content = INDEX_HTML.read_text(encoding="utf-8") - assert 'id="reg-login"' in content, ( - "index.html не содержит поле с id='reg-login'" - ) - - -def test_index_html_has_password_field() -> None: - """index.html должен содержать поле пароля для регистрации (id=reg-password).""" - content = INDEX_HTML.read_text(encoding="utf-8") - assert 'id="reg-password"' in content, ( - "index.html не содержит поле с id='reg-password'" - ) - - -def test_index_html_email_field_has_correct_type() -> None: - """Поле email регистрации должно иметь type='email'.""" - content = INDEX_HTML.read_text(encoding="utf-8") - # Ищем input с id=reg-email и type=email в любом порядке атрибутов - email_input_block = re.search( - r']*id="reg-email"[^>]*>', content, re.DOTALL - ) - assert email_input_block is not None, "Не найден input с id='reg-email'" - assert 'type="email"' in email_input_block.group(0), ( - "Поле reg-email не имеет type='email'" - ) - - -def test_index_html_password_field_has_correct_type() -> None: - """Поле пароля регистрации должно иметь type='password'.""" - content = INDEX_HTML.read_text(encoding="utf-8") - password_input_block = re.search( - r']*id="reg-password"[^>]*>', content, re.DOTALL - ) - assert password_input_block is not None, "Не найден input с id='reg-password'" - assert 'type="password"' in password_input_block.group(0), ( - "Поле reg-password не имеет type='password'" - ) - - -def test_index_html_has_register_button() -> None: - """index.html должен содержать кнопку регистрации (id=btn-register).""" - content = INDEX_HTML.read_text(encoding="utf-8") - assert 'id="btn-register"' in content, ( - "index.html не содержит кнопку с id='btn-register'" - ) - - -def test_index_html_has_switch_to_register_button() -> None: - """index.html должен содержать кнопку переключения на форму регистрации (id=btn-switch-to-register).""" - content = INDEX_HTML.read_text(encoding="utf-8") - assert 'id="btn-switch-to-register"' in content, ( - "index.html не содержит кнопку с id='btn-switch-to-register'" - ) - - -def test_index_html_has_view_register_div() -> None: - """index.html должен содержать блок view-register для формы регистрации.""" - content = INDEX_HTML.read_text(encoding="utf-8") - assert 'id="view-register"' in content, ( - "index.html не содержит блок с id='view-register'" - ) - - -def test_index_html_has_view_login_div() -> None: - """index.html должен содержать блок view-login для онбординга.""" - content = INDEX_HTML.read_text(encoding="utf-8") - assert 'id="view-login"' in content, ( - "index.html не содержит блок с id='view-login'" - ) - - -def test_index_html_has_reg_status_element() -> None: - """index.html должен содержать элемент статуса регистрации (id=reg-status).""" - content = INDEX_HTML.read_text(encoding="utf-8") - assert 'id="reg-status"' in content, ( - "index.html не содержит элемент с id='reg-status'" - ) - - -# --------------------------------------------------------------------------- -# HTML static analysis — Criterion 2: НЕТ захардкоженного VAPID в HTML (decision #1333) -# --------------------------------------------------------------------------- - - -def test_index_html_no_hardcoded_vapid_key_in_meta() -> None: - """index.html НЕ должен содержать VAPID-ключ захардкоженным в meta-теге (decision #1333).""" - content = INDEX_HTML.read_text(encoding="utf-8") - # VAPID public key — это URL-safe base64 строка длиной 87 символов (без padding) - # Ищем характерный паттерн в meta-атрибутах - vapid_in_meta = re.search( - r']+content\s*=\s*["\'][A-Za-z0-9_\-]{60,}["\'][^>]*>', - content, - ) - assert vapid_in_meta is None, ( - f"Найден meta-тег с длинной строкой (возможный VAPID-ключ): " - f"{vapid_in_meta.group(0) if vapid_in_meta else ''}" - ) - - -def test_index_html_no_vapid_key_attribute_pattern() -> None: - """index.html НЕ должен содержать data-vapid-key или аналогичные атрибуты.""" - content = INDEX_HTML.read_text(encoding="utf-8") - assert "vapid" not in content.lower(), ( - "index.html содержит упоминание 'vapid' — VAPID ключ должен читаться через API, " - "а не быть захардкожен в HTML (decision #1333)" - ) - - -# --------------------------------------------------------------------------- -# app.js static analysis — Criterion 3: /api/push/public-key endpoint (decision #1331) -# --------------------------------------------------------------------------- - - -def test_app_js_uses_new_vapid_endpoint() -> None: - """app.js должен обращаться к /api/push/public-key (decision #1331).""" - content = APP_JS.read_text(encoding="utf-8") - assert "/api/push/public-key" in content, ( - "app.js не содержит endpoint '/api/push/public-key'" - ) - - -def test_app_js_does_not_use_old_vapid_endpoint() -> None: - """app.js НЕ должен использовать устаревший /api/vapid-public-key (decision #1331).""" - content = APP_JS.read_text(encoding="utf-8") - assert "/api/vapid-public-key" not in content, ( - "app.js содержит устаревший endpoint '/api/vapid-public-key' — " - "нарушение decision #1331, должен использоваться '/api/push/public-key'" - ) - - -# --------------------------------------------------------------------------- -# app.js static analysis — Criterion 4: PushManager guard (decision #1332) -# --------------------------------------------------------------------------- - - -def test_app_js_has_push_manager_guard_in_registration_flow() -> None: - """app.js должен содержать guard 'PushManager' in window (decision #1332).""" - content = APP_JS.read_text(encoding="utf-8") - assert "'PushManager' in window" in content, ( - "app.js не содержит guard \"'PushManager' in window\" — " - "нарушение decision #1332" - ) - - -def test_app_js_push_manager_guard_combined_with_service_worker_check() -> None: - """Guard PushManager должен сочетаться с проверкой serviceWorker.""" - content = APP_JS.read_text(encoding="utf-8") - # Ищем паттерн совместной проверки serviceWorker + PushManager - assert re.search( - r"serviceWorker.*PushManager|PushManager.*serviceWorker", - content, - re.DOTALL, - ), ( - "app.js не содержит совместной проверки 'serviceWorker' и 'PushManager' — " - "guard неполный (decision #1332)" - ) - - -# --------------------------------------------------------------------------- -# app.js static analysis — Criterion 5: обработчик кнопки регистрации -# --------------------------------------------------------------------------- - - -def test_app_js_has_handle_sign_up_function() -> None: - """app.js должен содержать функцию _handleSignUp.""" - content = APP_JS.read_text(encoding="utf-8") - assert "_handleSignUp" in content, ( - "app.js не содержит функцию '_handleSignUp'" - ) - - -def test_app_js_registers_click_handler_for_btn_register() -> None: - """app.js должен добавлять click-обработчик на btn-register → _handleSignUp.""" - content = APP_JS.read_text(encoding="utf-8") - # Ищем addEventListener на элементе btn-register с вызовом _handleSignUp - assert re.search( - r'btn-register.*addEventListener|addEventListener.*btn-register', - content, - re.DOTALL, - ), ( - "app.js не содержит addEventListener для кнопки 'btn-register'" - ) - # Проверяем что именно _handleSignUp привязан к кнопке - assert re.search( - r'btn[Rr]egister.*_handleSignUp|_handleSignUp.*btn[Rr]egister', - content, - re.DOTALL, - ), ( - "app.js не связывает кнопку 'btn-register' с функцией '_handleSignUp'" - ) - - -# --------------------------------------------------------------------------- -# app.js static analysis — Criterion 6: переключение view-login / view-register -# --------------------------------------------------------------------------- - - -def test_app_js_has_show_view_function() -> None: - """app.js должен содержать функцию _showView для переключения видов.""" - content = APP_JS.read_text(encoding="utf-8") - assert "_showView" in content, ( - "app.js не содержит функцию '_showView'" - ) - - -def test_app_js_show_view_handles_view_login() -> None: - """_showView в app.js должна обрабатывать view-login.""" - content = APP_JS.read_text(encoding="utf-8") - assert "view-login" in content, ( - "app.js не содержит id 'view-login' — нет переключения на вид логина" - ) - - -def test_app_js_show_view_handles_view_register() -> None: - """_showView в app.js должна обрабатывать view-register.""" - content = APP_JS.read_text(encoding="utf-8") - assert "view-register" in content, ( - "app.js не содержит id 'view-register' — нет переключения на вид регистрации" - ) - - -def test_app_js_has_btn_switch_to_register_handler() -> None: - """app.js должен содержать обработчик для btn-switch-to-register.""" - content = APP_JS.read_text(encoding="utf-8") - assert "btn-switch-to-register" in content, ( - "app.js не содержит ссылку на 'btn-switch-to-register'" - ) - - -def test_app_js_has_btn_switch_to_login_handler() -> None: - """app.js должен содержать обработчик для btn-switch-to-login (назад).""" - content = APP_JS.read_text(encoding="utf-8") - assert "btn-switch-to-login" in content, ( - "app.js не содержит ссылку на 'btn-switch-to-login'" - ) - - -# --------------------------------------------------------------------------- -# app.js static analysis — Criterion 7: обработка ошибок / показ сообщения пользователю -# --------------------------------------------------------------------------- - - -def test_app_js_has_set_reg_status_function() -> None: - """app.js должен содержать _setRegStatus для показа статуса в форме регистрации.""" - content = APP_JS.read_text(encoding="utf-8") - assert "_setRegStatus" in content, ( - "app.js не содержит функцию '_setRegStatus'" - ) - - -def test_app_js_handle_sign_up_shows_error_on_empty_fields() -> None: - """_handleSignUp должна вызывать _setRegStatus с ошибкой при пустых полях.""" - content = APP_JS.read_text(encoding="utf-8") - # Проверяем наличие валидации пустых полей внутри _handleSignUp-подобного блока - assert re.search( - r"_setRegStatus\s*\([^)]*error", - content, - ), ( - "app.js не содержит вызов _setRegStatus с классом 'error' " - "— ошибки не отображаются пользователю" - ) - - -def test_app_js_handle_sign_up_shows_success_on_ok() -> None: - """_handleSignUp должна вызывать _setRegStatus с success при успешной регистрации.""" - content = APP_JS.read_text(encoding="utf-8") - assert re.search( - r"_setRegStatus\s*\([^)]*success", - content, - ), ( - "app.js не содержит вызов _setRegStatus с классом 'success' " - "— пользователь не уведомляется об успехе регистрации" - ) - - -def test_app_js_clears_password_after_successful_signup() -> None: - """_handleSignUp должна очищать поле пароля после успешной отправки.""" - content = APP_JS.read_text(encoding="utf-8") - # Ищем сброс значения пароля - assert re.search( - r"passwordInput\.value\s*=\s*['\"][\s]*['\"]", - content, - ), ( - "app.js не очищает поле пароля после успешной регистрации — " - "пароль остаётся в DOM (security concern)" - ) - - -def test_app_js_uses_api_auth_register_endpoint() -> None: - """app.js должен отправлять форму на /api/auth/register.""" - content = APP_JS.read_text(encoding="utf-8") - assert "/api/auth/register" in content, ( - "app.js не содержит endpoint '/api/auth/register'" - ) - - -# --------------------------------------------------------------------------- -# Integration tests — API контракты (Criteria 8–12) -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_vapid_public_key_endpoint_returns_200_with_key(): - """GET /api/push/public-key → 200 с полем vapid_public_key.""" - async with make_app_client() as client: - resp = await client.get("/api/push/public-key") - - assert resp.status_code == 200, ( - f"GET /api/push/public-key вернул {resp.status_code}, ожидался 200" - ) - body = resp.json() - assert "vapid_public_key" in body, ( - f"Ответ /api/push/public-key не содержит 'vapid_public_key': {body}" - ) - assert isinstance(body["vapid_public_key"], str), ( - "vapid_public_key должен быть строкой" - ) - - -@pytest.mark.asyncio -async def test_register_valid_payload_returns_201_pending(): - """POST /api/auth/register с валидными данными → 201 status=pending.""" - async with make_app_client() as client: - with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock): - resp = await client.post("/api/auth/register", json=_VALID_PAYLOAD) - - assert resp.status_code == 201, ( - f"POST /api/auth/register вернул {resp.status_code}: {resp.text}" - ) - body = resp.json() - assert body.get("status") == "pending", ( - f"Ожидался status='pending', получено: {body}" - ) - assert "message" in body, ( - f"Ответ не содержит поле 'message': {body}" - ) - - -@pytest.mark.asyncio -async def test_register_duplicate_email_returns_409(): - """POST /api/auth/register с дублирующим email → 409.""" - async with make_app_client() as client: - with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock): - r1 = await client.post("/api/auth/register", json=_VALID_PAYLOAD) - assert r1.status_code == 201, f"Первая регистрация не прошла: {r1.text}" - - r2 = await client.post( - "/api/auth/register", - json={**_VALID_PAYLOAD, "login": "anotherlogin"}, - ) - - assert r2.status_code == 409, ( - f"Дублирующий email должен вернуть 409, получено {r2.status_code}" - ) - - -@pytest.mark.asyncio -async def test_register_duplicate_login_returns_409(): - """POST /api/auth/register с дублирующим login → 409.""" - async with make_app_client() as client: - with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock): - r1 = await client.post("/api/auth/register", json=_VALID_PAYLOAD) - assert r1.status_code == 201, f"Первая регистрация не прошла: {r1.text}" - - r2 = await client.post( - "/api/auth/register", - json={**_VALID_PAYLOAD, "email": "another@tutlot.com"}, - ) - - assert r2.status_code == 409, ( - f"Дублирующий login должен вернуть 409, получено {r2.status_code}" - ) - - -@pytest.mark.asyncio -async def test_register_invalid_email_returns_422(): - """POST /api/auth/register с невалидным email → 422.""" - async with make_app_client() as client: - resp = await client.post( - "/api/auth/register", - json={**_VALID_PAYLOAD, "email": "not-an-email"}, - ) - - assert resp.status_code == 422, ( - f"Невалидный email должен вернуть 422, получено {resp.status_code}" - ) diff --git a/tests/test_biz_001.py b/tests/test_biz_001.py deleted file mode 100644 index b48d176..0000000 --- a/tests/test_biz_001.py +++ /dev/null @@ -1,338 +0,0 @@ -""" -Tests for BATON-BIZ-001: Login mechanism for approved users (dual-layer: AST + httpx functional). - -Acceptance criteria: -1. Успешный login по login-полю → 200 + token -2. Успешный login по email-полю → 200 + token -3. Неверный пароль → 401 (без раскрытия причины) -4. Статус pending → 403 с читаемым сообщением -5. Статус rejected → 403 с читаемым сообщением -6. Rate limit — 6-й запрос подряд → 429 -7. Guard middleware возвращает 401 без токена -8. Guard middleware пропускает валидный токен - -Additional: error message uniformity, PBKDF2 verification. -""" -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") -os.environ.setdefault("ADMIN_CHAT_ID", "5694335584") - -import pytest -from fastapi import HTTPException -from fastapi.security import HTTPAuthorizationCredentials - -from backend import db -from backend.middleware import create_auth_token, verify_auth_token -from tests.conftest import make_app_client - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -async def _register_auth(client, email: str, login: str, password: str) -> int: - """Register via /api/auth/register, return registration id.""" - resp = await client.post( - "/api/auth/register", - json={"email": email, "login": login, "password": password}, - ) - assert resp.status_code == 201, f"auth/register failed: {resp.text}" - reg = await db.get_registration_by_login_or_email(login) - assert reg is not None - return reg["id"] - - -async def _approve(reg_id: int) -> None: - await db.update_registration_status(reg_id, "approved") - - -async def _reject(reg_id: int) -> None: - await db.update_registration_status(reg_id, "rejected") - - -# --------------------------------------------------------------------------- -# Criterion 1 — Успешный login по login-полю -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_login_by_login_field_returns_200_with_token(): - """Approved user can login using their login field → 200 + token.""" - async with make_app_client() as client: - reg_id = await _register_auth(client, "alice@tutlot.com", "alice", "password123") - await _approve(reg_id) - resp = await client.post( - "/api/auth/login", - json={"login_or_email": "alice", "password": "password123"}, - ) - assert resp.status_code == 200 - data = resp.json() - assert "token" in data - assert data["login"] == "alice" - - -@pytest.mark.asyncio -async def test_login_by_login_field_token_is_non_empty_string(): - """Token returned for approved login user is a non-empty string.""" - async with make_app_client() as client: - reg_id = await _register_auth(client, "alice2@tutlot.com", "alice2", "password123") - await _approve(reg_id) - resp = await client.post( - "/api/auth/login", - json={"login_or_email": "alice2", "password": "password123"}, - ) - assert isinstance(resp.json()["token"], str) - assert len(resp.json()["token"]) > 0 - - -# --------------------------------------------------------------------------- -# Criterion 2 — Успешный login по email-полю -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_login_by_email_field_returns_200_with_token(): - """Approved user can login using their email field → 200 + token.""" - async with make_app_client() as client: - reg_id = await _register_auth(client, "bob@tutlot.com", "bobuser", "securepass1") - await _approve(reg_id) - resp = await client.post( - "/api/auth/login", - json={"login_or_email": "bob@tutlot.com", "password": "securepass1"}, - ) - assert resp.status_code == 200 - data = resp.json() - assert "token" in data - assert data["login"] == "bobuser" - - -@pytest.mark.asyncio -async def test_login_by_email_field_token_login_matches_registration(): - """Token response login field matches the login set during registration.""" - async with make_app_client() as client: - reg_id = await _register_auth(client, "bob2@tutlot.com", "bob2user", "securepass1") - await _approve(reg_id) - resp = await client.post( - "/api/auth/login", - json={"login_or_email": "bob2@tutlot.com", "password": "securepass1"}, - ) - assert resp.json()["login"] == "bob2user" - - -# --------------------------------------------------------------------------- -# Criterion 3 — Неверный пароль → 401 без раскрытия причины -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_wrong_password_returns_401(): - """Wrong password returns 401 with generic message (no detail about which field failed).""" - async with make_app_client() as client: - reg_id = await _register_auth(client, "carol@tutlot.com", "carol", "correctpass1") - await _approve(reg_id) - resp = await client.post( - "/api/auth/login", - json={"login_or_email": "carol", "password": "wrongpassword"}, - ) - assert resp.status_code == 401 - assert "Неверный логин или пароль" in resp.json()["detail"] - - -@pytest.mark.asyncio -async def test_nonexistent_user_returns_401_same_message_as_wrong_password(): - """Non-existent login returns same 401 message as wrong password (prevents user enumeration).""" - async with make_app_client() as client: - resp = await client.post( - "/api/auth/login", - json={"login_or_email": "doesnotexist", "password": "anypassword"}, - ) - assert resp.status_code == 401 - assert "Неверный логин или пароль" in resp.json()["detail"] - - -# --------------------------------------------------------------------------- -# Criterion 4 — Статус pending → 403 с читаемым сообщением -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_pending_user_login_returns_403(): - """User with pending status gets 403.""" - async with make_app_client() as client: - await _register_auth(client, "dave@tutlot.com", "dave", "password123") - # Status is 'pending' by default — no approval step - resp = await client.post( - "/api/auth/login", - json={"login_or_email": "dave", "password": "password123"}, - ) - assert resp.status_code == 403 - - -@pytest.mark.asyncio -async def test_pending_user_login_403_message_is_human_readable(): - """403 message for pending user contains readable Russian text about the waiting status.""" - async with make_app_client() as client: - await _register_auth(client, "dave2@tutlot.com", "dave2", "password123") - resp = await client.post( - "/api/auth/login", - json={"login_or_email": "dave2", "password": "password123"}, - ) - assert "ожидает" in resp.json()["detail"] - - -# --------------------------------------------------------------------------- -# Criterion 5 — Статус rejected → 403 с читаемым сообщением -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_rejected_user_login_returns_403(): - """User with rejected status gets 403.""" - async with make_app_client() as client: - reg_id = await _register_auth(client, "eve@tutlot.com", "evegirl", "password123") - await _reject(reg_id) - resp = await client.post( - "/api/auth/login", - json={"login_or_email": "evegirl", "password": "password123"}, - ) - assert resp.status_code == 403 - - -@pytest.mark.asyncio -async def test_rejected_user_login_403_message_is_human_readable(): - """403 message for rejected user contains readable Russian text about rejection.""" - async with make_app_client() as client: - reg_id = await _register_auth(client, "eve2@tutlot.com", "eve2girl", "password123") - await _reject(reg_id) - resp = await client.post( - "/api/auth/login", - json={"login_or_email": "eve2girl", "password": "password123"}, - ) - assert "отклонена" in resp.json()["detail"] - - -# --------------------------------------------------------------------------- -# Criterion 6 — Rate limit: 6-й запрос подряд → 429 -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_rate_limit_triggers_on_sixth_login_attempt(): - """Login rate limit (5 per window) triggers 429 exactly on the 6th request.""" - async with make_app_client() as client: - statuses = [] - for _ in range(6): - resp = await client.post( - "/api/auth/login", - json={"login_or_email": "nouser_rl", "password": "nopass"}, - headers={"X-Real-IP": "10.99.99.1"}, - ) - statuses.append(resp.status_code) - # First 5 attempts pass rate limit (user not found → 401) - assert all(s == 401 for s in statuses[:5]), ( - f"Первые 5 попыток должны быть 401, получили: {statuses[:5]}" - ) - # 6th attempt hits rate limit - assert statuses[5] == 429, ( - f"6-я попытка должна быть 429, получили: {statuses[5]}" - ) - - -@pytest.mark.asyncio -async def test_rate_limit_fifth_attempt_still_passes(): - """5th login attempt is still allowed (rate limit triggers only on 6th).""" - async with make_app_client() as client: - for i in range(4): - await client.post( - "/api/auth/login", - json={"login_or_email": "nouser_rl2", "password": "nopass"}, - headers={"X-Real-IP": "10.99.99.2"}, - ) - resp = await client.post( - "/api/auth/login", - json={"login_or_email": "nouser_rl2", "password": "nopass"}, - headers={"X-Real-IP": "10.99.99.2"}, - ) - assert resp.status_code == 401, ( - f"5-я попытка должна пройти rate limit и вернуть 401, получили: {resp.status_code}" - ) - - -# --------------------------------------------------------------------------- -# Criterion 7 — Guard middleware: 401 без токена -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_verify_auth_token_raises_401_when_credentials_is_none(): - """verify_auth_token raises HTTPException 401 when no credentials provided.""" - with pytest.raises(HTTPException) as exc_info: - await verify_auth_token(credentials=None) - assert exc_info.value.status_code == 401 - - -@pytest.mark.asyncio -async def test_verify_auth_token_raises_401_for_malformed_token(): - """verify_auth_token raises HTTPException 401 for a malformed/invalid token.""" - creds = HTTPAuthorizationCredentials(scheme="Bearer", credentials="not.a.valid.jwt") - with pytest.raises(HTTPException) as exc_info: - await verify_auth_token(credentials=creds) - assert exc_info.value.status_code == 401 - - -# --------------------------------------------------------------------------- -# Criterion 8 — Guard middleware: валидный токен пропускается -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_verify_auth_token_returns_payload_for_valid_token(): - """verify_auth_token returns decoded JWT payload for a valid signed token.""" - token = create_auth_token(reg_id=42, login="testuser") - creds = HTTPAuthorizationCredentials(scheme="Bearer", credentials=token) - payload = await verify_auth_token(credentials=creds) - assert payload["sub"] == "42" - assert payload["login"] == "testuser" - - -@pytest.mark.asyncio -async def test_verify_auth_token_payload_contains_expected_fields(): - """Payload returned by verify_auth_token contains sub, login, iat, exp fields.""" - token = create_auth_token(reg_id=7, login="inspector") - creds = HTTPAuthorizationCredentials(scheme="Bearer", credentials=token) - payload = await verify_auth_token(credentials=creds) - for field in ("sub", "login", "iat", "exp"): - assert field in payload, f"Поле '{field}' отсутствует в payload" - - -# --------------------------------------------------------------------------- -# Additional: PBKDF2 correctness — verify_password timing-safe -# --------------------------------------------------------------------------- - - -def test_hash_and_verify_password_returns_true_for_correct_password(): - """_hash_password + _verify_password: correct password returns True.""" - from backend.main import _hash_password, _verify_password - stored = _hash_password("mysecretpass") - assert _verify_password("mysecretpass", stored) is True - - -def test_hash_and_verify_password_returns_false_for_wrong_password(): - """_hash_password + _verify_password: wrong password returns False.""" - from backend.main import _hash_password, _verify_password - stored = _hash_password("mysecretpass") - assert _verify_password("wrongpassword", stored) is False - - -def test_verify_password_returns_false_for_malformed_hash(): - """_verify_password returns False (not exception) for a malformed hash string.""" - from backend.main import _verify_password - assert _verify_password("anypassword", "not-a-valid-hash") is False diff --git a/tests/test_biz_002.py b/tests/test_biz_002.py deleted file mode 100644 index 0136df7..0000000 --- a/tests/test_biz_002.py +++ /dev/null @@ -1,203 +0,0 @@ -""" -Tests for BATON-BIZ-002: Убрать hardcoded VAPID key из meta-тега, читать с /api/push/public-key - -Acceptance criteria: -1. Meta-тег vapid-public-key полностью отсутствует в frontend/index.html (decision #1333). -2. app.js использует canonical URL /api/push/public-key для получения VAPID ключа. -3. Graceful fallback: endpoint недоступен → функция возвращает null, не бросает исключение. -4. Graceful fallback: ключ пустой → _initPushSubscription не выполняется (guard на null). -5. GET /api/push/public-key возвращает HTTP 200 с полем vapid_public_key. -6. GET /api/push/public-key возвращает правильное значение из конфига. -""" -from __future__ import annotations - -import os -import re -from pathlib import Path -from unittest.mock import patch - -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") -os.environ.setdefault("ADMIN_CHAT_ID", "5694335584") - -import pytest - -from tests.conftest import make_app_client - -PROJECT_ROOT = Path(__file__).parent.parent -INDEX_HTML = PROJECT_ROOT / "frontend" / "index.html" -APP_JS = PROJECT_ROOT / "frontend" / "app.js" - -_TEST_VAPID_KEY = "BFakeVapidPublicKeyForBiz002TestingBase64UrlEncoded" - - -# --------------------------------------------------------------------------- -# Criterion 1 — AST: meta-тег vapid-public-key полностью отсутствует -# --------------------------------------------------------------------------- - - -def test_index_html_has_no_meta_tag_named_vapid_public_key() -> None: - """index.html не должен содержать вообще (decision #1333).""" - content = INDEX_HTML.read_text(encoding="utf-8") - match = re.search( - r']+name\s*=\s*["\']vapid-public-key["\']', - content, - re.IGNORECASE, - ) - assert match is None, ( - f"index.html содержит удалённый тег : {match.group(0)!r}" - ) - - -def test_index_html_has_no_vapid_meta_tag_with_empty_or_any_content() -> None: - """index.html не должен содержать ни пустой, ни непустой VAPID ключ в meta content.""" - content = INDEX_HTML.read_text(encoding="utf-8") - match = re.search( - r']*(?:vapid|application-server-key)[^>]*content\s*=', - content, - re.IGNORECASE, - ) - assert match is None, ( - f"index.html содержит -тег с VAPID-связанным атрибутом content: {match.group(0)!r}" - ) - - -# --------------------------------------------------------------------------- -# Criterion 2 — AST: app.js использует canonical /api/push/public-key -# --------------------------------------------------------------------------- - - -def test_app_js_fetch_vapid_uses_canonical_push_public_key_url() -> None: - """_fetchVapidPublicKey в app.js должна использовать /api/push/public-key (canonical URL).""" - content = APP_JS.read_text(encoding="utf-8") - assert "/api/push/public-key" in content, ( - "app.js не содержит canonical URL '/api/push/public-key' — " - "ключ не читается через правильный endpoint" - ) - - -def test_app_js_fetch_vapid_returns_vapid_public_key_field() -> None: - """_fetchVapidPublicKey должна читать поле vapid_public_key из JSON-ответа.""" - content = APP_JS.read_text(encoding="utf-8") - assert re.search(r"data\.vapid_public_key", content), ( - "app.js не читает поле 'data.vapid_public_key' из ответа API" - ) - - -# --------------------------------------------------------------------------- -# Criterion 3 — AST: graceful fallback когда endpoint недоступен -# --------------------------------------------------------------------------- - - -def test_app_js_fetch_vapid_returns_null_on_http_error() -> None: - """_fetchVapidPublicKey должна возвращать null при res.ok === false (HTTP-ошибка).""" - content = APP_JS.read_text(encoding="utf-8") - assert re.search(r"if\s*\(\s*!\s*res\.ok\s*\)", content), ( - "app.js не содержит проверку 'if (!res.ok)' — " - "HTTP-ошибки не обрабатываются gracefully в _fetchVapidPublicKey" - ) - - -def test_app_js_fetch_vapid_catches_network_errors() -> None: - """_fetchVapidPublicKey должна оборачивать fetch в try/catch и возвращать null при сетевой ошибке.""" - content = APP_JS.read_text(encoding="utf-8") - # Проверяем паттерн try { fetch ... } catch (err) { return null; } внутри функции - func_match = re.search( - r"async function _fetchVapidPublicKey\(\).*?(?=^(?:async )?function |\Z)", - content, - re.DOTALL | re.MULTILINE, - ) - assert func_match, "Функция _fetchVapidPublicKey не найдена в app.js" - func_body = func_match.group(0) - assert "catch" in func_body, ( - "app.js: _fetchVapidPublicKey не содержит блок catch — " - "сетевые ошибки при fetch не обрабатываются" - ) - assert re.search(r"return\s+null", func_body), ( - "app.js: _fetchVapidPublicKey не возвращает null при ошибке — " - "upstream код получит исключение вместо null" - ) - - -# --------------------------------------------------------------------------- -# Criterion 4 — AST: graceful fallback когда ключ пустой (decision #1332) -# --------------------------------------------------------------------------- - - -def test_app_js_fetch_vapid_returns_null_on_empty_key() -> None: - """_fetchVapidPublicKey должна возвращать null когда vapid_public_key пустой.""" - content = APP_JS.read_text(encoding="utf-8") - assert re.search(r"data\.vapid_public_key\s*\|\|\s*null", content), ( - "app.js не содержит 'data.vapid_public_key || null' — " - "пустой ключ не преобразуется в null" - ) - - -def test_app_js_init_push_subscription_guard_skips_on_null_key() -> None: - """_initPushSubscription должна ранним возвратом пропускать подписку при null ключе (decision #1332).""" - content = APP_JS.read_text(encoding="utf-8") - assert re.search(r"if\s*\(\s*!\s*vapidPublicKey\s*\)", content), ( - "app.js: _initPushSubscription не содержит guard 'if (!vapidPublicKey)' — " - "подписка может быть создана без ключа" - ) - - -# --------------------------------------------------------------------------- -# Criterion 5 — HTTP: GET /api/push/public-key → 200 + vapid_public_key -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_push_public_key_endpoint_returns_200() -> None: - """GET /api/push/public-key должен вернуть HTTP 200.""" - async with make_app_client() as client: - response = await client.get("/api/push/public-key") - assert response.status_code == 200, ( - f"GET /api/push/public-key вернул {response.status_code}, ожидался 200" - ) - - -@pytest.mark.asyncio -async def test_push_public_key_endpoint_returns_json_with_vapid_field() -> None: - """GET /api/push/public-key должен вернуть JSON с полем vapid_public_key.""" - async with make_app_client() as client: - response = await client.get("/api/push/public-key") - data = response.json() - assert "vapid_public_key" in data, ( - f"Ответ /api/push/public-key не содержит поле 'vapid_public_key': {data!r}" - ) - - -# --------------------------------------------------------------------------- -# Criterion 6 — HTTP: возвращает правильное значение из конфига -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_push_public_key_endpoint_returns_configured_value() -> None: - """GET /api/push/public-key возвращает значение из VAPID_PUBLIC_KEY конфига.""" - with patch("backend.config.VAPID_PUBLIC_KEY", _TEST_VAPID_KEY): - async with make_app_client() as client: - response = await client.get("/api/push/public-key") - data = response.json() - assert data.get("vapid_public_key") == _TEST_VAPID_KEY, ( - f"vapid_public_key должен быть '{_TEST_VAPID_KEY}', " - f"получили: {data.get('vapid_public_key')!r}" - ) - - -@pytest.mark.asyncio -async def test_push_public_key_endpoint_returns_empty_string_when_not_configured() -> None: - """GET /api/push/public-key возвращает пустую строку (не ошибку) если ключ не настроен.""" - with patch("backend.config.VAPID_PUBLIC_KEY", ""): - async with make_app_client() as client: - response = await client.get("/api/push/public-key") - assert response.status_code == 200, ( - f"Endpoint вернул {response.status_code} при пустом ключе, ожидался 200" - ) - data = response.json() - assert "vapid_public_key" in data, "Поле vapid_public_key отсутствует при пустом конфиге" diff --git a/tests/test_biz_004.py b/tests/test_biz_004.py deleted file mode 100644 index 228f2ac..0000000 --- a/tests/test_biz_004.py +++ /dev/null @@ -1,96 +0,0 @@ -""" -BATON-BIZ-004: Verify removal of dead code from backend/telegram.py. - -Acceptance criteria: -1. telegram.py does NOT contain duplicate logging setLevel calls for httpx/httpcore. -2. telegram.py does NOT contain the SignalAggregator class. -3. httpx/httpcore logging suppression is still configured in main.py (globally). -4. SignalAggregator is NOT importable from backend.telegram. -""" -from __future__ import annotations - -import ast -import importlib -import inspect -import logging -import os -from pathlib import Path - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -_BACKEND_DIR = Path(__file__).parent.parent / "backend" -_TELEGRAM_SRC = (_BACKEND_DIR / "telegram.py").read_text(encoding="utf-8") -_MAIN_SRC = (_BACKEND_DIR / "main.py").read_text(encoding="utf-8") - - -# --------------------------------------------------------------------------- -# Criteria 1 — no setLevel for httpx/httpcore in telegram.py -# --------------------------------------------------------------------------- - -def test_telegram_has_no_httpx_setlevel(): - """telegram.py must not set log level for 'httpx'.""" - assert 'getLogger("httpx").setLevel' not in _TELEGRAM_SRC - assert "getLogger('httpx').setLevel" not in _TELEGRAM_SRC - - -def test_telegram_has_no_httpcore_setlevel(): - """telegram.py must not set log level for 'httpcore'.""" - assert 'getLogger("httpcore").setLevel' not in _TELEGRAM_SRC - assert "getLogger('httpcore').setLevel" not in _TELEGRAM_SRC - - -# --------------------------------------------------------------------------- -# Criteria 2 — SignalAggregator absent from telegram.py source -# --------------------------------------------------------------------------- - -def test_telegram_source_has_no_signal_aggregator_class(): - """telegram.py source text must not contain the class definition.""" - assert "class SignalAggregator" not in _TELEGRAM_SRC - - -def test_telegram_source_has_no_signal_aggregator_reference(): - """telegram.py source text must not reference SignalAggregator at all.""" - assert "SignalAggregator" not in _TELEGRAM_SRC - - -# --------------------------------------------------------------------------- -# Criteria 3 — httpx/httpcore suppression still lives in main.py -# --------------------------------------------------------------------------- - -def test_main_suppresses_httpx_logging(): - """main.py must call getLogger('httpx').setLevel to suppress noise.""" - assert ( - 'getLogger("httpx").setLevel' in _MAIN_SRC - or "getLogger('httpx').setLevel" in _MAIN_SRC - ) - - -def test_main_suppresses_httpcore_logging(): - """main.py must call getLogger('httpcore').setLevel to suppress noise.""" - assert ( - 'getLogger("httpcore").setLevel' in _MAIN_SRC - or "getLogger('httpcore').setLevel" in _MAIN_SRC - ) - - -# --------------------------------------------------------------------------- -# Criteria 4 — SignalAggregator not importable from backend.telegram -# --------------------------------------------------------------------------- - -def test_signal_aggregator_not_importable_from_telegram(): - """Importing SignalAggregator from backend.telegram must raise ImportError.""" - import importlib - import sys - - # Force a fresh import so changes to the module are reflected - mod_name = "backend.telegram" - if mod_name in sys.modules: - del sys.modules[mod_name] - - import backend.telegram as tg_mod # noqa: F401 - assert not hasattr(tg_mod, "SignalAggregator"), ( - "SignalAggregator should not be an attribute of backend.telegram" - ) diff --git a/tests/test_db.py b/tests/test_db.py index 93e87a1..e823fc4 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -29,14 +29,6 @@ import pytest from backend import config, db -# Valid UUID v4 constants — db-layer tests bypass Pydantic but use canonical UUIDs -_UUID_DB_1 = "d0000001-0000-4000-8000-000000000001" -_UUID_DB_2 = "d0000002-0000-4000-8000-000000000002" -_UUID_DB_3 = "d0000003-0000-4000-8000-000000000003" -_UUID_DB_4 = "d0000004-0000-4000-8000-000000000004" -_UUID_DB_5 = "d0000005-0000-4000-8000-000000000005" -_UUID_DB_6 = "d0000006-0000-4000-8000-000000000006" - def _tmpdb(): """Return a fresh temp-file path and set config.DB_PATH.""" @@ -136,10 +128,10 @@ async def test_register_user_returns_id(): path = _tmpdb() try: await db.init_db() - result = await db.register_user(uuid=_UUID_DB_1, name="Alice") + result = await db.register_user(uuid="uuid-001", name="Alice") assert isinstance(result["user_id"], int) assert result["user_id"] > 0 - assert result["uuid"] == _UUID_DB_1 + assert result["uuid"] == "uuid-001" finally: _cleanup(path) @@ -150,8 +142,8 @@ async def test_register_user_idempotent(): path = _tmpdb() try: await db.init_db() - r1 = await db.register_user(uuid=_UUID_DB_2, name="Bob") - r2 = await db.register_user(uuid=_UUID_DB_2, name="Bob") + r1 = await db.register_user(uuid="uuid-002", name="Bob") + r2 = await db.register_user(uuid="uuid-002", name="Bob") assert r1["user_id"] == r2["user_id"] finally: _cleanup(path) @@ -167,8 +159,8 @@ async def test_get_user_name_returns_name(): path = _tmpdb() try: await db.init_db() - await db.register_user(uuid=_UUID_DB_3, name="Charlie") - name = await db.get_user_name(_UUID_DB_3) + await db.register_user(uuid="uuid-003", name="Charlie") + name = await db.get_user_name("uuid-003") assert name == "Charlie" finally: _cleanup(path) @@ -196,9 +188,9 @@ async def test_save_signal_returns_id(): path = _tmpdb() try: await db.init_db() - await db.register_user(uuid=_UUID_DB_4, name="Dana") + await db.register_user(uuid="uuid-004", name="Dana") signal_id = await db.save_signal( - user_uuid=_UUID_DB_4, + user_uuid="uuid-004", timestamp=1742478000000, lat=55.7558, lon=37.6173, @@ -216,9 +208,9 @@ async def test_save_signal_without_geo(): path = _tmpdb() try: await db.init_db() - await db.register_user(uuid=_UUID_DB_5, name="Eve") + await db.register_user(uuid="uuid-005", name="Eve") signal_id = await db.save_signal( - user_uuid=_UUID_DB_5, + user_uuid="uuid-005", timestamp=1742478000000, lat=None, lon=None, @@ -247,9 +239,9 @@ async def test_save_signal_increments_id(): path = _tmpdb() try: await db.init_db() - await db.register_user(uuid=_UUID_DB_6, name="Frank") - id1 = await db.save_signal(_UUID_DB_6, 1742478000001, None, None, None) - id2 = await db.save_signal(_UUID_DB_6, 1742478000002, None, None, None) + await db.register_user(uuid="uuid-006", name="Frank") + id1 = await db.save_signal("uuid-006", 1742478000001, None, None, None) + id2 = await db.save_signal("uuid-006", 1742478000002, None, None, None) assert id2 > id1 finally: _cleanup(path) diff --git a/tests/test_fix_005.py b/tests/test_fix_005.py deleted file mode 100644 index 4a0c25f..0000000 --- a/tests/test_fix_005.py +++ /dev/null @@ -1,172 +0,0 @@ -""" -Tests for BATON-FIX-005: BOT_TOKEN leak prevention in logs. - -Acceptance criteria covered by unit tests: - AC#4 — no places in source code where token is logged in plain text: - - _mask_token() returns masked representation (***XXXX format) - - validate_bot_token() exception handler does not log raw BOT_TOKEN - - validate_bot_token() exception handler logs type(exc).__name__ + masked token - - httpcore logger level >= WARNING (prevents URL leak via transport layer) - -AC#1, AC#2, AC#3 (journalctl, webhook, service health) require live production -verification and are outside unit test scope. -""" -from __future__ import annotations - -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") - -import httpx -import pytest -import respx - -from backend import config -from backend.telegram import _mask_token, validate_bot_token - -GET_ME_URL = f"https://api.telegram.org/bot{config.BOT_TOKEN}/getMe" - - -# --------------------------------------------------------------------------- -# _mask_token helper -# --------------------------------------------------------------------------- - - -def test_mask_token_shows_last_4_chars(): - """_mask_token returns '***XXXX' where XXXX is the last 4 chars of the token.""" - token = "123456789:ABCDEFsomeLongTokenXYZW" - result = _mask_token(token) - assert result == f"***{token[-4:]}", f"Expected ***{token[-4:]}, got {result!r}" - - -def test_mask_token_hides_most_of_token(): - """_mask_token must NOT expose the full token — only last 4 chars.""" - token = "123456789:ABCDEFsomeLongTokenXYZW" - result = _mask_token(token) - assert token[:-4] not in result, f"Masked token exposes too much: {result!r}" - - -def test_mask_token_short_token_returns_redacted(): - """_mask_token returns '***REDACTED***' for tokens shorter than 4 chars.""" - assert _mask_token("abc") == "***REDACTED***" - - -def test_mask_token_empty_string_returns_redacted(): - """_mask_token on empty string returns '***REDACTED***'.""" - assert _mask_token("") == "***REDACTED***" - - -def test_mask_token_exactly_4_chars_is_not_redacted(): - """_mask_token with exactly 4 chars returns '***XXXX' (not redacted).""" - result = _mask_token("1234") - assert result == "***1234", f"Expected ***1234, got {result!r}" - - -# --------------------------------------------------------------------------- -# httpcore logger suppression (new in FIX-005; httpx covered in test_fix_011) -# --------------------------------------------------------------------------- - - -def test_httpcore_logger_level_is_warning_or_higher(): - """logging.getLogger('httpcore').level must be WARNING or higher after app import.""" - import backend.main # noqa: F401 — ensures telegram.py module-level setLevel is called - - httpcore_logger = logging.getLogger("httpcore") - assert httpcore_logger.level >= logging.WARNING, ( - f"httpcore logger level must be >= WARNING (30), got {httpcore_logger.level}. " - "httpcore logs transport-level requests including URLs with BOT_TOKEN." - ) - - -def test_httpcore_logger_info_not_enabled(): - """httpcore logger must not propagate INFO-level messages (would leak BOT_TOKEN URL).""" - import backend.main # noqa: F401 - - httpcore_logger = logging.getLogger("httpcore") - assert not httpcore_logger.isEnabledFor(logging.INFO), ( - "httpcore logger must not process INFO messages — could leak BOT_TOKEN via URL" - ) - - -# --------------------------------------------------------------------------- -# validate_bot_token() exception handler — AC#4: no raw token in error logs -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_validate_bot_token_network_error_does_not_log_raw_token(caplog): - """validate_bot_token() on ConnectError must NOT log the raw BOT_TOKEN. - - AC#4: The exception handler logs type(exc).__name__ + _mask_token() instead - of raw exc, which embeds the Telegram API URL containing the token. - """ - 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 - - raw_token = config.BOT_TOKEN - for record in caplog.records: - assert raw_token not in record.message, ( - f"AC#4: Raw BOT_TOKEN leaked in log message: {record.message!r}" - ) - - -@pytest.mark.asyncio -async def test_validate_bot_token_network_error_logs_exception_type_name(caplog): - """validate_bot_token() on ConnectError logs the exception type name, not repr(exc). - - The fixed handler: logger.error('...%s...', type(exc).__name__, ...) — not str(exc). - """ - 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"): - await validate_bot_token() - - error_messages = [r.message for r in caplog.records if r.levelno >= logging.ERROR] - assert error_messages, "Expected at least one ERROR log on network failure" - assert any("ConnectError" in msg for msg in error_messages), ( - f"Expected 'ConnectError' (type name) in error log, got: {error_messages}" - ) - - -@pytest.mark.asyncio -async def test_validate_bot_token_network_error_logs_masked_token(caplog): - """validate_bot_token() on network error logs masked token (***XXXX), not raw token.""" - 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"): - await validate_bot_token() - - token = config.BOT_TOKEN # "test-bot-token" - masked = f"***{token[-4:]}" # "***oken" - error_messages = [r.message for r in caplog.records if r.levelno >= logging.ERROR] - assert any(masked in msg for msg in error_messages), ( - f"Expected masked token '{masked}' in error log. Got: {error_messages}" - ) - - -@pytest.mark.asyncio -async def test_validate_bot_token_network_error_no_api_url_in_logs(caplog): - """validate_bot_token() on network error must not log the Telegram API URL. - - httpx embeds the request URL (including the token) into exception repr/str. - The fixed handler avoids logging exc directly to prevent this leak. - """ - 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"): - await validate_bot_token() - - for record in caplog.records: - assert "api.telegram.org" not in record.message, ( - f"AC#4: Telegram API URL (containing token) leaked in log: {record.message!r}" - ) diff --git a/tests/test_fix_007.py b/tests/test_fix_007.py deleted file mode 100644 index 3779fe0..0000000 --- a/tests/test_fix_007.py +++ /dev/null @@ -1,155 +0,0 @@ -""" -Tests for BATON-FIX-007: CORS OPTIONS preflight verification. - -Acceptance criteria: -1. OPTIONS preflight to /api/signal returns 200. -2. Preflight response includes Access-Control-Allow-Methods containing GET. -3. Preflight response includes Access-Control-Allow-Origin matching the configured origin. -4. Preflight response includes Access-Control-Allow-Headers with Authorization. -5. allow_methods in CORSMiddleware configuration explicitly contains GET. -""" -from __future__ import annotations - -import ast -from pathlib import Path - -import pytest - -from tests.conftest import make_app_client - -_FRONTEND_ORIGIN = "http://localhost:3000" -_BACKEND_DIR = Path(__file__).parent.parent / "backend" - -# --------------------------------------------------------------------------- -# Static check — CORSMiddleware config contains GET in allow_methods -# --------------------------------------------------------------------------- - - -def test_main_py_cors_allow_methods_contains_get() -> None: - """allow_methods в CORSMiddleware должен содержать 'GET'.""" - source = (_BACKEND_DIR / "main.py").read_text(encoding="utf-8") - tree = ast.parse(source, filename="main.py") - - for node in ast.walk(tree): - if isinstance(node, ast.Call): - func = node.func - if isinstance(func, ast.Name) and func.id == "add_middleware": - continue - if not ( - isinstance(func, ast.Attribute) and func.attr == "add_middleware" - ): - continue - for kw in node.keywords: - if kw.arg == "allow_methods": - if isinstance(kw.value, ast.List): - methods = [ - elt.value - for elt in kw.value.elts - if isinstance(elt, ast.Constant) and isinstance(elt.value, str) - ] - assert "GET" in methods, ( - f"allow_methods в CORSMiddleware не содержит 'GET': {methods}" - ) - return - - pytest.fail("add_middleware с CORSMiddleware и allow_methods не найден в main.py") - - -def test_main_py_cors_allow_methods_contains_post() -> None: - """allow_methods в CORSMiddleware должен содержать 'POST' (регрессия).""" - source = (_BACKEND_DIR / "main.py").read_text(encoding="utf-8") - assert '"POST"' in source or "'POST'" in source, ( - "allow_methods в CORSMiddleware не содержит 'POST'" - ) - - -# --------------------------------------------------------------------------- -# Functional — OPTIONS preflight request -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_options_preflight_signal_returns_200() -> None: - """OPTIONS preflight к /api/signal должен возвращать 200.""" - async with make_app_client() as client: - resp = await client.options( - "/api/signal", - headers={ - "Origin": _FRONTEND_ORIGIN, - "Access-Control-Request-Method": "POST", - "Access-Control-Request-Headers": "Content-Type, Authorization", - }, - ) - assert resp.status_code == 200, ( - f"Preflight OPTIONS /api/signal вернул {resp.status_code}, ожидался 200" - ) - - -@pytest.mark.asyncio -async def test_options_preflight_allow_origin_header() -> None: - """OPTIONS preflight должен вернуть Access-Control-Allow-Origin.""" - async with make_app_client() as client: - resp = await client.options( - "/api/signal", - headers={ - "Origin": _FRONTEND_ORIGIN, - "Access-Control-Request-Method": "POST", - "Access-Control-Request-Headers": "Content-Type, Authorization", - }, - ) - acao = resp.headers.get("access-control-allow-origin", "") - assert acao == _FRONTEND_ORIGIN, ( - f"Ожидался Access-Control-Allow-Origin: {_FRONTEND_ORIGIN!r}, получен: {acao!r}" - ) - - -@pytest.mark.asyncio -async def test_options_preflight_allow_methods_contains_get() -> None: - """OPTIONS preflight должен вернуть Access-Control-Allow-Methods, включающий GET.""" - async with make_app_client() as client: - resp = await client.options( - "/api/signal", - headers={ - "Origin": _FRONTEND_ORIGIN, - "Access-Control-Request-Method": "GET", - "Access-Control-Request-Headers": "Authorization", - }, - ) - acam = resp.headers.get("access-control-allow-methods", "") - assert "GET" in acam, ( - f"Access-Control-Allow-Methods не содержит GET: {acam!r}\n" - "Decision #1268: allow_methods=['POST'] — GET отсутствует" - ) - - -@pytest.mark.asyncio -async def test_options_preflight_allow_headers_contains_authorization() -> None: - """OPTIONS preflight должен вернуть Access-Control-Allow-Headers, включающий Authorization.""" - async with make_app_client() as client: - resp = await client.options( - "/api/signal", - headers={ - "Origin": _FRONTEND_ORIGIN, - "Access-Control-Request-Method": "POST", - "Access-Control-Request-Headers": "Authorization", - }, - ) - acah = resp.headers.get("access-control-allow-headers", "") - assert "authorization" in acah.lower(), ( - f"Access-Control-Allow-Headers не содержит Authorization: {acah!r}" - ) - - -@pytest.mark.asyncio -async def test_get_health_cors_header_present() -> None: - """GET /health с Origin должен вернуть Access-Control-Allow-Origin (simple request).""" - async with make_app_client() as client: - resp = await client.get( - "/health", - headers={"Origin": _FRONTEND_ORIGIN}, - ) - assert resp.status_code == 200 - acao = resp.headers.get("access-control-allow-origin", "") - assert acao == _FRONTEND_ORIGIN, ( - f"GET /health: ожидался CORS-заголовок {_FRONTEND_ORIGIN!r}, получен: {acao!r}" - ) diff --git a/tests/test_fix_009.py b/tests/test_fix_009.py deleted file mode 100644 index 399e4aa..0000000 --- a/tests/test_fix_009.py +++ /dev/null @@ -1,229 +0,0 @@ -""" -Tests for BATON-FIX-009: Live delivery verification — automated regression guards. - -Acceptance criteria mapped to unit tests: - AC#3 — BOT_TOKEN validates on startup via validate_bot_token() (getMe call) - AC#4 — CHAT_ID is negative (regression guard for decision #1212) - AC#1 — POST /api/signal returns 200 with valid auth - -Physical production checks (AC#2 Telegram group message, AC#5 systemd status) -are outside unit test scope and require live production verification. -""" -from __future__ import annotations - -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") - -import json -from unittest.mock import AsyncMock, patch - -import httpx -import pytest -import respx - -from tests.conftest import make_app_client, temp_db - - -# --------------------------------------------------------------------------- -# AC#3 — validate_bot_token called at startup (decision #1211) -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_validate_bot_token_called_once_during_startup(): - """AC#3: validate_bot_token() must be called exactly once during app startup. - - Maps to production check: curl getMe must be executed to detect invalid token - before the service starts accepting signals (decision #1211). - """ - from backend.main import app - - with temp_db(): - with patch("backend.telegram.validate_bot_token", new_callable=AsyncMock) as mock_validate: - mock_validate.return_value = True - with patch("backend.telegram.set_webhook", new_callable=AsyncMock): - async with app.router.lifespan_context(app): - pass - - assert mock_validate.call_count == 1, ( - f"Expected validate_bot_token to be called exactly once at startup, " - f"got {mock_validate.call_count}" - ) - - -@pytest.mark.asyncio -async def test_invalid_bot_token_logs_critical_error_on_startup(caplog): - """AC#3: When BOT_TOKEN is invalid (validate_bot_token returns False), - a CRITICAL/ERROR is logged but lifespan continues — service must not crash. - - Maps to: 'Check BOT_TOKEN valid via getMe — status OK/FAIL' (decision #1211). - """ - from backend.main import app - - with temp_db(): - with patch("backend.telegram.validate_bot_token", new_callable=AsyncMock) as mock_validate: - mock_validate.return_value = False - with patch("backend.telegram.set_webhook", new_callable=AsyncMock): - with caplog.at_level(logging.ERROR, logger="backend.main"): - async with app.router.lifespan_context(app): - pass # lifespan must complete without raising - - critical_msgs = [r.message for r in caplog.records if r.levelno >= logging.ERROR] - assert len(critical_msgs) >= 1, ( - "Expected at least one ERROR/CRITICAL log when BOT_TOKEN is invalid. " - "Operator must be alerted on startup if Telegram delivery is broken." - ) - assert any("BOT_TOKEN" in m for m in critical_msgs), ( - f"Expected log mentioning 'BOT_TOKEN', got: {critical_msgs}" - ) - - -@pytest.mark.asyncio -async def test_invalid_bot_token_lifespan_does_not_raise(): - """AC#3: Invalid BOT_TOKEN must not crash the service — lifespan completes normally.""" - from backend.main import app - - with temp_db(): - with patch("backend.telegram.validate_bot_token", new_callable=AsyncMock) as mock_validate: - mock_validate.return_value = False - with patch("backend.telegram.set_webhook", new_callable=AsyncMock): - # Must not raise — service stays alive even with broken Telegram token - async with app.router.lifespan_context(app): - pass - - -# --------------------------------------------------------------------------- -# AC#4 — CHAT_ID is negative (decision #1212 regression guard) -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_send_message_chat_id_in_request_is_negative(): - """AC#4: The chat_id sent to Telegram API must be negative (group ID). - - Root cause of BATON-007: CHAT_ID=5190015988 (positive) was set in .env - instead of -5190015988 (negative). Negative ID = Telegram group/supergroup. - Decision #1212: CHAT_ID=-5190015988 отрицательный. - """ - from backend import config - from backend.telegram import send_message - - send_url = f"https://api.telegram.org/bot{config.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("AC#4 regression guard") - - assert route.called - body = json.loads(route.calls[0].request.content) - chat_id = body["chat_id"] - assert str(chat_id).startswith("-"), ( - f"Regression #1212: chat_id must be negative (group ID), got {chat_id!r}. " - "Positive chat_id is a user ID — messages go to private DM, not the group." - ) - - -# --------------------------------------------------------------------------- -# AC#1 — POST /api/signal returns 200 (decision #1211) -# --------------------------------------------------------------------------- - - -_UUID_FIX009 = "f0090001-0000-4000-8000-000000000001" - - -@pytest.mark.asyncio -async def test_signal_endpoint_returns_200_with_valid_auth(): - """AC#1: POST /api/signal with valid Bearer token must return HTTP 200. - - Maps to production check: 'SSH на сервер, отправить POST /api/signal, - зафиксировать raw ответ API' (decision #1211). - """ - async with make_app_client() as client: - reg = await client.post( - "/api/register", - json={"uuid": _UUID_FIX009, "name": "Fix009User"}, - ) - assert reg.status_code == 200, f"Registration failed: {reg.text}" - api_key = reg.json()["api_key"] - - resp = await client.post( - "/api/signal", - json={"user_id": _UUID_FIX009, "timestamp": 1742478000000}, - headers={"Authorization": f"Bearer {api_key}"}, - ) - - assert resp.status_code == 200, ( - f"Expected /api/signal to return 200, got {resp.status_code}: {resp.text}" - ) - body = resp.json() - assert body.get("status") == "ok", f"Expected status='ok', got: {body}" - assert "signal_id" in body, f"Expected signal_id in response, got: {body}" - - -@pytest.mark.asyncio -async def test_signal_endpoint_returns_200_even_when_telegram_returns_400(caplog): - """AC#1 + decision #1230: POST /api/signal must return 200 even if Telegram returns 400. - - Decision #1230: 'Если Telegram возвращает 400 — зафиксировать и сообщить'. - The HTTP 400 from Telegram must be logged as ERROR (captured/reported), - but /api/signal must still return 200 — signal was saved to DB. - """ - from backend import config - from backend.main import app - - send_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage" - set_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/setWebhook" - get_me_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/getMe" - - _UUID_400 = "f0090002-0000-4000-8000-000000000002" - - with temp_db(): - with respx.mock(assert_all_called=False) as mock_tg: - mock_tg.get(get_me_url).mock( - return_value=httpx.Response(200, json={"ok": True, "result": {"username": "testbot"}}) - ) - mock_tg.post(set_url).mock( - return_value=httpx.Response(200, json={"ok": True, "result": True}) - ) - mock_tg.post(send_url).mock( - return_value=httpx.Response( - 400, - json={"ok": False, "error_code": 400, "description": "Bad Request: chat not found"}, - ) - ) - - async with app.router.lifespan_context(app): - import asyncio - from httpx import AsyncClient, ASGITransport - - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://testserver") as client: - reg = await client.post("/api/register", json={"uuid": _UUID_400, "name": "TgErrUser"}) - assert reg.status_code == 200 - api_key = reg.json()["api_key"] - - with caplog.at_level(logging.ERROR, logger="backend.telegram"): - resp = await client.post( - "/api/signal", - json={"user_id": _UUID_400, "timestamp": 1742478000000}, - headers={"Authorization": f"Bearer {api_key}"}, - ) - await asyncio.sleep(0) - - assert resp.status_code == 200, ( - f"Decision #1230: /api/signal must return 200 even on Telegram 400, " - f"got {resp.status_code}" - ) - assert any("400" in r.message for r in caplog.records), ( - "Decision #1230: Telegram 400 error must be logged (captured and reported). " - "Got logs: " + str([r.message for r in caplog.records]) - ) diff --git a/tests/test_fix_011.py b/tests/test_fix_011.py deleted file mode 100644 index f4a3019..0000000 --- a/tests/test_fix_011.py +++ /dev/null @@ -1,116 +0,0 @@ -""" -BATON-FIX-011: Проверяет, что BOT_TOKEN не попадает в httpx-логи. - -1. logging.getLogger('httpx').level >= logging.WARNING после импорта приложения. -2. Дочерние логгеры httpx._client и httpx._async_client также не пишут INFO. -3. При вызове send_message ни одна запись httpx-логгера с уровнем INFO - не содержит 'bot' или токен-подобный паттерн /bot[0-9]+:/. -""" -from __future__ import annotations - -import logging -import re - -import httpx -import pytest -import respx -from unittest.mock import patch, AsyncMock - -# conftest.py уже устанавливает BOT_TOKEN=test-bot-token до этого импорта -from backend import config -from backend.telegram import send_message - -SEND_URL = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage" -BOT_TOKEN_PATTERN = re.compile(r"bot[0-9]+:") - - -# --------------------------------------------------------------------------- -# Уровень логгера httpx -# --------------------------------------------------------------------------- - -def test_httpx_logger_level_is_warning_or_higher(): - """logging.getLogger('httpx').level должен быть WARNING (30) или выше после импорта приложения.""" - # Импортируем main, чтобы гарантировать, что setLevel уже вызван - import backend.main # noqa: F401 - - httpx_logger = logging.getLogger("httpx") - assert httpx_logger.level >= logging.WARNING, ( - f"Ожидался уровень >= WARNING (30), получен {httpx_logger.level}" - ) - - -def test_httpx_logger_info_not_enabled(): - """logging.getLogger('httpx').isEnabledFor(INFO) должен возвращать False.""" - import backend.main # noqa: F401 - - httpx_logger = logging.getLogger("httpx") - assert not httpx_logger.isEnabledFor(logging.INFO), ( - "httpx-логгер не должен обрабатывать INFO-сообщения" - ) - - -def test_httpx_client_logger_info_not_enabled(): - """Дочерний логгер httpx._client не должен обрабатывать INFO.""" - import backend.main # noqa: F401 - - child_logger = logging.getLogger("httpx._client") - assert not child_logger.isEnabledFor(logging.INFO), ( - "httpx._client не должен обрабатывать INFO-сообщения" - ) - - -def test_httpx_async_client_logger_info_not_enabled(): - """Дочерний логгер httpx._async_client не должен обрабатывать INFO.""" - import backend.main # noqa: F401 - - child_logger = logging.getLogger("httpx._async_client") - assert not child_logger.isEnabledFor(logging.INFO), ( - "httpx._async_client не должен обрабатывать INFO-сообщения" - ) - - -# --------------------------------------------------------------------------- -# BOT_TOKEN не появляется в httpx INFO-логах при send_message -# --------------------------------------------------------------------------- - -@pytest.mark.asyncio -async def test_send_message_no_httpx_records_at_warning_level(caplog): - """При вызове send_message httpx не выдаёт записей уровня WARNING и ниже с токеном. - - Проверяет фактическое состояние логгера в продакшне (WARNING): INFO-сообщения - с URL (включая BOT_TOKEN) не должны проходить через httpx-логгер. - """ - import backend.main # noqa: F401 — убеждаемся, что setLevel вызван - - with respx.mock(assert_all_called=False) as mock: - mock.post(SEND_URL).mock(return_value=httpx.Response(200, json={"ok": True})) - - # Захватываем логи при реальном уровне WARNING — INFO-сообщения не должны проходить - with caplog.at_level(logging.WARNING, logger="httpx"): - await send_message("test message for token leak check") - - bot_token = config.BOT_TOKEN - httpx_records = [r for r in caplog.records if r.name.startswith("httpx")] - for record in httpx_records: - assert bot_token not in record.message, ( - f"BOT_TOKEN найден в httpx-логе (уровень {record.levelname}): {record.message!r}" - ) - - -@pytest.mark.asyncio -async def test_send_message_no_token_pattern_in_httpx_info_logs(caplog): - """При вызове send_message httpx INFO-логи не содержат паттерн /bot[0-9]+:/.""" - with respx.mock(assert_all_called=False) as mock: - mock.post(SEND_URL).mock(return_value=httpx.Response(200, json={"ok": True})) - - with caplog.at_level(logging.INFO, logger="httpx"): - await send_message("check token pattern") - - info_records = [ - r for r in caplog.records - if r.name.startswith("httpx") and r.levelno <= logging.INFO - ] - for record in info_records: - assert not BOT_TOKEN_PATTERN.search(record.message), ( - f"Паттерн bot[0-9]+: найден в httpx INFO-логе: {record.message!r}" - ) diff --git a/tests/test_fix_012.py b/tests/test_fix_012.py deleted file mode 100644 index 324091a..0000000 --- a/tests/test_fix_012.py +++ /dev/null @@ -1,172 +0,0 @@ -""" -Tests for BATON-FIX-012: UUID v4 validation regression guard. - -BATON-SEC-005 added UUID v4 pattern validation to RegisterRequest.uuid and -SignalRequest.user_id. Tests in test_db.py / test_baton_005.py / test_telegram.py -previously used placeholder strings ('uuid-001', 'create-uuid-001', 'agg-uuid-001') -that are not valid UUID v4 — causing 25 regressions. - -This file locks down the behaviour so the same mistake cannot recur silently: - - Old-style placeholder strings are rejected by Pydantic - - All UUID constants used across the fixed test files are valid UUID v4 - - RegisterRequest and SignalRequest accept exactly-valid v4 UUIDs - - They reject strings that violate version (bit 3 of field-3 must be 4) or - variant (top bits of field-4 must be 10xx) requirements -""" -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 pydantic import ValidationError - -from backend.models import RegisterRequest, SignalRequest - -# --------------------------------------------------------------------------- -# UUID constants from fixed test files — all must be valid UUID v4 -# --------------------------------------------------------------------------- - -# test_db.py constants (_UUID_DB_1 .. _UUID_DB_6) -_DB_UUIDS = [ - "d0000001-0000-4000-8000-000000000001", - "d0000002-0000-4000-8000-000000000002", - "d0000003-0000-4000-8000-000000000003", - "d0000004-0000-4000-8000-000000000004", - "d0000005-0000-4000-8000-000000000005", - "d0000006-0000-4000-8000-000000000006", -] - -# test_baton_005.py constants (_UUID_ADM_*) -_ADM_UUIDS = [ - "e0000000-0000-4000-8000-000000000000", - "e0000001-0000-4000-8000-000000000001", - "e0000002-0000-4000-8000-000000000002", - "e0000003-0000-4000-8000-000000000003", - "e0000004-0000-4000-8000-000000000004", - "e0000005-0000-4000-8000-000000000005", - "e0000006-0000-4000-8000-000000000006", - "e0000007-0000-4000-8000-000000000007", - "e0000008-0000-4000-8000-000000000008", - "e0000009-0000-4000-8000-000000000009", - "e000000a-0000-4000-8000-000000000010", -] - -# test_telegram.py constants (aggregator UUIDs) -_AGG_UUIDS = [ - "a9900001-0000-4000-8000-000000000001", - "a9900099-0000-4000-8000-000000000099", -] + [f"a990000{i}-0000-4000-8000-00000000000{i}" for i in range(5)] - - -# --------------------------------------------------------------------------- -# Old-style placeholder UUIDs (pre-fix) must be rejected -# --------------------------------------------------------------------------- - - -@pytest.mark.parametrize("bad_uuid", [ - "uuid-001", - "uuid-002", - "uuid-003", - "uuid-004", - "uuid-005", - "uuid-006", - "create-uuid-001", - "create-uuid-002", - "create-uuid-003", - "pass-uuid-001", - "pass-uuid-002", - "block-uuid-001", - "unblock-uuid-001", - "delete-uuid-001", - "delete-uuid-002", - "regress-admin-uuid-001", - "unauth-uuid-001", - "agg-uuid-001", - "agg-uuid-clr", -]) -def test_register_request_rejects_old_placeholder_uuid(bad_uuid: str) -> None: - """RegisterRequest.uuid must reject all pre-BATON-SEC-005 placeholder strings.""" - with pytest.raises(ValidationError): - RegisterRequest(uuid=bad_uuid, name="Test") - - -@pytest.mark.parametrize("bad_uuid", [ - "uuid-001", - "agg-uuid-001", - "create-uuid-001", -]) -def test_signal_request_accepts_any_user_id_string(bad_uuid: str) -> None: - """SignalRequest.user_id is optional (no pattern) — validation is at endpoint level.""" - req = SignalRequest(user_id=bad_uuid, timestamp=1700000000000) - assert req.user_id == bad_uuid - - -# --------------------------------------------------------------------------- -# All UUID constants from the fixed test files are valid UUID v4 -# --------------------------------------------------------------------------- - - -@pytest.mark.parametrize("valid_uuid", _DB_UUIDS) -def test_register_request_accepts_db_uuid_constants(valid_uuid: str) -> None: - """RegisterRequest accepts all _UUID_DB_* constants from test_db.py.""" - req = RegisterRequest(uuid=valid_uuid, name="Test") - assert req.uuid == valid_uuid - - -@pytest.mark.parametrize("valid_uuid", _ADM_UUIDS) -def test_register_request_accepts_adm_uuid_constants(valid_uuid: str) -> None: - """RegisterRequest accepts all _UUID_ADM_* constants from test_baton_005.py.""" - req = RegisterRequest(uuid=valid_uuid, name="Test") - assert req.uuid == valid_uuid - - -@pytest.mark.parametrize("valid_uuid", _AGG_UUIDS) -def test_signal_request_accepts_agg_uuid_constants(valid_uuid: str) -> None: - """SignalRequest accepts all aggregator UUID constants from test_telegram.py.""" - req = SignalRequest(user_id=valid_uuid, timestamp=1700000000000) - assert req.user_id == valid_uuid - - -# --------------------------------------------------------------------------- -# UUID v4 structural requirements — version digit and variant bits -# --------------------------------------------------------------------------- - - -def test_register_request_rejects_uuid_v1_version_digit() -> None: - """UUID with version digit = 1 (not 4) must be rejected by RegisterRequest.""" - with pytest.raises(ValidationError): - # third group starts with '1' — version 1, not v4 - RegisterRequest(uuid="550e8400-e29b-11d4-a716-446655440000", name="Test") - - -def test_register_request_rejects_uuid_v3_version_digit() -> None: - """UUID with version digit = 3 must be rejected.""" - with pytest.raises(ValidationError): - RegisterRequest(uuid="550e8400-e29b-31d4-a716-446655440000", name="Test") - - -def test_signal_request_accepts_any_variant_bits() -> None: - """SignalRequest.user_id is now optional and unvalidated (JWT auth doesn't use it).""" - req = SignalRequest(user_id="550e8400-e29b-41d4-0716-446655440000", timestamp=1700000000000) - assert req.user_id is not None - - -def test_signal_request_without_user_id() -> None: - """SignalRequest works without user_id (JWT auth mode).""" - req = SignalRequest(timestamp=1700000000000) - assert req.user_id is None - - -def test_register_request_accepts_all_valid_v4_variants() -> None: - """RegisterRequest accepts UUIDs with variant nibbles 8, 9, a, b.""" - for variant in ("8", "9", "a", "b"): - uuid = f"550e8400-e29b-41d4-{variant}716-446655440000" - req = RegisterRequest(uuid=uuid, name="Test") - assert req.uuid == uuid diff --git a/tests/test_fix_013.py b/tests/test_fix_013.py deleted file mode 100644 index 5445895..0000000 --- a/tests/test_fix_013.py +++ /dev/null @@ -1,194 +0,0 @@ -""" -Tests for BATON-FIX-013: CORS allow_methods — добавить GET для /health эндпоинтов. - -Acceptance criteria: -1. CORSMiddleware в main.py содержит "GET" в allow_methods. -2. OPTIONS preflight /health с Origin и Access-Control-Request-Method: GET - возвращает 200/204 и содержит GET в Access-Control-Allow-Methods. -3. OPTIONS preflight /api/health — аналогично. -4. GET /health возвращает 200 (regression guard vs. allow_methods=['POST'] only). -""" -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") -os.environ.setdefault("ADMIN_CHAT_ID", "5694335584") - -import pytest - -from tests.conftest import make_app_client - -_ORIGIN = "http://localhost:3000" -# allow_headers = ["Content-Type", "Authorization"] — X-Custom-Header не разрешён, -# поэтому preflight с X-Custom-Header вернёт 400. Используем Content-Type. -_PREFLIGHT_HEADER = "Content-Type" - - -# --------------------------------------------------------------------------- -# Criterion 1 — Static: CORSMiddleware.allow_methods must contain "GET" -# --------------------------------------------------------------------------- - - -def test_cors_middleware_allow_methods_contains_get() -> None: - """app.user_middleware CORSMiddleware должен содержать 'GET' в allow_methods.""" - from fastapi.middleware.cors import CORSMiddleware - - from backend.main import app - - cors_mw = next( - (m for m in app.user_middleware if m.cls is CORSMiddleware), None - ) - assert cors_mw is not None, "CORSMiddleware не найден в app.user_middleware" - allow_methods = cors_mw.kwargs.get("allow_methods", []) - assert "GET" in allow_methods, ( - f"CORSMiddleware.allow_methods={allow_methods!r} не содержит 'GET'" - ) - - -def test_cors_middleware_allow_methods_contains_head() -> None: - """allow_methods должен содержать 'HEAD' для корректной работы preflight.""" - from fastapi.middleware.cors import CORSMiddleware - - from backend.main import app - - cors_mw = next( - (m for m in app.user_middleware if m.cls is CORSMiddleware), None - ) - assert cors_mw is not None - allow_methods = cors_mw.kwargs.get("allow_methods", []) - assert "HEAD" in allow_methods, ( - f"CORSMiddleware.allow_methods={allow_methods!r} не содержит 'HEAD'" - ) - - -def test_cors_middleware_allow_methods_contains_options() -> None: - """allow_methods должен содержать 'OPTIONS' для корректной обработки preflight.""" - from fastapi.middleware.cors import CORSMiddleware - - from backend.main import app - - cors_mw = next( - (m for m in app.user_middleware if m.cls is CORSMiddleware), None - ) - assert cors_mw is not None - allow_methods = cors_mw.kwargs.get("allow_methods", []) - assert "OPTIONS" in allow_methods, ( - f"CORSMiddleware.allow_methods={allow_methods!r} не содержит 'OPTIONS'" - ) - - -# --------------------------------------------------------------------------- -# Criterion 2 — Preflight OPTIONS /health includes GET in Allow-Methods -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_health_preflight_options_returns_success_status() -> None: - """OPTIONS preflight /health должен вернуть 200 или 204.""" - async with make_app_client() as client: - response = await client.options( - "/health", - headers={ - "Origin": _ORIGIN, - "Access-Control-Request-Method": "GET", - "Access-Control-Request-Headers": _PREFLIGHT_HEADER, - }, - ) - assert response.status_code in (200, 204), ( - f"OPTIONS /health вернул {response.status_code}, ожидался 200 или 204" - ) - - -@pytest.mark.asyncio -async def test_health_preflight_options_allow_methods_contains_get() -> None: - """OPTIONS preflight /health: Access-Control-Allow-Methods должен содержать GET.""" - async with make_app_client() as client: - response = await client.options( - "/health", - headers={ - "Origin": _ORIGIN, - "Access-Control-Request-Method": "GET", - "Access-Control-Request-Headers": _PREFLIGHT_HEADER, - }, - ) - allow_methods_header = response.headers.get("access-control-allow-methods", "") - assert "GET" in allow_methods_header, ( - f"Access-Control-Allow-Methods={allow_methods_header!r} не содержит 'GET'" - ) - - -# --------------------------------------------------------------------------- -# Criterion 3 — Preflight OPTIONS /api/health includes GET in Allow-Methods -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_api_health_preflight_options_returns_success_status() -> None: - """OPTIONS preflight /api/health должен вернуть 200 или 204.""" - async with make_app_client() as client: - response = await client.options( - "/api/health", - headers={ - "Origin": _ORIGIN, - "Access-Control-Request-Method": "GET", - "Access-Control-Request-Headers": _PREFLIGHT_HEADER, - }, - ) - assert response.status_code in (200, 204), ( - f"OPTIONS /api/health вернул {response.status_code}, ожидался 200 или 204" - ) - - -@pytest.mark.asyncio -async def test_api_health_preflight_options_allow_methods_contains_get() -> None: - """OPTIONS preflight /api/health: Access-Control-Allow-Methods должен содержать GET.""" - async with make_app_client() as client: - response = await client.options( - "/api/health", - headers={ - "Origin": _ORIGIN, - "Access-Control-Request-Method": "GET", - "Access-Control-Request-Headers": _PREFLIGHT_HEADER, - }, - ) - allow_methods_header = response.headers.get("access-control-allow-methods", "") - assert "GET" in allow_methods_header, ( - f"Access-Control-Allow-Methods={allow_methods_header!r} не содержит 'GET'" - ) - - -# --------------------------------------------------------------------------- -# Criterion 4 — GET /health returns 200 (regression guard) -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_health_get_returns_200_regression_guard() -> None: - """GET /health должен вернуть 200 — regression guard против allow_methods=['POST'] only.""" - async with make_app_client() as client: - response = await client.get( - "/health", - headers={"Origin": _ORIGIN}, - ) - assert response.status_code == 200, ( - f"GET /health вернул {response.status_code}, ожидался 200" - ) - - -@pytest.mark.asyncio -async def test_api_health_get_returns_200_regression_guard() -> None: - """GET /api/health должен вернуть 200 — regression guard против allow_methods=['POST'] only.""" - async with make_app_client() as client: - response = await client.get( - "/api/health", - headers={"Origin": _ORIGIN}, - ) - assert response.status_code == 200, ( - f"GET /api/health вернул {response.status_code}, ожидался 200" - ) diff --git a/tests/test_fix_016.py b/tests/test_fix_016.py deleted file mode 100644 index e4748ba..0000000 --- a/tests/test_fix_016.py +++ /dev/null @@ -1,163 +0,0 @@ -""" -Tests for BATON-FIX-016: VAPID public key — убедиться, что ключ не вшит -как пустая строка в frontend-коде и читается через API. - -Acceptance criteria: -1. В frontend-коде нет хардкода пустой строки в качестве VAPID key в -теге. -2. frontend читает ключ через API /api/vapid-public-key (_fetchVapidPublicKey). -3. GET /api/vapid-public-key возвращает HTTP 200. -4. GET /api/vapid-public-key возвращает JSON с полем vapid_public_key. -5. При наличии конфигурации VAPID_PUBLIC_KEY — ответ содержит непустое значение. -""" -from __future__ import annotations - -import os -import re -from pathlib import Path -from unittest.mock import patch - -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") -os.environ.setdefault("ADMIN_CHAT_ID", "5694335584") - -import pytest - -from tests.conftest import make_app_client - -PROJECT_ROOT = Path(__file__).parent.parent -FRONTEND_DIR = PROJECT_ROOT / "frontend" -INDEX_HTML = FRONTEND_DIR / "index.html" -APP_JS = FRONTEND_DIR / "app.js" - -_TEST_VAPID_PUBLIC_KEY = "BFakeVapidPublicKeyForTestingPurposesOnlyBase64UrlEncoded" - - -# --------------------------------------------------------------------------- -# Criterion 1 — AST: no hardcoded empty VAPID key in tag (index.html) -# --------------------------------------------------------------------------- - - -def test_index_html_has_no_vapid_meta_tag_with_empty_content() -> None: - """index.html не должен содержать -тег с application-server-key и пустым content.""" - content = INDEX_HTML.read_text(encoding="utf-8") - match = re.search( - r']*(?:application-server-key|vapid)[^>]*content\s*=\s*["\']["\']', - content, - re.IGNORECASE, - ) - assert match is None, ( - f"index.html содержит -тег с пустым VAPID ключом: {match.group(0)!r}" - ) - - -def test_index_html_has_no_hardcoded_application_server_key_attribute() -> None: - """index.html не должен содержать атрибут application-server-key вообще.""" - content = INDEX_HTML.read_text(encoding="utf-8") - assert "application-server-key" not in content.lower(), ( - "index.html содержит атрибут 'application-server-key' — " - "VAPID ключ не должен быть вшит в HTML" - ) - - -# --------------------------------------------------------------------------- -# Criterion 2 — AST: frontend reads key through API (app.js) -# --------------------------------------------------------------------------- - - -def test_app_js_contains_fetch_vapid_public_key_function() -> None: - """app.js должен содержать функцию _fetchVapidPublicKey.""" - content = APP_JS.read_text(encoding="utf-8") - assert "_fetchVapidPublicKey" in content, ( - "app.js не содержит функцию _fetchVapidPublicKey — " - "чтение VAPID ключа через API не реализовано" - ) - - -def test_app_js_fetch_vapid_calls_api_endpoint() -> None: - """_fetchVapidPublicKey в app.js должна обращаться к /api/push/public-key (canonical URL).""" - content = APP_JS.read_text(encoding="utf-8") - assert "/api/push/public-key" in content, ( - "app.js не содержит URL '/api/push/public-key' — VAPID ключ не читается через API" - ) - - -def test_app_js_init_push_subscription_has_null_guard() -> None: - """_initPushSubscription в app.js должна содержать guard против null ключа.""" - content = APP_JS.read_text(encoding="utf-8") - assert re.search(r"if\s*\(\s*!\s*vapidPublicKey\s*\)", content), ( - "app.js: _initPushSubscription не содержит guard 'if (!vapidPublicKey)' — " - "подписка может быть создана без ключа" - ) - - -def test_app_js_init_chains_fetch_vapid_then_init_subscription() -> None: - """_init() в app.js должна вызывать _fetchVapidPublicKey().then(_initPushSubscription).""" - content = APP_JS.read_text(encoding="utf-8") - assert re.search( - r"_fetchVapidPublicKey\(\)\s*\.\s*then\s*\(\s*_initPushSubscription\s*\)", - content, - ), ( - "app.js: _init() не содержит цепочку _fetchVapidPublicKey().then(_initPushSubscription)" - ) - - -def test_app_js_has_no_empty_string_hardcoded_as_application_server_key() -> None: - """app.js не должен содержать хардкода пустой строки для applicationServerKey.""" - content = APP_JS.read_text(encoding="utf-8") - match = re.search(r"applicationServerKey\s*[=:]\s*[\"']{2}", content) - assert match is None, ( - f"app.js содержит хардкод пустой строки для applicationServerKey: {match.group(0)!r}" - ) - - -# --------------------------------------------------------------------------- -# Criterion 3 — HTTP: GET /api/vapid-public-key returns 200 -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_vapid_public_key_endpoint_returns_200() -> None: - """GET /api/vapid-public-key должен вернуть HTTP 200.""" - async with make_app_client() as client: - response = await client.get("/api/vapid-public-key") - assert response.status_code == 200, ( - f"GET /api/vapid-public-key вернул {response.status_code}, ожидался 200" - ) - - -# --------------------------------------------------------------------------- -# Criterion 4 — HTTP: response JSON contains vapid_public_key field -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_vapid_public_key_endpoint_returns_json_with_field() -> None: - """GET /api/vapid-public-key должен вернуть JSON с полем vapid_public_key.""" - async with make_app_client() as client: - response = await client.get("/api/vapid-public-key") - data = response.json() - assert "vapid_public_key" in data, ( - f"Ответ /api/vapid-public-key не содержит поле 'vapid_public_key': {data!r}" - ) - - -# --------------------------------------------------------------------------- -# Criterion 5 — HTTP: non-empty vapid_public_key when env var is configured -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_vapid_public_key_endpoint_returns_configured_value() -> None: - """GET /api/vapid-public-key возвращает непустой ключ, когда VAPID_PUBLIC_KEY задан.""" - with patch("backend.config.VAPID_PUBLIC_KEY", _TEST_VAPID_PUBLIC_KEY): - async with make_app_client() as client: - response = await client.get("/api/vapid-public-key") - data = response.json() - assert data.get("vapid_public_key") == _TEST_VAPID_PUBLIC_KEY, ( - f"vapid_public_key должен быть '{_TEST_VAPID_PUBLIC_KEY}', " - f"получили: {data.get('vapid_public_key')!r}" - ) diff --git a/tests/test_models.py b/tests/test_models.py index cf9641a..2b902c7 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -46,11 +46,11 @@ def test_register_request_empty_uuid(): def test_register_request_name_max_length(): """name longer than 100 chars raises ValidationError.""" with pytest.raises(ValidationError): - RegisterRequest(uuid="550e8400-e29b-41d4-a716-446655440000", name="x" * 101) + RegisterRequest(uuid="some-uuid", name="x" * 101) def test_register_request_name_exactly_100(): - req = RegisterRequest(uuid="550e8400-e29b-41d4-a716-446655440000", name="x" * 100) + req = RegisterRequest(uuid="some-uuid", name="x" * 100) assert len(req.name) == 100 @@ -116,31 +116,29 @@ def test_signal_request_valid(): def test_signal_request_no_geo(): req = SignalRequest( - user_id="550e8400-e29b-41d4-a716-446655440000", + user_id="some-uuid", timestamp=1742478000000, geo=None, ) assert req.geo is None -def test_signal_request_without_user_id(): - """user_id is optional (JWT auth sends signals without it).""" - req = SignalRequest(timestamp=1742478000000) - assert req.user_id is None +def test_signal_request_missing_user_id(): + with pytest.raises(ValidationError): + SignalRequest(timestamp=1742478000000) # type: ignore[call-arg] def test_signal_request_empty_user_id(): - """Empty string user_id is accepted (treated as None at endpoint level).""" - req = SignalRequest(user_id="", timestamp=1742478000000) - assert req.user_id == "" + with pytest.raises(ValidationError): + SignalRequest(user_id="", timestamp=1742478000000) def test_signal_request_timestamp_zero(): """timestamp must be > 0.""" with pytest.raises(ValidationError): - SignalRequest(user_id="550e8400-e29b-41d4-a716-446655440000", timestamp=0) + SignalRequest(user_id="some-uuid", timestamp=0) def test_signal_request_timestamp_negative(): with pytest.raises(ValidationError): - SignalRequest(user_id="550e8400-e29b-41d4-a716-446655440000", timestamp=-1) + SignalRequest(user_id="some-uuid", timestamp=-1) diff --git a/tests/test_register.py b/tests/test_register.py index 0f69d24..fb05341 100644 --- a/tests/test_register.py +++ b/tests/test_register.py @@ -1,11 +1,5 @@ """ Integration tests for POST /api/register. - -UUID notes: RegisterRequest.uuid requires a valid UUID v4 pattern -(^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$). -All UUID constants below satisfy this constraint. - -BATON-SEC-003: /api/register now returns api_key in the response. """ from __future__ import annotations @@ -16,34 +10,23 @@ os.environ.setdefault("CHAT_ID", "-1001234567890") os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret") os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram") os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000") -os.environ.setdefault("ADMIN_TOKEN", "test-admin-token") import pytest from tests.conftest import make_app_client -# Valid UUID v4 constants for register tests -_UUID_REG_1 = "b0000001-0000-4000-8000-000000000001" -_UUID_REG_2 = "b0000002-0000-4000-8000-000000000002" -_UUID_REG_3 = "b0000003-0000-4000-8000-000000000003" -_UUID_REG_4 = "b0000004-0000-4000-8000-000000000004" -_UUID_REG_5 = "b0000005-0000-4000-8000-000000000005" -_UUID_REG_6 = "b0000006-0000-4000-8000-000000000006" - @pytest.mark.asyncio async def test_register_new_user_success(): - """POST /api/register returns 200 with user_id > 0 and api_key.""" + """POST /api/register returns 200 with user_id > 0.""" async with make_app_client() as client: resp = await client.post( "/api/register", - json={"uuid": _UUID_REG_1, "name": "Alice"}, + json={"uuid": "reg-uuid-001", "name": "Alice"}, ) assert resp.status_code == 200 data = resp.json() assert data["user_id"] > 0 - assert data["uuid"] == _UUID_REG_1 - assert "api_key" in data - assert len(data["api_key"]) == 64 # secrets.token_hex(32) = 64 hex chars + assert data["uuid"] == "reg-uuid-001" @pytest.mark.asyncio @@ -52,42 +35,24 @@ async def test_register_idempotent(): async with make_app_client() as client: r1 = await client.post( "/api/register", - json={"uuid": _UUID_REG_2, "name": "Bob"}, + json={"uuid": "reg-uuid-002", "name": "Bob"}, ) r2 = await client.post( "/api/register", - json={"uuid": _UUID_REG_2, "name": "Bob"}, + json={"uuid": "reg-uuid-002", "name": "Bob"}, ) assert r1.status_code == 200 assert r2.status_code == 200 assert r1.json()["user_id"] == r2.json()["user_id"] -@pytest.mark.asyncio -async def test_register_idempotent_returns_api_key_on_every_call(): - """Each registration call returns an api_key (key rotation on re-register).""" - async with make_app_client() as client: - r1 = await client.post( - "/api/register", - json={"uuid": _UUID_REG_3, "name": "Carol"}, - ) - r2 = await client.post( - "/api/register", - json={"uuid": _UUID_REG_3, "name": "Carol"}, - ) - assert r1.status_code == 200 - assert r2.status_code == 200 - assert "api_key" in r1.json() - assert "api_key" in r2.json() - - @pytest.mark.asyncio async def test_register_empty_name_returns_422(): """Empty name must fail validation with 422.""" async with make_app_client() as client: resp = await client.post( "/api/register", - json={"uuid": _UUID_REG_4, "name": ""}, + json={"uuid": "reg-uuid-003", "name": ""}, ) assert resp.status_code == 422 @@ -109,18 +74,7 @@ async def test_register_missing_name_returns_422(): async with make_app_client() as client: resp = await client.post( "/api/register", - json={"uuid": _UUID_REG_4}, - ) - assert resp.status_code == 422 - - -@pytest.mark.asyncio -async def test_register_invalid_uuid_format_returns_422(): - """Non-UUID4 string as uuid must return 422.""" - async with make_app_client() as client: - resp = await client.post( - "/api/register", - json={"uuid": "not-a-uuid", "name": "Dave"}, + json={"uuid": "reg-uuid-004"}, ) assert resp.status_code == 422 @@ -131,11 +85,11 @@ async def test_register_user_stored_in_db(): async with make_app_client() as client: r1 = await client.post( "/api/register", - json={"uuid": _UUID_REG_5, "name": "Dana"}, + json={"uuid": "reg-uuid-005", "name": "Dana"}, ) r2 = await client.post( "/api/register", - json={"uuid": _UUID_REG_5, "name": "Dana"}, + json={"uuid": "reg-uuid-005", "name": "Dana"}, ) assert r1.json()["user_id"] == r2.json()["user_id"] @@ -146,6 +100,6 @@ async def test_register_response_contains_uuid(): async with make_app_client() as client: resp = await client.post( "/api/register", - json={"uuid": _UUID_REG_6, "name": "Eve"}, + json={"uuid": "reg-uuid-006", "name": "Eve"}, ) - assert resp.json()["uuid"] == _UUID_REG_6 + assert resp.json()["uuid"] == "reg-uuid-006" diff --git a/tests/test_sec_002.py b/tests/test_sec_002.py deleted file mode 100644 index ccf863f..0000000 --- a/tests/test_sec_002.py +++ /dev/null @@ -1,282 +0,0 @@ -""" -Tests for BATON-SEC-002: -1. _get_client_ip() extracts real IP from X-Real-IP / X-Forwarded-For headers. -2. POST /api/signal returns 429 when the per-IP rate limit is exceeded. -3. Rate counters for register and signal are independent (separate key namespaces). - -UUID notes: RegisterRequest.uuid and SignalRequest.user_id both require a valid -UUID v4 pattern (^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$). -All constants below satisfy this constraint. - -BATON-SEC-003: POST /api/signal now requires Authorization: Bearer . -_register_and_get_key() helper returns the api_key from the registration response. -""" -from __future__ import annotations - -import os - -os.environ.setdefault("BOT_TOKEN", "test-bot-token") -os.environ.setdefault("CHAT_ID", "-1001234567890") -os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret") -os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram") -os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000") -os.environ.setdefault("ADMIN_TOKEN", "test-admin-token") - -import pytest -from starlette.requests import Request - -from backend.middleware import _get_client_ip -from tests.conftest import make_app_client - -# ── Valid UUID v4 constants ────────────────────────────────────────────────── -# Pattern: xxxxxxxx-xxxx-4xxx-[89ab]xxx-xxxxxxxxxxxx (all hex chars) - -_UUID_SIG_RL = "a0000001-0000-4000-8000-000000000001" # rate-limit 429 test -_UUID_SIG_OK = "a0000002-0000-4000-8000-000000000002" # first-10-allowed test -_UUID_IND_SIG = "a0000003-0000-4000-8000-000000000003" # independence (exhaust signal) -_UUID_IND_SIG2 = "a0000033-0000-4000-8000-000000000033" # second register after exhaust -_UUID_IND_REG = "a0000004-0000-4000-8000-000000000004" # independence (exhaust register) -_UUID_IP_A = "a0000005-0000-4000-8000-000000000005" # per-IP isolation, user A -_UUID_IP_B = "a0000006-0000-4000-8000-000000000006" # per-IP isolation, user B - - -# ── Helpers ───────────────────────────────────────────────────────────────── - - -async def _register_and_get_key(client, uuid: str, name: str) -> str: - """Register user and return api_key.""" - r = await client.post("/api/register", json={"uuid": uuid, "name": name}) - assert r.status_code == 200 - return r.json()["api_key"] - - -def _make_request(headers: dict | None = None, client_host: str = "127.0.0.1") -> Request: - """Build a minimal Starlette Request with given headers and remote address.""" - scope = { - "type": "http", - "method": "POST", - "path": "/", - "headers": [ - (k.lower().encode(), v.encode()) - for k, v in (headers or {}).items() - ], - "client": (client_host, 12345), - } - return Request(scope) - - -# ── Unit: _get_client_ip ──────────────────────────────────────────────────── - - -def test_get_client_ip_returns_x_real_ip_when_present(): - """X-Real-IP header is returned as-is (highest priority).""" - req = _make_request({"X-Real-IP": "203.0.113.10"}, client_host="127.0.0.1") - assert _get_client_ip(req) == "203.0.113.10" - - -def test_get_client_ip_ignores_client_host_when_x_real_ip_set(): - """When X-Real-IP is present, client.host (127.0.0.1) must NOT be returned.""" - req = _make_request({"X-Real-IP": "10.20.30.40"}, client_host="127.0.0.1") - assert _get_client_ip(req) != "127.0.0.1" - - -def test_get_client_ip_uses_x_forwarded_for_when_no_x_real_ip(): - """X-Forwarded-For is used when X-Real-IP is absent.""" - req = _make_request({"X-Forwarded-For": "198.51.100.5"}, client_host="127.0.0.1") - assert _get_client_ip(req) == "198.51.100.5" - - -def test_get_client_ip_x_forwarded_for_returns_first_ip_in_chain(): - """When X-Forwarded-For contains a chain, only the first (original) IP is returned.""" - req = _make_request( - {"X-Forwarded-For": "192.0.2.1, 10.0.0.1, 172.16.0.1"}, - client_host="127.0.0.1", - ) - assert _get_client_ip(req) == "192.0.2.1" - - -def test_get_client_ip_x_real_ip_takes_priority_over_x_forwarded_for(): - """X-Real-IP beats X-Forwarded-For when both headers are present.""" - req = _make_request( - {"X-Real-IP": "1.1.1.1", "X-Forwarded-For": "2.2.2.2"}, - client_host="127.0.0.1", - ) - assert _get_client_ip(req) == "1.1.1.1" - - -def test_get_client_ip_falls_back_to_client_host_when_no_proxy_headers(): - """Without proxy headers, client.host is returned.""" - req = _make_request(client_host="203.0.113.99") - assert _get_client_ip(req) == "203.0.113.99" - - -def test_get_client_ip_returns_unknown_when_no_client_and_no_headers(): - """If no proxy headers and client is None, 'unknown' is returned.""" - scope = { - "type": "http", - "method": "POST", - "path": "/", - "headers": [], - "client": None, - } - req = Request(scope) - assert _get_client_ip(req) == "unknown" - - -# ── Integration: signal rate limit (429) ──────────────────────────────────── - - -@pytest.mark.asyncio -async def test_signal_rate_limit_returns_429_after_10_requests(): - """POST /api/signal returns 429 on the 11th request from the same IP.""" - async with make_app_client() as client: - api_key = await _register_and_get_key(client, _UUID_SIG_RL, "RL") - - payload = {"user_id": _UUID_SIG_RL, "timestamp": 1742478000000} - ip_hdrs = {"X-Real-IP": "5.5.5.5", "Authorization": f"Bearer {api_key}"} - - statuses = [] - for _ in range(11): - r = await client.post("/api/signal", json=payload, headers=ip_hdrs) - statuses.append(r.status_code) - - assert statuses[-1] == 429, f"Expected 429 on 11th request, got {statuses}" - - -@pytest.mark.asyncio -async def test_signal_first_10_requests_are_allowed(): - """First 10 POST /api/signal requests from the same IP must all return 200.""" - async with make_app_client() as client: - api_key = await _register_and_get_key(client, _UUID_SIG_OK, "OK") - - payload = {"user_id": _UUID_SIG_OK, "timestamp": 1742478000000} - ip_hdrs = {"X-Real-IP": "6.6.6.6", "Authorization": f"Bearer {api_key}"} - - statuses = [] - for _ in range(10): - r = await client.post("/api/signal", json=payload, headers=ip_hdrs) - statuses.append(r.status_code) - - assert all(s == 200 for s in statuses), ( - f"Some request(s) before limit returned non-200: {statuses}" - ) - - -# ── Integration: independence of register and signal rate limits ───────────── - - -@pytest.mark.asyncio -async def test_signal_rate_limit_does_not_affect_register_counter(): - """ - Exhausting the signal rate limit (11 requests) must NOT cause /api/register - to return 429 — the counters use different keys ('sig:IP' vs 'IP'). - """ - async with make_app_client() as client: - ip_hdrs_reg = {"X-Real-IP": "7.7.7.7"} - - # Register a user (increments register counter, key='7.7.7.7', count=1) - r_reg = await client.post( - "/api/register", - json={"uuid": _UUID_IND_SIG, "name": "Ind"}, - headers=ip_hdrs_reg, - ) - assert r_reg.status_code == 200 - api_key = r_reg.json()["api_key"] - - # Exhaust signal rate limit for same IP (11 requests, key='sig:7.7.7.7') - payload = {"user_id": _UUID_IND_SIG, "timestamp": 1742478000000} - ip_hdrs_sig = {"X-Real-IP": "7.7.7.7", "Authorization": f"Bearer {api_key}"} - for _ in range(11): - await client.post("/api/signal", json=payload, headers=ip_hdrs_sig) - - # Register counter is still at 1 — must allow another registration - r_reg2 = await client.post( - "/api/register", - json={"uuid": _UUID_IND_SIG2, "name": "Ind2"}, - headers=ip_hdrs_reg, - ) - - assert r_reg2.status_code == 200, ( - f"Register returned {r_reg2.status_code} — " - "signal exhaustion incorrectly bled into register counter" - ) - - -@pytest.mark.asyncio -async def test_register_rate_limit_does_not_affect_signal_counter(): - """ - Exhausting the register rate limit (6 requests → 6th returns 429) must NOT - prevent subsequent /api/signal requests from the same IP. - """ - async with make_app_client() as client: - ip_hdrs = {"X-Real-IP": "8.8.8.8"} - - # First register succeeds and creates the user we'll signal later - r0 = await client.post( - "/api/register", - json={"uuid": _UUID_IND_REG, "name": "Reg"}, - headers=ip_hdrs, - ) - assert r0.status_code == 200 - api_key = r0.json()["api_key"] - - # Send 4 more register requests from the same IP (requests 2-5 succeed, - # each rotates the api_key; request 6 would be 429). - # We keep track of the last api_key since re-registration rotates it. - for _ in range(4): - r = await client.post( - "/api/register", - json={"uuid": _UUID_IND_REG, "name": "Reg"}, - headers=ip_hdrs, - ) - if r.status_code == 200: - api_key = r.json()["api_key"] - - # 6th request → 429 (exhausts limit without rotating key) - await client.post( - "/api/register", - json={"uuid": _UUID_IND_REG, "name": "Reg"}, - headers=ip_hdrs, - ) - - # Signal must still succeed — signal counter (key='sig:8.8.8.8') is still 0 - r_sig = await client.post( - "/api/signal", - json={"user_id": _UUID_IND_REG, "timestamp": 1742478000000}, - headers={"X-Real-IP": "8.8.8.8", "Authorization": f"Bearer {api_key}"}, - ) - - assert r_sig.status_code == 200, ( - f"Signal returned {r_sig.status_code} — " - "register exhaustion incorrectly bled into signal counter" - ) - - -# ── Integration: signal rate limit is per-IP ───────────────────────────────── - - -@pytest.mark.asyncio -async def test_signal_rate_limit_is_per_ip_different_ips_are_independent(): - """ - Rate limit counters are per-IP — exhausting for IP A must not block IP B. - """ - async with make_app_client() as client: - api_key_a = await _register_and_get_key(client, _UUID_IP_A, "IPA") - api_key_b = await _register_and_get_key(client, _UUID_IP_B, "IPB") - - # Exhaust rate limit for IP A (11 requests → 11th is 429) - for _ in range(11): - await client.post( - "/api/signal", - json={"user_id": _UUID_IP_A, "timestamp": 1742478000000}, - headers={"X-Real-IP": "11.11.11.11", "Authorization": f"Bearer {api_key_a}"}, - ) - - # IP B should still be allowed (independent counter) - r = await client.post( - "/api/signal", - json={"user_id": _UUID_IP_B, "timestamp": 1742478000000}, - headers={"X-Real-IP": "22.22.22.22", "Authorization": f"Bearer {api_key_b}"}, - ) - - assert r.status_code == 200, f"IP B was incorrectly blocked: {r.status_code}" diff --git a/tests/test_sec_003.py b/tests/test_sec_003.py deleted file mode 100644 index cb04e53..0000000 --- a/tests/test_sec_003.py +++ /dev/null @@ -1,298 +0,0 @@ -""" -Tests for BATON-SEC-003: API-ключи для аутентификации /api/signal. - -Acceptance criteria: -1. POST /api/register возвращает api_key длиной 64 hex-символа. -2. POST /api/signal без Authorization header → 401. -3. POST /api/signal с неверным api_key → 401. -4. POST /api/signal с правильным api_key → 200. -5. Повторная регистрация генерирует новый api_key (ротация ключа). -6. Старый api_key становится недействительным после ротации. -7. Новый api_key работает после ротации. -8. SHA-256 хэш api_key сохраняется в БД, сырой ключ — нет (проверка через DB функцию). - -UUID notes: все UUID ниже удовлетворяют паттерну UUID v4. -""" -from __future__ import annotations - -import hashlib -import os - -os.environ.setdefault("BOT_TOKEN", "test-bot-token") -os.environ.setdefault("CHAT_ID", "-1001234567890") -os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret") -os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram") -os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000") -os.environ.setdefault("ADMIN_TOKEN", "test-admin-token") - -import pytest - -from backend import db -from tests.conftest import make_app_client, temp_db -from backend import config - -# Valid UUID v4 constants -_UUID_1 = "aa000001-0000-4000-8000-000000000001" -_UUID_2 = "aa000002-0000-4000-8000-000000000002" -_UUID_3 = "aa000003-0000-4000-8000-000000000003" -_UUID_4 = "aa000004-0000-4000-8000-000000000004" -_UUID_5 = "aa000005-0000-4000-8000-000000000005" -_UUID_6 = "aa000006-0000-4000-8000-000000000006" -_UUID_7 = "aa000007-0000-4000-8000-000000000007" -_UUID_8 = "aa000008-0000-4000-8000-000000000008" -_UUID_9 = "aa000009-0000-4000-8000-000000000009" -_UUID_10 = "aa00000a-0000-4000-8000-00000000000a" - - -# --------------------------------------------------------------------------- -# Criterion 1 — /api/register returns api_key of correct length -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_register_returns_api_key(): - """POST /api/register должен вернуть поле api_key в ответе.""" - async with make_app_client() as client: - resp = await client.post( - "/api/register", - json={"uuid": _UUID_1, "name": "Alice"}, - ) - assert resp.status_code == 200 - assert "api_key" in resp.json() - - -@pytest.mark.asyncio -async def test_register_api_key_is_64_hex_chars(): - """api_key должен быть строкой из 64 hex-символов (secrets.token_hex(32)).""" - async with make_app_client() as client: - resp = await client.post( - "/api/register", - json={"uuid": _UUID_2, "name": "Bob"}, - ) - api_key = resp.json()["api_key"] - assert len(api_key) == 64 - assert all(c in "0123456789abcdef" for c in api_key), ( - f"api_key contains non-hex characters: {api_key}" - ) - - -# --------------------------------------------------------------------------- -# Criterion 2 — Missing Authorization → 401 -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_signal_without_auth_header_returns_401(): - """POST /api/signal без Authorization header должен вернуть 401.""" - async with make_app_client() as client: - await client.post("/api/register", json={"uuid": _UUID_3, "name": "Charlie"}) - resp = await client.post( - "/api/signal", - json={"user_id": _UUID_3, "timestamp": 1742478000000}, - ) - assert resp.status_code == 401 - - -@pytest.mark.asyncio -async def test_signal_without_bearer_scheme_returns_401(): - """POST /api/signal с неверной схемой (Basic вместо Bearer) должен вернуть 401.""" - async with make_app_client() as client: - await client.post("/api/register", json={"uuid": _UUID_3, "name": "Charlie"}) - resp = await client.post( - "/api/signal", - json={"user_id": _UUID_3, "timestamp": 1742478000000}, - headers={"Authorization": "Basic wrongtoken"}, - ) - assert resp.status_code == 401 - - -# --------------------------------------------------------------------------- -# Criterion 3 — Wrong api_key → 401 -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_signal_with_wrong_api_key_returns_401(): - """POST /api/signal с неверным api_key должен вернуть 401.""" - async with make_app_client() as client: - await client.post("/api/register", json={"uuid": _UUID_4, "name": "Dave"}) - resp = await client.post( - "/api/signal", - json={"user_id": _UUID_4, "timestamp": 1742478000000}, - headers={"Authorization": "Bearer " + "0" * 64}, - ) - assert resp.status_code == 401 - - -@pytest.mark.asyncio -async def test_signal_with_unknown_user_returns_401(): - """POST /api/signal с api_key незарегистрированного пользователя должен вернуть 401.""" - async with make_app_client() as client: - resp = await client.post( - "/api/signal", - json={"user_id": _UUID_5, "timestamp": 1742478000000}, - headers={"Authorization": "Bearer " + "a" * 64}, - ) - assert resp.status_code == 401 - - -# --------------------------------------------------------------------------- -# Criterion 4 — Correct api_key → 200 -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_signal_with_valid_api_key_returns_200(): - """POST /api/signal с правильным api_key должен вернуть 200.""" - async with make_app_client() as client: - reg = await client.post( - "/api/register", - json={"uuid": _UUID_6, "name": "Eve"}, - ) - assert reg.status_code == 200 - api_key = reg.json()["api_key"] - - resp = await client.post( - "/api/signal", - json={"user_id": _UUID_6, "timestamp": 1742478000000}, - headers={"Authorization": f"Bearer {api_key}"}, - ) - assert resp.status_code == 200 - assert resp.json()["status"] == "ok" - - -# --------------------------------------------------------------------------- -# Criterion 5-7 — Key rotation on re-register -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_re_register_produces_new_api_key(): - """Повторная регистрация должна возвращать новый api_key (ротация).""" - async with make_app_client() as client: - r1 = await client.post("/api/register", json={"uuid": _UUID_7, "name": "Frank"}) - r2 = await client.post("/api/register", json={"uuid": _UUID_7, "name": "Frank"}) - - assert r1.status_code == 200 - assert r2.status_code == 200 - # Ключи могут совпасть (очень маловероятно), но оба должны быть длиной 64 - assert len(r2.json()["api_key"]) == 64 - - -@pytest.mark.asyncio -async def test_old_api_key_invalid_after_re_register(): - """После повторной регистрации старый api_key не должен работать.""" - async with make_app_client() as client: - r1 = await client.post("/api/register", json={"uuid": _UUID_8, "name": "Grace"}) - old_key = r1.json()["api_key"] - - # Повторная регистрация — ротация ключа - r2 = await client.post("/api/register", json={"uuid": _UUID_8, "name": "Grace"}) - new_key = r2.json()["api_key"] - - # Старый ключ больше не должен работать - old_resp = await client.post( - "/api/signal", - json={"user_id": _UUID_8, "timestamp": 1742478000000}, - headers={"Authorization": f"Bearer {old_key}"}, - ) - - # Новый ключ должен работать - new_resp = await client.post( - "/api/signal", - json={"user_id": _UUID_8, "timestamp": 1742478000000}, - headers={"Authorization": f"Bearer {new_key}"}, - ) - - assert old_resp.status_code == 401, "Старый ключ должен быть недействителен после ротации" - assert new_resp.status_code == 200, "Новый ключ должен работать" - - -# --------------------------------------------------------------------------- -# Criterion 5 (task brief) — Token from another user → 401 -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_signal_with_other_user_token_returns_401(): - """POST /api/signal с токеном другого пользователя должен вернуть 401. - - Невозможно отправить сигнал от чужого имени даже зная UUID. - """ - async with make_app_client() as client: - # Регистрируем двух пользователей - r_a = await client.post("/api/register", json={"uuid": _UUID_9, "name": "UserA"}) - r_b = await client.post("/api/register", json={"uuid": _UUID_10, "name": "UserB"}) - assert r_a.status_code == 200 - assert r_b.status_code == 200 - api_key_a = r_a.json()["api_key"] - api_key_b = r_b.json()["api_key"] - - # UserA пытается отправить сигнал с токеном UserB - resp_a_with_b_key = await client.post( - "/api/signal", - json={"user_id": _UUID_9, "timestamp": 1742478000000}, - headers={"Authorization": f"Bearer {api_key_b}"}, - ) - - # UserB пытается отправить сигнал с токеном UserA - resp_b_with_a_key = await client.post( - "/api/signal", - json={"user_id": _UUID_10, "timestamp": 1742478000000}, - headers={"Authorization": f"Bearer {api_key_a}"}, - ) - - assert resp_a_with_b_key.status_code == 401, ( - "Нельзя отправить сигнал от имени UserA с токеном UserB" - ) - assert resp_b_with_a_key.status_code == 401, ( - "Нельзя отправить сигнал от имени UserB с токеном UserA" - ) - - -# --------------------------------------------------------------------------- -# Criterion 8 — SHA-256 hash is stored, not the raw key -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_api_key_hash_stored_in_db_not_raw_key(): - """В БД должен храниться SHA-256 хэш api_key, а не сырой ключ.""" - with temp_db(): - from backend.main import app - import contextlib - import httpx - import respx - from httpx import AsyncClient, ASGITransport - - tg_set_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/setWebhook" - send_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage" - - mock_router = respx.mock(assert_all_called=False) - mock_router.post(tg_set_url).mock( - return_value=httpx.Response(200, json={"ok": True, "result": True}) - ) - mock_router.post(send_url).mock( - return_value=httpx.Response(200, json={"ok": True}) - ) - - with mock_router: - async with app.router.lifespan_context(app): - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://testserver") as client: - reg = await client.post( - "/api/register", - json={"uuid": _UUID_1, "name": "HashTest"}, - ) - assert reg.status_code == 200 - raw_api_key = reg.json()["api_key"] - - # Читаем хэш из БД напрямую - stored_hash = await db.get_api_key_hash_by_uuid(_UUID_1) - - expected_hash = hashlib.sha256(raw_api_key.encode()).hexdigest() - assert stored_hash is not None, "api_key_hash должен быть в БД" - assert stored_hash == expected_hash, ( - "В БД должен быть SHA-256 хэш, а не сырой ключ" - ) - assert stored_hash != raw_api_key, "В БД не должен храниться сырой ключ" diff --git a/tests/test_sec_006.py b/tests/test_sec_006.py deleted file mode 100644 index e0db144..0000000 --- a/tests/test_sec_006.py +++ /dev/null @@ -1,337 +0,0 @@ -""" -Tests for BATON-SEC-006: Персистентное хранение rate-limit счётчиков. - -Acceptance criteria: -1. Счётчики сохраняются между пересозданием экземпляра приложения (симуляция рестарта). -2. TTL-очистка корректно сбрасывает устаревшие записи после истечения окна. -3. Превышение лимита возвращает HTTP 429. -4. X-Real-IP и X-Forwarded-For корректно парсятся для подсчёта. - -UUID note: All UUIDs below satisfy the v4 pattern validated since BATON-SEC-005. -""" -from __future__ import annotations - -import os - -os.environ.setdefault("BOT_TOKEN", "test-bot-token") -os.environ.setdefault("CHAT_ID", "-1001234567890") -os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret") -os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram") -os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000") -os.environ.setdefault("ADMIN_TOKEN", "test-admin-token") - -import tempfile -import unittest.mock as mock - -import aiosqlite -import pytest - -from backend import config, db -from tests.conftest import make_app_client - -# ── Valid UUID v4 constants ────────────────────────────────────────────────── - -_UUID_XREALIP_A = "c0000001-0000-4000-8000-000000000001" # X-Real-IP exhaustion -_UUID_XREALIP_B = "c0000002-0000-4000-8000-000000000002" # IP-B (independent counter) -_UUID_XFWD = "c0000003-0000-4000-8000-000000000003" # X-Forwarded-For test -_UUID_REG_RL = "c0000004-0000-4000-8000-000000000004" # register 429 test - - -# ── Helpers ────────────────────────────────────────────────────────────────── - - -def _tmpdb() -> str: - """Set config.DB_PATH to a fresh temp file and return the path.""" - path = tempfile.mktemp(suffix=".db") - config.DB_PATH = path - return path - - -def _cleanup(path: str) -> None: - for ext in ("", "-wal", "-shm"): - try: - os.unlink(path + ext) - except FileNotFoundError: - pass - - -# ── Criterion 1: Persistence across restart ─────────────────────────────────── - - -@pytest.mark.asyncio -async def test_rate_limits_table_created_by_init_db(): - """init_db() creates the rate_limits table in SQLite.""" - path = _tmpdb() - try: - await db.init_db() - async with aiosqlite.connect(path) as conn: - async with conn.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name='rate_limits'" - ) as cur: - row = await cur.fetchone() - assert row is not None, "rate_limits table not found after init_db()" - finally: - _cleanup(path) - - -@pytest.mark.asyncio -async def test_rate_limit_counter_persists_after_db_reinit(): - """Counter survives re-initialization of the DB (simulates app restart). - - Before: in-memory app.state.rate_counters was lost on restart. - After: SQLite-backed rate_limits table persists across init_db() calls. - """ - path = _tmpdb() - try: - await db.init_db() - - c1 = await db.rate_limit_increment("persist:test", 600) - c2 = await db.rate_limit_increment("persist:test", 600) - c3 = await db.rate_limit_increment("persist:test", 600) - assert c3 == 3, f"Expected 3 after 3 increments, got {c3}" - - # Simulate restart: re-initialize DB against the same file - await db.init_db() - - # Counter must continue from 3, not reset to 0 - c4 = await db.rate_limit_increment("persist:test", 600) - assert c4 == 4, ( - f"Expected 4 after reinit + 1 more increment (counter must persist), got {c4}" - ) - finally: - _cleanup(path) - - -@pytest.mark.asyncio -async def test_rate_limit_increment_returns_sequential_counts(): - """rate_limit_increment returns 1, 2, 3 on successive calls within window.""" - path = _tmpdb() - try: - await db.init_db() - c1 = await db.rate_limit_increment("seq:test", 600) - c2 = await db.rate_limit_increment("seq:test", 600) - c3 = await db.rate_limit_increment("seq:test", 600) - assert (c1, c2, c3) == (1, 2, 3), f"Expected (1,2,3), got ({c1},{c2},{c3})" - finally: - _cleanup(path) - - -# ── Criterion 2: TTL cleanup resets stale entries ──────────────────────────── - - -@pytest.mark.asyncio -async def test_rate_limit_ttl_resets_counter_after_window_expires(): - """Counter resets to 1 when the time window has expired (TTL cleanup). - - time.time() is mocked — no real sleep required. - """ - path = _tmpdb() - try: - await db.init_db() - - with mock.patch("backend.db.time") as mock_time: - mock_time.time.return_value = 1000.0 # window_start = t0 - - c1 = await db.rate_limit_increment("ttl:test", 10) - c2 = await db.rate_limit_increment("ttl:test", 10) - c3 = await db.rate_limit_increment("ttl:test", 10) - assert c3 == 3 - - # Jump 11 seconds ahead (window = 10s → expired) - mock_time.time.return_value = 1011.0 - - c4 = await db.rate_limit_increment("ttl:test", 10) - - assert c4 == 1, ( - f"Expected counter reset to 1 after window expired, got {c4}" - ) - finally: - _cleanup(path) - - -@pytest.mark.asyncio -async def test_rate_limit_ttl_does_not_reset_within_window(): - """Counter is NOT reset when the window has NOT expired yet.""" - path = _tmpdb() - try: - await db.init_db() - - with mock.patch("backend.db.time") as mock_time: - mock_time.time.return_value = 1000.0 - - await db.rate_limit_increment("ttl:within", 10) - await db.rate_limit_increment("ttl:within", 10) - c3 = await db.rate_limit_increment("ttl:within", 10) - assert c3 == 3 - - # Only 5 seconds passed (window = 10s, still active) - mock_time.time.return_value = 1005.0 - - c4 = await db.rate_limit_increment("ttl:within", 10) - - assert c4 == 4, ( - f"Expected 4 (counter continues inside window), got {c4}" - ) - finally: - _cleanup(path) - - -@pytest.mark.asyncio -async def test_rate_limit_ttl_boundary_exactly_at_window_end(): - """Counter resets when elapsed time equals exactly the window duration.""" - path = _tmpdb() - try: - await db.init_db() - - with mock.patch("backend.db.time") as mock_time: - mock_time.time.return_value = 1000.0 - - await db.rate_limit_increment("ttl:boundary", 10) - await db.rate_limit_increment("ttl:boundary", 10) - - # Exactly at window boundary (elapsed == window → stale) - mock_time.time.return_value = 1010.0 - - c = await db.rate_limit_increment("ttl:boundary", 10) - - assert c == 1, ( - f"Expected reset at exact window boundary (elapsed == window), got {c}" - ) - finally: - _cleanup(path) - - -# ── Criterion 3: HTTP 429 when rate limit exceeded ──────────────────────────── - - -@pytest.mark.asyncio -async def test_register_returns_429_after_rate_limit_exceeded(): - """POST /api/register returns 429 on the 6th request from the same IP. - - Register limit = 5 requests per 600s window. - """ - async with make_app_client() as client: - ip_hdrs = {"X-Real-IP": "192.0.2.10"} - statuses = [] - for _ in range(6): - r = await client.post( - "/api/register", - json={"uuid": _UUID_REG_RL, "name": "RateLimitTest"}, - headers=ip_hdrs, - ) - statuses.append(r.status_code) - - assert statuses[-1] == 429, ( - f"Expected 429 on 6th register request, got statuses: {statuses}" - ) - - -@pytest.mark.asyncio -async def test_register_first_5_requests_are_allowed(): - """First 5 POST /api/register requests from the same IP must all return 200.""" - async with make_app_client() as client: - ip_hdrs = {"X-Real-IP": "192.0.2.11"} - statuses = [] - for _ in range(5): - r = await client.post( - "/api/register", - json={"uuid": _UUID_REG_RL, "name": "RateLimitTest"}, - headers=ip_hdrs, - ) - statuses.append(r.status_code) - - assert all(s == 200 for s in statuses), ( - f"Expected all 5 register requests to return 200, got: {statuses}" - ) - - -# ── Criterion 4: X-Real-IP and X-Forwarded-For for rate counting ────────────── - - -@pytest.mark.asyncio -async def test_x_real_ip_header_is_used_for_rate_counting(): - """Rate counter keys are derived from X-Real-IP: two requests sharing - the same X-Real-IP share the same counter and collectively hit the 429 limit. - """ - async with make_app_client() as client: - await client.post( - "/api/register", json={"uuid": _UUID_XREALIP_A, "name": "RealIPUser"} - ) - - ip_hdrs = {"X-Real-IP": "203.0.113.10"} - payload = {"user_id": _UUID_XREALIP_A, "timestamp": 1742478000000} - - statuses = [] - for _ in range(11): - r = await client.post("/api/signal", json=payload, headers=ip_hdrs) - statuses.append(r.status_code) - - assert statuses[-1] == 429, ( - f"Expected 429 on 11th signal with same X-Real-IP, got: {statuses}" - ) - - -@pytest.mark.asyncio -async def test_x_forwarded_for_header_is_used_for_rate_counting(): - """Rate counter keys are derived from X-Forwarded-For (first IP) when - X-Real-IP is absent: requests sharing the same forwarded IP hit the limit. - """ - async with make_app_client() as client: - await client.post( - "/api/register", json={"uuid": _UUID_XFWD, "name": "FwdUser"} - ) - - # Chain: first IP is the original client (only that one is used) - fwd_hdrs = {"X-Forwarded-For": "198.51.100.5, 10.0.0.1, 172.16.0.1"} - payload = {"user_id": _UUID_XFWD, "timestamp": 1742478000000} - - statuses = [] - for _ in range(11): - r = await client.post("/api/signal", json=payload, headers=fwd_hdrs) - statuses.append(r.status_code) - - assert statuses[-1] == 429, ( - f"Expected 429 on 11th request with same X-Forwarded-For first IP, got: {statuses}" - ) - - -@pytest.mark.asyncio -async def test_different_x_real_ip_values_have_independent_counters(): - """Exhausting the rate limit for IP-A must not block IP-B. - - Verifies that rate-limit keys are truly per-IP. - """ - async with make_app_client() as client: - r_a = await client.post( - "/api/register", json={"uuid": _UUID_XREALIP_A, "name": "IPA"} - ) - r_b = await client.post( - "/api/register", json={"uuid": _UUID_XREALIP_B, "name": "IPB"} - ) - api_key_a = r_a.json()["api_key"] - api_key_b = r_b.json()["api_key"] - - # Exhaust limit for IP-A (with valid auth so requests reach the rate limiter) - for _ in range(11): - await client.post( - "/api/signal", - json={"user_id": _UUID_XREALIP_A, "timestamp": 1742478000000}, - headers={ - "X-Real-IP": "198.51.100.100", - "Authorization": f"Bearer {api_key_a}", - }, - ) - - # IP-B has its own independent counter — must not be blocked - r = await client.post( - "/api/signal", - json={"user_id": _UUID_XREALIP_B, "timestamp": 1742478000000}, - headers={ - "X-Real-IP": "198.51.100.200", - "Authorization": f"Bearer {api_key_b}", - }, - ) - - assert r.status_code == 200, ( - f"IP-B was incorrectly blocked after IP-A exhausted its counter: {r.status_code}" - ) diff --git a/tests/test_sec_007.py b/tests/test_sec_007.py deleted file mode 100644 index a6c3383..0000000 --- a/tests/test_sec_007.py +++ /dev/null @@ -1,240 +0,0 @@ -""" -Regression tests for BATON-SEC-007: - -1. Retry loop in telegram.py is bounded to exactly 3 attempts. -2. Exponential backoff applies correctly: sleep = retry_after * (attempt + 1). -3. POST /api/signal uses asyncio.create_task — HTTP response is not blocked - by Telegram rate-limit pauses. -4. GET /health returns only {"status": "ok"} — no timestamp field. - -BATON-SEC-003: POST /api/signal now requires Authorization: Bearer . -Tests that send signals now register first and use the returned api_key. -""" -from __future__ import annotations - -import asyncio -import logging -import os - -os.environ.setdefault("BOT_TOKEN", "test-bot-token") -os.environ.setdefault("CHAT_ID", "-1001234567890") -os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret") -os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram") -os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000") -os.environ.setdefault("ADMIN_TOKEN", "test-admin-token") - -from unittest.mock import AsyncMock, patch - -import httpx -import pytest -import respx - -from backend import config -from backend.telegram import send_message -from tests.conftest import make_app_client - -SEND_URL = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage" - -# Valid UUID v4 constants -_UUID_CT = "d4e5f6a7-b8c9-4d0e-a1b2-c3d4e5f6a7b8" -_UUID_SLOW = "e5f6a7b8-c9d0-4e1f-a2b3-c4d5e6f7a8b9" - - -# --------------------------------------------------------------------------- -# Criterion 1 — retry loop is bounded to max 3 attempts -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_retry_loop_stops_after_3_attempts_on_all_429(): - """When all 3 responses are 429, send_message makes exactly 3 HTTP requests and stops.""" - responses = [ - httpx.Response(429, json={"ok": False, "parameters": {"retry_after": 1}}), - httpx.Response(429, json={"ok": False, "parameters": {"retry_after": 1}}), - httpx.Response(429, json={"ok": False, "parameters": {"retry_after": 1}}), - ] - with respx.mock(assert_all_called=False) as mock: - route = mock.post(SEND_URL).mock(side_effect=responses) - with patch("backend.telegram.asyncio.sleep", new_callable=AsyncMock): - await send_message("test max 3 attempts") - - assert route.call_count == 3 - - -@pytest.mark.asyncio -async def test_retry_loop_does_not_make_4th_attempt_on_all_429(): - """send_message must never attempt a 4th request when the first 3 all return 429.""" - call_count = 0 - - async def _count_and_return_429(_request): - nonlocal call_count - call_count += 1 - return httpx.Response(429, json={"ok": False, "parameters": {"retry_after": 1}}) - - with respx.mock(assert_all_called=False) as mock: - mock.post(SEND_URL).mock(side_effect=_count_and_return_429) - with patch("backend.telegram.asyncio.sleep", new_callable=AsyncMock): - await send_message("test no 4th attempt") - - assert call_count == 3 - - -# --------------------------------------------------------------------------- -# Criterion 2 — exponential backoff: sleep = retry_after * (attempt + 1) -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_retry_429_first_attempt_sleeps_retry_after_times_1(): - """First 429 (attempt 0): sleep duration must be retry_after * 1.""" - retry_after = 7 - responses = [ - httpx.Response(429, json={"ok": False, "parameters": {"retry_after": retry_after}}), - httpx.Response(200, json={"ok": True}), - ] - with respx.mock(assert_all_called=False) as mock: - mock.post(SEND_URL).mock(side_effect=responses) - with patch("backend.telegram.asyncio.sleep", new_callable=AsyncMock) as mock_sleep: - await send_message("test attempt 0 backoff") - - mock_sleep.assert_called_once_with(retry_after * 1) - - -@pytest.mark.asyncio -async def test_retry_429_exponential_backoff_sleep_sequence(): - """Two consecutive 429 responses produce sleep = retry_after*1 then retry_after*2.""" - retry_after = 10 - responses = [ - httpx.Response(429, json={"ok": False, "parameters": {"retry_after": retry_after}}), - httpx.Response(429, json={"ok": False, "parameters": {"retry_after": retry_after}}), - httpx.Response(200, json={"ok": True}), - ] - with respx.mock(assert_all_called=False) as mock: - mock.post(SEND_URL).mock(side_effect=responses) - with patch("backend.telegram.asyncio.sleep", new_callable=AsyncMock) as mock_sleep: - await send_message("test backoff sequence") - - sleep_args = [c.args[0] for c in mock_sleep.call_args_list] - assert retry_after * 1 in sleep_args, f"Expected sleep({retry_after}) not found in {sleep_args}" - assert retry_after * 2 in sleep_args, f"Expected sleep({retry_after * 2}) not found in {sleep_args}" - - -@pytest.mark.asyncio -async def test_retry_429_third_attempt_sleeps_retry_after_times_3(): - """Third 429 (attempt 2): sleep duration must be retry_after * 3.""" - retry_after = 5 - responses = [ - httpx.Response(429, json={"ok": False, "parameters": {"retry_after": retry_after}}), - httpx.Response(429, json={"ok": False, "parameters": {"retry_after": retry_after}}), - httpx.Response(429, json={"ok": False, "parameters": {"retry_after": retry_after}}), - ] - with respx.mock(assert_all_called=False) as mock: - mock.post(SEND_URL).mock(side_effect=responses) - with patch("backend.telegram.asyncio.sleep", new_callable=AsyncMock) as mock_sleep: - await send_message("test attempt 2 backoff") - - sleep_args = [c.args[0] for c in mock_sleep.call_args_list] - assert retry_after * 3 in sleep_args, f"Expected sleep({retry_after * 3}) not found in {sleep_args}" - - -# --------------------------------------------------------------------------- -# After exhausting all 3 attempts — error is logged -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_send_message_all_attempts_exhausted_logs_error(caplog): - """After 3 failed 429 attempts, an ERROR containing 'all 3 attempts' is logged.""" - responses = [ - httpx.Response(429, json={"ok": False, "parameters": {"retry_after": 1}}), - httpx.Response(429, json={"ok": False, "parameters": {"retry_after": 1}}), - httpx.Response(429, json={"ok": False, "parameters": {"retry_after": 1}}), - ] - with respx.mock(assert_all_called=False) as mock: - mock.post(SEND_URL).mock(side_effect=responses) - with patch("backend.telegram.asyncio.sleep", new_callable=AsyncMock): - with caplog.at_level(logging.ERROR, logger="backend.telegram"): - await send_message("test exhausted log") - - error_messages = [r.message for r in caplog.records if r.levelno == logging.ERROR] - assert any("all 3 attempts" in m.lower() for m in error_messages), ( - f"Expected 'all 3 attempts' in error logs, got: {error_messages}" - ) - - -# --------------------------------------------------------------------------- -# Criterion 3 — POST /api/signal uses asyncio.create_task (non-blocking) -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_signal_uses_create_task_for_telegram_send_message(): - """POST /api/signal must wrap telegram.send_message in asyncio.create_task.""" - with patch("backend.main.asyncio.create_task", wraps=asyncio.create_task) as mock_ct: - async with make_app_client() as client: - reg = await client.post("/api/register", json={"uuid": _UUID_CT, "name": "CT"}) - assert reg.status_code == 200 - api_key = reg.json()["api_key"] - resp = await client.post( - "/api/signal", - json={"user_id": _UUID_CT, "timestamp": 1742478000000}, - headers={"Authorization": f"Bearer {api_key}"}, - ) - - assert resp.status_code == 200 - assert mock_ct.called, "asyncio.create_task was never called — send_message may have been awaited directly" - - -@pytest.mark.asyncio -async def test_signal_response_returns_before_telegram_completes(): - """POST /api/signal returns 200 even when Telegram send_message is delayed.""" - slow_sleep_called = False - - async def slow_send_message(_text: str) -> None: - nonlocal slow_sleep_called - slow_sleep_called = True - await asyncio.sleep(9999) # would block forever if awaited - - with patch("backend.main.telegram.send_message", side_effect=slow_send_message): - with patch("backend.main.asyncio.create_task", wraps=asyncio.create_task): - async with make_app_client() as client: - reg = await client.post( - "/api/register", - json={"uuid": _UUID_SLOW, "name": "Slow"}, - ) - assert reg.status_code == 200 - api_key = reg.json()["api_key"] - resp = await client.post( - "/api/signal", - json={ - "user_id": _UUID_SLOW, - "timestamp": 1742478000000, - }, - headers={"Authorization": f"Bearer {api_key}"}, - ) - - assert resp.status_code == 200 - - -# --------------------------------------------------------------------------- -# Criterion 4 — GET /health exact response body (regression guard) -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_health_response_is_exactly_status_ok(): - """GET /health body must be exactly {"status": "ok"} — no extra fields.""" - async with make_app_client() as client: - response = await client.get("/health") - - assert response.status_code == 200 - assert response.json() == {"status": "ok"} - - -@pytest.mark.asyncio -async def test_health_no_timestamp_field(): - """GET /health must not expose a timestamp field (time-based fingerprinting prevention).""" - async with make_app_client() as client: - response = await client.get("/health") - - assert "timestamp" not in response.json() diff --git a/tests/test_signal.py b/tests/test_signal.py index ca9d547..83a86af 100644 --- a/tests/test_signal.py +++ b/tests/test_signal.py @@ -1,11 +1,5 @@ """ Integration tests for POST /api/signal. - -UUID notes: both RegisterRequest.uuid and SignalRequest.user_id require valid UUID v4. -All UUID constants below satisfy the pattern. - -BATON-SEC-003: /api/signal now requires Authorization: Bearer . -The _register() helper returns the api_key from the registration response. """ from __future__ import annotations @@ -16,42 +10,30 @@ os.environ.setdefault("CHAT_ID", "-1001234567890") os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret") os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram") os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000") -os.environ.setdefault("ADMIN_TOKEN", "test-admin-token") import pytest from httpx import AsyncClient from tests.conftest import make_app_client -# Valid UUID v4 constants for signal tests -_UUID_1 = "c0000001-0000-4000-8000-000000000001" -_UUID_2 = "c0000002-0000-4000-8000-000000000002" -_UUID_3 = "c0000003-0000-4000-8000-000000000003" -_UUID_4 = "c0000004-0000-4000-8000-000000000004" -_UUID_5 = "c0000005-0000-4000-8000-000000000005" -_UUID_6 = "c0000006-0000-4000-8000-000000000006" - -async def _register(client: AsyncClient, uuid: str, name: str) -> str: - """Register user, assert success, return raw api_key.""" +async def _register(client: AsyncClient, uuid: str, name: str) -> None: r = await client.post("/api/register", json={"uuid": uuid, "name": name}) - assert r.status_code == 200, f"Registration failed: {r.status_code} {r.text}" - return r.json()["api_key"] + assert r.status_code == 200 @pytest.mark.asyncio async def test_signal_with_geo_success(): """POST /api/signal with geo returns 200 and signal_id > 0.""" async with make_app_client() as client: - api_key = await _register(client, _UUID_1, "Alice") + await _register(client, "sig-uuid-001", "Alice") resp = await client.post( "/api/signal", json={ - "user_id": _UUID_1, + "user_id": "sig-uuid-001", "timestamp": 1742478000000, "geo": {"lat": 55.7558, "lon": 37.6173, "accuracy": 15.0}, }, - headers={"Authorization": f"Bearer {api_key}"}, ) assert resp.status_code == 200 data = resp.json() @@ -63,29 +45,28 @@ async def test_signal_with_geo_success(): async def test_signal_without_geo_success(): """POST /api/signal with geo: null returns 200.""" async with make_app_client() as client: - api_key = await _register(client, _UUID_2, "Bob") + await _register(client, "sig-uuid-002", "Bob") resp = await client.post( "/api/signal", json={ - "user_id": _UUID_2, + "user_id": "sig-uuid-002", "timestamp": 1742478000000, "geo": None, }, - headers={"Authorization": f"Bearer {api_key}"}, ) assert resp.status_code == 200 assert resp.json()["status"] == "ok" @pytest.mark.asyncio -async def test_signal_missing_auth_returns_401(): - """Missing Authorization header must return 401.""" +async def test_signal_missing_user_id_returns_422(): + """Missing user_id field must return 422.""" async with make_app_client() as client: resp = await client.post( "/api/signal", json={"timestamp": 1742478000000}, ) - assert resp.status_code == 401 + assert resp.status_code == 422 @pytest.mark.asyncio @@ -94,7 +75,7 @@ async def test_signal_missing_timestamp_returns_422(): async with make_app_client() as client: resp = await client.post( "/api/signal", - json={"user_id": _UUID_3}, + json={"user_id": "sig-uuid-003"}, ) assert resp.status_code == 422 @@ -106,16 +87,14 @@ async def test_signal_stored_in_db(): proving both were persisted. """ async with make_app_client() as client: - api_key = await _register(client, _UUID_4, "Charlie") + await _register(client, "sig-uuid-004", "Charlie") r1 = await client.post( "/api/signal", - json={"user_id": _UUID_4, "timestamp": 1742478000001}, - headers={"Authorization": f"Bearer {api_key}"}, + json={"user_id": "sig-uuid-004", "timestamp": 1742478000001}, ) r2 = await client.post( "/api/signal", - json={"user_id": _UUID_4, "timestamp": 1742478000002}, - headers={"Authorization": f"Bearer {api_key}"}, + json={"user_id": "sig-uuid-004", "timestamp": 1742478000002}, ) assert r1.status_code == 200 assert r2.status_code == 200 @@ -132,12 +111,11 @@ async def test_signal_sends_telegram_message_directly(): send_url = f"https://api.telegram.org/bot{_cfg.BOT_TOKEN}/sendMessage" async with make_app_client() as client: - api_key = await _register(client, _UUID_5, "Dana") + await _register(client, "sig-uuid-005", "Dana") # make_app_client already mocks send_url; signal returns 200 proves send was called resp = await client.post( "/api/signal", - json={"user_id": _UUID_5, "timestamp": 1742478000000}, - headers={"Authorization": f"Bearer {api_key}"}, + json={"user_id": "sig-uuid-005", "timestamp": 1742478000000}, ) assert resp.status_code == 200 @@ -148,11 +126,10 @@ async def test_signal_sends_telegram_message_directly(): async def test_signal_returns_signal_id_positive(): """signal_id in response is always a positive integer.""" async with make_app_client() as client: - api_key = await _register(client, _UUID_6, "Eve") + await _register(client, "sig-uuid-006", "Eve") resp = await client.post( "/api/signal", - json={"user_id": _UUID_6, "timestamp": 1742478000000}, - headers={"Authorization": f"Bearer {api_key}"}, + json={"user_id": "sig-uuid-006", "timestamp": 1742478000000}, ) assert resp.json()["signal_id"] > 0 @@ -164,7 +141,7 @@ async def test_signal_geo_invalid_lat_returns_422(): resp = await client.post( "/api/signal", json={ - "user_id": _UUID_1, + "user_id": "sig-uuid-007", "timestamp": 1742478000000, "geo": {"lat": 200.0, "lon": 0.0, "accuracy": 10.0}, }, diff --git a/tests/test_telegram.py b/tests/test_telegram.py index bd46f51..17ec801 100644 --- a/tests/test_telegram.py +++ b/tests/test_telegram.py @@ -1,5 +1,5 @@ """ -Tests for backend/telegram.py: send_message, set_webhook, validate_bot_token. +Tests for backend/telegram.py: send_message, set_webhook, SignalAggregator. NOTE: respx routes must be registered INSIDE the 'with mock:' block to be intercepted properly. Registering them before entering the context does not @@ -25,6 +25,8 @@ def _safe_aiosqlite_await(self): aiosqlite.core.Connection.__await__ = _safe_aiosqlite_await # type: ignore[method-assign] import json +import os as _os +import tempfile from unittest.mock import AsyncMock, patch import httpx @@ -32,57 +34,11 @@ import pytest import respx from backend import config -from backend.telegram import send_message, set_webhook, validate_bot_token +from backend.telegram import SignalAggregator, send_message, set_webhook SEND_URL = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage" WEBHOOK_URL_API = f"https://api.telegram.org/bot{config.BOT_TOKEN}/setWebhook" -GET_ME_URL = f"https://api.telegram.org/bot{config.BOT_TOKEN}/getMe" - - -# --------------------------------------------------------------------------- -# validate_bot_token -# --------------------------------------------------------------------------- - -@pytest.mark.asyncio -async def test_validate_bot_token_returns_true_on_200(): - """validate_bot_token returns True when getMe responds 200.""" - with respx.mock(assert_all_called=False) as mock: - mock.get(GET_ME_URL).mock( - return_value=httpx.Response(200, json={"ok": True, "result": {"username": "batonbot"}}) - ) - result = await validate_bot_token() - assert result is True - - -@pytest.mark.asyncio -async def test_validate_bot_token_returns_false_on_401(caplog): - """validate_bot_token returns False and logs ERROR when getMe responds 401.""" - import logging - - with respx.mock(assert_all_called=False) as mock: - mock.get(GET_ME_URL).mock( - return_value=httpx.Response(401, json={"ok": False, "description": "Unauthorized"}) - ) - with caplog.at_level(logging.ERROR, logger="backend.telegram"): - result = await validate_bot_token() - - assert result is False - assert any("401" in record.message for record in caplog.records) - - -@pytest.mark.asyncio -async def test_validate_bot_token_returns_false_on_network_error(caplog): - """validate_bot_token returns False and logs ERROR on network failure — never raises.""" - import logging - - with respx.mock(assert_all_called=False) as mock: - mock.get(GET_ME_URL).mock(side_effect=httpx.ConnectError("connection refused")) - with caplog.at_level(logging.ERROR, logger="backend.telegram"): - result = await validate_bot_token() - - assert result is False - assert len(caplog.records) >= 1 # --------------------------------------------------------------------------- @@ -185,66 +141,152 @@ async def test_set_webhook_raises_on_non_200(): # --------------------------------------------------------------------------- -# BATON-007: 400 "chat not found" handling +# SignalAggregator helpers +# --------------------------------------------------------------------------- + +async def _init_db_with_tmp() -> str: + """Init a temp-file DB and return its path.""" + from backend import config as _cfg, db as _db + path = tempfile.mktemp(suffix=".db") + _cfg.DB_PATH = path + await _db.init_db() + return path + + +def _cleanup(path: str) -> None: + for ext in ("", "-wal", "-shm"): + try: + _os.unlink(path + ext) + except FileNotFoundError: + pass + + +# --------------------------------------------------------------------------- +# SignalAggregator tests # --------------------------------------------------------------------------- @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"}, - ) +async def test_aggregator_single_signal_calls_send_message(): + """Flushing an aggregator with one signal calls send_message once.""" + path = await _init_db_with_tmp() + try: + agg = SignalAggregator(interval=9999) + await agg.add_signal( + user_uuid="agg-uuid-001", + user_name="Alice", + timestamp=1742478000000, + geo={"lat": 55.0, "lon": 37.0, "accuracy": 10.0}, + signal_id=1, ) - # Must not raise — service must stay alive even with wrong CHAT_ID - await send_message("test") + + with respx.mock(assert_all_called=False) as mock: + send_route = mock.post(SEND_URL).mock( + return_value=httpx.Response(200, json={"ok": True}) + ) + with patch("backend.telegram.db.save_telegram_batch", new_callable=AsyncMock): + with patch("backend.telegram.asyncio.sleep", new_callable=AsyncMock): + await agg.flush() + + assert send_route.call_count == 1 + finally: + _cleanup(path) @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"}, +async def test_aggregator_multiple_signals_one_message(): + """5 signals flushed at once produce exactly one send_message call.""" + path = await _init_db_with_tmp() + try: + agg = SignalAggregator(interval=9999) + for i in range(5): + await agg.add_signal( + user_uuid=f"agg-uuid-{i:03d}", + user_name=f"User{i}", + timestamp=1742478000000 + i * 1000, + geo=None, + signal_id=i + 1, ) - ) - 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]) - ) + with respx.mock(assert_all_called=False) as mock: + send_route = mock.post(SEND_URL).mock( + return_value=httpx.Response(200, json={"ok": True}) + ) + with patch("backend.telegram.db.save_telegram_batch", new_callable=AsyncMock): + with patch("backend.telegram.asyncio.sleep", new_callable=AsyncMock): + await agg.flush() + + assert send_route.call_count == 1 + finally: + _cleanup(path) @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") +async def test_aggregator_empty_buffer_no_send(): + """Flushing an empty aggregator must NOT call send_message.""" + agg = SignalAggregator(interval=9999) - assert route.call_count == 1, f"Expected 1 call on 400, got {route.call_count}" + # No routes registered — if a POST is made it will raise AllMockedAssertionError + with respx.mock(assert_all_called=False) as mock: + send_route = mock.post(SEND_URL).mock( + return_value=httpx.Response(200, json={"ok": True}) + ) + await agg.flush() + + assert send_route.call_count == 0 @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") +async def test_aggregator_buffer_cleared_after_flush(): + """After flush, the aggregator buffer is empty.""" + path = await _init_db_with_tmp() + try: + agg = SignalAggregator(interval=9999) + await agg.add_signal( + user_uuid="agg-uuid-clr", + user_name="Test", + timestamp=1742478000000, + geo=None, + signal_id=99, ) - 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") + assert len(agg._buffer) == 1 + with respx.mock(assert_all_called=False) as mock: + mock.post(SEND_URL).mock(return_value=httpx.Response(200, json={"ok": True})) + with patch("backend.telegram.db.save_telegram_batch", new_callable=AsyncMock): + with patch("backend.telegram.asyncio.sleep", new_callable=AsyncMock): + await agg.flush() + + assert len(agg._buffer) == 0 + finally: + _cleanup(path) + + +@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.""" + path = await _init_db_with_tmp() + try: + agg = SignalAggregator(interval=9999) + test_uuid = "abcdef1234567890" + await agg.add_signal( + user_uuid=test_uuid, + user_name=None, + timestamp=1742478000000, + geo=None, + signal_id=1, + ) + + sent_texts: list[str] = [] + + async def _fake_send(text: str) -> None: + sent_texts.append(text) + + with patch("backend.telegram.send_message", side_effect=_fake_send): + with patch("backend.telegram.db.save_telegram_batch", new_callable=AsyncMock): + with patch("backend.telegram.asyncio.sleep", new_callable=AsyncMock): + await agg.flush() + + assert len(sent_texts) == 1 + assert test_uuid[:8] in sent_texts[0] + finally: + _cleanup(path)