diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..c29bee9
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,16 @@
+.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 cf447e0..6d8ac36 100644
--- a/.env.example
+++ b/.env.example
@@ -11,3 +11,8 @@ 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
new file mode 100644
index 0000000..7732a47
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,11 @@
+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
new file mode 100644
index 0000000..c8c6c93
--- /dev/null
+++ b/=2.0.0
@@ -0,0 +1 @@
+(eval):1: command not found: pip
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..8345948
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,12 @@
+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 40159b0..305d05e 100644
--- a/backend/config.py
+++ b/backend/config.py
@@ -22,3 +22,9 @@ 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 5acc94c..6243e04 100644
--- a/backend/db.py
+++ b/backend/db.py
@@ -67,6 +67,31 @@ async def init_db() -> None:
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 [
@@ -284,6 +309,81 @@ async def rate_limit_increment(key: str, window: float) -> int:
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,
@@ -306,3 +406,36 @@ 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 7fb9d19..672cfaa 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio
import hashlib
+import hmac
import logging
import os
import secrets
@@ -15,12 +16,27 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
-from backend import config, db, telegram
-from backend.middleware import rate_limit_register, rate_limit_signal, verify_admin_token, verify_webhook_secret
+from backend 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.models import (
AdminBlockRequest,
AdminCreateUserRequest,
AdminSetPasswordRequest,
+ AuthLoginRequest,
+ AuthLoginResponse,
+ AuthRegisterRequest,
+ AuthRegisterResponse,
RegisterRequest,
RegisterResponse,
SignalRequest,
@@ -30,6 +46,8 @@ from backend.models import (
_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__)
@@ -47,6 +65,18 @@ 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 минут
@@ -117,7 +147,7 @@ app = FastAPI(lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=[config.FRONTEND_ORIGIN],
- allow_methods=["POST"],
+ allow_methods=["GET", "HEAD", "OPTIONS", "POST"],
allow_headers=["Content-Type", "Authorization"],
)
@@ -128,6 +158,12 @@ 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}
+
+
@app.post("/api/register", response_model=RegisterResponse)
async def register(body: RegisterRequest, _: None = Depends(rate_limit_register)) -> RegisterResponse:
api_key = secrets.token_hex(32)
@@ -143,13 +179,36 @@ async def signal(
) -> SignalResponse:
if credentials is None:
raise HTTPException(status_code=401, detail="Unauthorized")
- key_hash = _hash_api_key(credentials.credentials)
- stored_hash = await db.get_api_key_hash_by_uuid(body.user_id)
- if stored_hash is None or not secrets.compare_digest(key_hash, stored_hash):
- raise HTTPException(status_code=401, detail="Unauthorized")
- if await db.is_user_blocked(body.user_id):
- raise HTTPException(status_code=403, detail="User is blocked")
+ 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]
geo = body.geo
lat = geo.lat if geo else None
@@ -157,31 +216,157 @@ async def signal(
accuracy = geo.accuracy if geo else None
signal_id = await db.save_signal(
- user_uuid=body.user_id,
+ user_uuid=user_identifier,
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}м)"
+ f"📍 {lat}, {lon} (±{accuracy:.0f}м)"
if geo
- else "Без геолокации"
- )
- text = (
- f"🚨 Сигнал от {name}\n"
- f"⏰ {ts.strftime('%H:%M:%S')} UTC\n"
- f"{geo_info}"
+ 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))
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()
@@ -226,6 +411,13 @@ 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 b91b83e..55f4269 100644
--- a/backend/middleware.py
+++ b/backend/middleware.py
@@ -1,6 +1,11 @@
from __future__ import annotations
+import base64
+import hashlib
+import hmac
+import json
import secrets
+import time
from typing import Optional
from fastapi import Depends, Header, HTTPException, Request
@@ -8,6 +13,12 @@ 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()
+)
+
_bearer = HTTPBearer(auto_error=False)
_RATE_LIMIT = 5
@@ -16,6 +27,9 @@ _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 (
@@ -25,6 +39,12 @@ def _get_client_ip(request: Request) -> str:
)
+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=""),
) -> None:
@@ -55,3 +75,81 @@ async def rate_limit_signal(request: Request) -> None:
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 7b88b20..91c0941 100644
--- a/backend/models.py
+++ b/backend/models.py
@@ -1,7 +1,7 @@
from __future__ import annotations
from typing import Optional
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, EmailStr, Field
class RegisterRequest(BaseModel):
@@ -22,9 +22,10 @@ class GeoData(BaseModel):
class SignalRequest(BaseModel):
- user_id: str = Field(..., pattern=r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$')
+ user_id: Optional[str] = None # UUID for legacy api_key auth; omit for JWT auth
timestamp: int = Field(..., gt=0)
geo: Optional[GeoData] = None
+ is_test: bool = False
class SignalResponse(BaseModel):
@@ -44,3 +45,35 @@ 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
new file mode 100644
index 0000000..c86f799
--- /dev/null
+++ b/backend/push.py
@@ -0,0 +1,35 @@
+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 0633462..881bb23 100644
--- a/backend/telegram.py
+++ b/backend/telegram.py
@@ -14,6 +14,13 @@ 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")
@@ -29,7 +36,13 @@ async def validate_bot_token() -> bool:
)
return False
except Exception as exc:
- logger.error("BOT_TOKEN validation failed (network): %s", 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
@@ -37,7 +50,7 @@ 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})
+ resp = await client.post(url, json={"chat_id": config.CHAT_ID, "text": text, "parse_mode": "HTML"})
if resp.status_code == 429:
retry_after = resp.json().get("parameters", {}).get("retry_after", 30)
sleep = retry_after * (attempt + 1)
@@ -55,6 +68,71 @@ async def send_message(text: str) -> None:
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:
api_url = _TELEGRAM_API.format(token=config.BOT_TOKEN, method="setWebhook")
async with httpx.AsyncClient(timeout=10) as client:
@@ -65,76 +143,3 @@ 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 141d6b6..ec8be1f 100644
--- a/deploy/baton.service
+++ b/deploy/baton.service
@@ -8,6 +8,7 @@ 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
new file mode 100644
index 0000000..f3a1680
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,23 @@
+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
new file mode 100644
index 0000000..6bf2a49
--- /dev/null
+++ b/docs/DESIGN_BATON008.md
@@ -0,0 +1,300 @@
+# 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 e457ee7..88aa0c0 100644
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -39,31 +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_registered') === '1';
+ return !!_storage.getItem('baton_auth_token');
}
function _getUserName() {
- return _storage.getItem('baton_user_name') || '';
+ return _storage.getItem('baton_login') || '';
}
-function _getApiKey() {
- return _storage.getItem('baton_api_key') || '';
+function _getAuthToken() {
+ return _storage.getItem('baton_auth_token') || '';
}
-function _saveRegistration(name, apiKey) {
- _storage.setItem('baton_user_name', name);
- _storage.setItem('baton_registered', '1');
- if (apiKey) _storage.setItem('baton_api_key', apiKey);
+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 _getInitials(name) {
@@ -92,6 +87,29 @@ 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;
@@ -142,23 +160,38 @@ function _getGeo() {
// ========== Handlers ==========
-async function _handleRegister() {
- const input = document.getElementById('name-input');
- const btn = document.getElementById('btn-confirm');
- const name = input.value.trim();
- if (!name) return;
+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;
btn.disabled = true;
- _setStatus('', '');
+ _setLoginStatus('', '');
try {
- const uuid = _getOrCreateUserId();
- const data = await _apiPost('/api/register', { uuid, name });
- _saveRegistration(name, data.api_key);
+ const data = await _apiPost('/api/auth/login', {
+ login_or_email: login,
+ password: password,
+ });
+ _saveAuth(data.token, data.login);
+ passInput.value = '';
_updateUserAvatar();
_showMain();
- } catch (_) {
- _setStatus('Error. Please try again.', 'error');
+ } 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');
btn.disabled = false;
}
}
@@ -170,10 +203,43 @@ function _setSosState(state) {
btn.disabled = state === 'sending';
}
-async function _handleSignal() {
- // v1: no offline queue — show error and return (decision #1019)
+async function _handleTestSignal() {
if (!navigator.onLine) {
- _setStatus('No connection. Check your network and try again.', 'error');
+ _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() {
+ if (!navigator.onLine) {
+ _setStatus('Нет соединения. Проверьте сеть и попробуйте снова.', 'error');
+ return;
+ }
+
+ const token = _getAuthToken();
+ if (!token) {
+ _clearAuth();
+ _showOnboarding();
return;
}
@@ -182,16 +248,13 @@ async function _handleSignal() {
try {
const geo = await _getGeo();
- const uuid = _getOrCreateUserId();
- const body = { user_id: uuid, timestamp: Date.now() };
+ const body = { timestamp: Date.now() };
if (geo) body.geo = geo;
- const apiKey = _getApiKey();
- const authHeaders = apiKey ? { Authorization: 'Bearer ' + apiKey } : {};
- await _apiPost('/api/signal', body, authHeaders);
+ await _apiPost('/api/signal', body, { Authorization: 'Bearer ' + token });
_setSosState('success');
- _setStatus('Signal sent!', 'success');
+ _setStatus('Сигнал отправлен!', 'success');
setTimeout(() => {
_setSosState('default');
_setStatus('', '');
@@ -199,9 +262,11 @@ async function _handleSignal() {
} catch (err) {
_setSosState('default');
if (err && err.status === 401) {
- _setStatus('Session expired or key is invalid. Please re-register.', 'error');
+ _clearAuth();
+ _setStatus('Сессия истекла. Войдите заново.', 'error');
+ setTimeout(() => _showOnboarding(), 1500);
} else {
- _setStatus('Error sending. Try again.', 'error');
+ _setStatus('Ошибка отправки. Попробуйте ещё раз.', 'error');
}
}
}
@@ -210,17 +275,44 @@ async function _handleSignal() {
function _showOnboarding() {
_showScreen('screen-onboarding');
+ _showView('view-login');
- const input = document.getElementById('name-input');
- const btn = document.getElementById('btn-confirm');
+ const loginInput = document.getElementById('login-input');
+ const passInput = document.getElementById('login-password');
+ const btnLogin = document.getElementById('btn-login');
- input.addEventListener('input', () => {
- btn.disabled = input.value.trim().length === 0;
+ 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('keydown', (e) => {
- if (e.key === 'Enter' && !btn.disabled) _handleRegister();
- });
- btn.addEventListener('click', _handleRegister);
+ 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);
+ }
}
function _showMain() {
@@ -232,6 +324,20 @@ 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 ==========
@@ -243,16 +349,150 @@ 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();
- // 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
+ // Private mode graceful degradation (decision #1041)
if (_storageType !== 'local') {
const banner = document.getElementById('private-mode-banner');
if (banner) banner.hidden = false;
@@ -263,12 +503,17 @@ function _init() {
window.addEventListener('online', _updateNetworkIndicator);
window.addEventListener('offline', _updateNetworkIndicator);
- // Route to correct screen
+ // Route to correct screen based on JWT token presence
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 e5fe30e..0294a32 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -2,7 +2,7 @@
-
+
@@ -36,23 +36,80 @@
-
diff --git a/frontend/style.css b/frontend/style.css
index 487a443..e07e53a 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;
- min-height: 100vh;
- /* Use dynamic viewport height on mobile to account for browser chrome */
- min-height: 100dvh;
+ height: 100vh;
+ height: 100dvh;
}
/* ===== Private mode banner (decision #1041) ===== */
@@ -59,6 +59,7 @@ body {
justify-content: space-between;
align-items: center;
padding: 16px 20px;
+ padding-top: calc(env(safe-area-inset-top, 0px) + 16px);
flex-shrink: 0;
}
@@ -148,10 +149,8 @@ body {
/* ===== SOS button (min 60vmin × 60vmin per UX spec) ===== */
.btn-sos {
- width: 60vmin;
- height: 60vmin;
- min-width: 180px;
- min-height: 180px;
+ width: min(60vmin, 70vw, 300px);
+ height: min(60vmin, 70vw, 300px);
border-radius: 50%;
border: none;
background: var(--sos);
@@ -198,3 +197,44 @@ 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 e37d2fa..79d89da 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -1,6 +1,6 @@
'use strict';
-const CACHE_NAME = 'baton-v1';
+const CACHE_NAME = 'baton-v4';
// App shell assets to precache
const APP_SHELL = [
diff --git a/nginx/docker.conf b/nginx/docker.conf
new file mode 100644
index 0000000..54df415
--- /dev/null
+++ b/nginx/docker.conf
@@ -0,0 +1,61 @@
+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 c992449..9876432 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -4,3 +4,5 @@ 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 0801e32..7f47b12 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -22,6 +22,7 @@ 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
@@ -69,17 +70,25 @@ def temp_db():
# ── 5. App client factory ────────────────────────────────────────────────────
-def make_app_client():
+def make_app_client(capture_send_requests: list | None = None):
"""
Async context manager that:
1. Assigns a fresh temp-file DB path
- 2. Mocks Telegram setWebhook and sendMessage
+ 2. Mocks Telegram setWebhook, sendMessage, answerCallbackQuery, editMessageText
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():
@@ -93,7 +102,22 @@ def make_app_client():
mock_router.post(tg_set_url).mock(
return_value=httpx.Response(200, json={"ok": True, "result": True})
)
- mock_router.post(send_url).mock(
+ 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(
return_value=httpx.Response(200, json={"ok": True})
)
diff --git a/tests/test_arch_002.py b/tests/test_arch_002.py
index c979b1d..0b5c681 100644
--- a/tests/test_arch_002.py
+++ b/tests/test_arch_002.py
@@ -119,7 +119,7 @@ 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")
with patch("backend.telegram.send_message", new_callable=AsyncMock) as mock_send:
@@ -129,7 +129,7 @@ async def test_signal_message_without_geo_contains_bez_geolocatsii():
headers={"Authorization": f"Bearer {api_key}"},
)
text = mock_send.call_args[0][0]
- assert "Без геолокации" in text
+ assert "Гео нету" in text
@pytest.mark.asyncio
@@ -168,25 +168,17 @@ async def test_signal_message_contains_utc_marker():
# ---------------------------------------------------------------------------
-# Criterion 3 — SignalAggregator preserved with '# v2.0 feature' marker (static)
+# Criterion 3 — SignalAggregator removed (BATON-BIZ-004: dead code cleanup)
# ---------------------------------------------------------------------------
-def test_signal_aggregator_class_preserved_in_telegram():
- """SignalAggregator class must still exist in telegram.py (ADR-004, reserved for v2)."""
+def test_signal_aggregator_class_removed_from_telegram():
+ """SignalAggregator must be removed from telegram.py (BATON-BIZ-004)."""
source = (_BACKEND_DIR / "telegram.py").read_text()
- assert "class SignalAggregator" in source
+ assert "class 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}"
- )
+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
diff --git a/tests/test_arch_009.py b/tests/test_arch_009.py
index 9457374..01ba1c4 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_name_input() -> None:
- """index.html must have name input field for onboarding."""
- assert 'id="name-input"' in _html()
+def test_html_has_login_input() -> None:
+ """index.html must have login input field for onboarding."""
+ assert 'id="login-input"' in _html()
# ---------------------------------------------------------------------------
@@ -316,31 +316,19 @@ def _app_js() -> str:
return (FRONTEND / "app.js").read_text(encoding="utf-8")
-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_login() -> None:
+ """app.js must send POST to /api/auth/login during login."""
+ assert "/api/auth/login" 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_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_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()
+def test_app_stores_auth_token() -> None:
+ """app.js must persist JWT token to storage."""
+ assert "baton_auth_token" in _app_js()
assert "setItem" in _app_js()
@@ -434,16 +422,14 @@ def test_app_posts_to_api_signal() -> None:
assert "/api/signal" in _app_js()
-def test_app_signal_sends_user_id() -> None:
- """app.js must include user_id (UUID) in the /api/signal request body."""
+def test_app_signal_sends_auth_header() -> None:
+ """app.js must include Authorization Bearer header in /api/signal request."""
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"user_id.*?_apiPost\(['\"]\/api\/signal", app, re.DOTALL
+ r"_apiPost\(['\"]\/api\/signal['\"].*Authorization.*Bearer", app, re.DOTALL
)
assert signal_area, \
- "user_id must be set in the request body before calling _apiPost('/api/signal')"
+ "Authorization Bearer header must be set in _apiPost('/api/signal') call"
def test_app_sos_button_click_calls_handle_signal() -> None:
@@ -456,15 +442,15 @@ def test_app_sos_button_click_calls_handle_signal() -> None:
"btn-sos must be connected to _handleSignal"
-def test_app_signal_uses_uuid_from_storage() -> None:
- """app.js must retrieve UUID from storage (_getOrCreateUserId) before sending signal."""
+def test_app_signal_uses_token_from_storage() -> None:
+ """app.js must retrieve auth token from storage 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 "_getOrCreateUserId" in handle_signal.group(0), \
- "_handleSignal must call _getOrCreateUserId() to get UUID"
+ assert "_getAuthToken" in handle_signal.group(0), \
+ "_handleSignal must call _getAuthToken() to get JWT token"
# ---------------------------------------------------------------------------
diff --git a/tests/test_baton_005.py b/tests/test_baton_005.py
index 1504d21..117e95d 100644
--- a/tests/test_baton_005.py
+++ b/tests/test_baton_005.py
@@ -42,6 +42,19 @@ _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
@@ -70,7 +83,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": "unauth-uuid-001", "name": "Ghost"},
+ json={"uuid": _UUID_ADM_UNAUTH, "name": "Ghost"},
)
assert resp.status_code == 401
@@ -116,12 +129,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": "create-uuid-001", "name": "Alice Admin"},
+ json={"uuid": _UUID_ADM_CREATE_1, "name": "Alice Admin"},
headers=ADMIN_HEADERS,
)
assert resp.status_code == 201
data = resp.json()
- assert data["uuid"] == "create-uuid-001"
+ assert data["uuid"] == _UUID_ADM_CREATE_1
assert data["name"] == "Alice Admin"
assert data["id"] > 0
assert data["is_blocked"] is False
@@ -133,7 +146,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": "create-uuid-002", "name": "Bob Admin"},
+ json={"uuid": _UUID_ADM_CREATE_2, "name": "Bob Admin"},
headers=ADMIN_HEADERS,
)
resp = await client.get("/admin/users", headers=ADMIN_HEADERS)
@@ -141,7 +154,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 "create-uuid-002" in uuids
+ assert _UUID_ADM_CREATE_2 in uuids
@pytest.mark.asyncio
@@ -150,12 +163,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": "create-uuid-003", "name": "Carol"},
+ json={"uuid": _UUID_ADM_CREATE_3, "name": "Carol"},
headers=ADMIN_HEADERS,
)
resp = await client.post(
"/admin/users",
- json={"uuid": "create-uuid-003", "name": "Carol Duplicate"},
+ json={"uuid": _UUID_ADM_CREATE_3, "name": "Carol Duplicate"},
headers=ADMIN_HEADERS,
)
assert resp.status_code == 409
@@ -181,7 +194,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": "pass-uuid-001", "name": "PassUser"},
+ json={"uuid": _UUID_ADM_PASS_1, "name": "PassUser"},
headers=ADMIN_HEADERS,
)
user_id = create_resp.json()["id"]
@@ -213,7 +226,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": "pass-uuid-002", "name": "PassUser2"},
+ json={"uuid": _UUID_ADM_PASS_2, "name": "PassUser2"},
headers=ADMIN_HEADERS,
)
user_id = create_resp.json()["id"]
@@ -227,7 +240,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 "pass-uuid-002" in uuids
+ assert _UUID_ADM_PASS_2 in uuids
# ---------------------------------------------------------------------------
@@ -241,7 +254,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": "block-uuid-001", "name": "BlockUser"},
+ json={"uuid": _UUID_ADM_BLOCK, "name": "BlockUser"},
headers=ADMIN_HEADERS,
)
user_id = create_resp.json()["id"]
@@ -312,7 +325,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": "unblock-uuid-001", "name": "UnblockUser"},
+ json={"uuid": _UUID_ADM_UNBLOCK, "name": "UnblockUser"},
headers=ADMIN_HEADERS,
)
user_id = create_resp.json()["id"]
@@ -385,7 +398,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": "delete-uuid-001", "name": "DeleteUser"},
+ json={"uuid": _UUID_ADM_DELETE_1, "name": "DeleteUser"},
headers=ADMIN_HEADERS,
)
user_id = create_resp.json()["id"]
@@ -403,7 +416,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": "delete-uuid-002", "name": "DeleteUser2"},
+ json={"uuid": _UUID_ADM_DELETE_2, "name": "DeleteUser2"},
headers=ADMIN_HEADERS,
)
user_id = create_resp.json()["id"]
@@ -416,7 +429,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 "delete-uuid-002" not in uuids
+ assert _UUID_ADM_DELETE_2 not in uuids
@pytest.mark.asyncio
@@ -480,7 +493,7 @@ async def test_register_not_broken_after_admin_operations() -> None:
# Admin операции
await client.post(
"/admin/users",
- json={"uuid": "regress-admin-uuid-001", "name": "AdminCreated"},
+ json={"uuid": _UUID_ADM_REGRESS, "name": "AdminCreated"},
headers=ADMIN_HEADERS,
)
diff --git a/tests/test_baton_007.py b/tests/test_baton_007.py
index 0030c7d..2be7818 100644
--- a/tests/test_baton_007.py
+++ b/tests/test_baton_007.py
@@ -15,6 +15,7 @@ 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")
@@ -30,9 +31,9 @@ from unittest.mock import AsyncMock, patch
import httpx
import pytest
import respx
-from httpx import AsyncClient
+from httpx import AsyncClient, ASGITransport
-from tests.conftest import make_app_client
+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"
@@ -40,6 +41,7 @@ _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:
@@ -138,7 +140,7 @@ async def test_signal_with_geo_send_message_contains_coordinates():
@pytest.mark.asyncio
async def test_signal_without_geo_send_message_contains_no_geo_label():
- """Criterion 1: when geo is null, Telegram message contains 'Без геолокации'."""
+ """Criterion 1: when geo is null, Telegram message contains 'Гео нету'."""
sent_texts: list[str] = []
async def _capture(text: str) -> None:
@@ -156,8 +158,8 @@ async def test_signal_without_geo_send_message_contains_no_geo_label():
await asyncio.sleep(0)
assert len(sent_texts) == 1
- assert "Без геолокации" in sent_texts[0], (
- f"Expected 'Без геолокации' in message, got: {sent_texts[0]!r}"
+ assert "Гео нету" in sent_texts[0], (
+ f"Expected 'Гео нету' in message, got: {sent_texts[0]!r}"
)
@@ -260,3 +262,120 @@ async def test_repeated_signals_produce_incrementing_signal_ids():
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
new file mode 100644
index 0000000..cc597a6
--- /dev/null
+++ b/tests/test_baton_008.py
@@ -0,0 +1,888 @@
+"""
+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
new file mode 100644
index 0000000..5b8eeb2
--- /dev/null
+++ b/tests/test_baton_008_frontend.py
@@ -0,0 +1,439 @@
+"""
+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
new file mode 100644
index 0000000..b48d176
--- /dev/null
+++ b/tests/test_biz_001.py
@@ -0,0 +1,338 @@
+"""
+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
new file mode 100644
index 0000000..0136df7
--- /dev/null
+++ b/tests/test_biz_002.py
@@ -0,0 +1,203 @@
+"""
+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
new file mode 100644
index 0000000..228f2ac
--- /dev/null
+++ b/tests/test_biz_004.py
@@ -0,0 +1,96 @@
+"""
+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 e823fc4..93e87a1 100644
--- a/tests/test_db.py
+++ b/tests/test_db.py
@@ -29,6 +29,14 @@ 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."""
@@ -128,10 +136,10 @@ async def test_register_user_returns_id():
path = _tmpdb()
try:
await db.init_db()
- result = await db.register_user(uuid="uuid-001", name="Alice")
+ result = await db.register_user(uuid=_UUID_DB_1, name="Alice")
assert isinstance(result["user_id"], int)
assert result["user_id"] > 0
- assert result["uuid"] == "uuid-001"
+ assert result["uuid"] == _UUID_DB_1
finally:
_cleanup(path)
@@ -142,8 +150,8 @@ async def test_register_user_idempotent():
path = _tmpdb()
try:
await db.init_db()
- r1 = await db.register_user(uuid="uuid-002", name="Bob")
- r2 = await db.register_user(uuid="uuid-002", name="Bob")
+ r1 = await db.register_user(uuid=_UUID_DB_2, name="Bob")
+ r2 = await db.register_user(uuid=_UUID_DB_2, name="Bob")
assert r1["user_id"] == r2["user_id"]
finally:
_cleanup(path)
@@ -159,8 +167,8 @@ async def test_get_user_name_returns_name():
path = _tmpdb()
try:
await db.init_db()
- await db.register_user(uuid="uuid-003", name="Charlie")
- name = await db.get_user_name("uuid-003")
+ await db.register_user(uuid=_UUID_DB_3, name="Charlie")
+ name = await db.get_user_name(_UUID_DB_3)
assert name == "Charlie"
finally:
_cleanup(path)
@@ -188,9 +196,9 @@ async def test_save_signal_returns_id():
path = _tmpdb()
try:
await db.init_db()
- await db.register_user(uuid="uuid-004", name="Dana")
+ await db.register_user(uuid=_UUID_DB_4, name="Dana")
signal_id = await db.save_signal(
- user_uuid="uuid-004",
+ user_uuid=_UUID_DB_4,
timestamp=1742478000000,
lat=55.7558,
lon=37.6173,
@@ -208,9 +216,9 @@ async def test_save_signal_without_geo():
path = _tmpdb()
try:
await db.init_db()
- await db.register_user(uuid="uuid-005", name="Eve")
+ await db.register_user(uuid=_UUID_DB_5, name="Eve")
signal_id = await db.save_signal(
- user_uuid="uuid-005",
+ user_uuid=_UUID_DB_5,
timestamp=1742478000000,
lat=None,
lon=None,
@@ -239,9 +247,9 @@ async def test_save_signal_increments_id():
path = _tmpdb()
try:
await db.init_db()
- 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)
+ 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)
assert id2 > id1
finally:
_cleanup(path)
diff --git a/tests/test_fix_005.py b/tests/test_fix_005.py
new file mode 100644
index 0000000..4a0c25f
--- /dev/null
+++ b/tests/test_fix_005.py
@@ -0,0 +1,172 @@
+"""
+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
new file mode 100644
index 0000000..3779fe0
--- /dev/null
+++ b/tests/test_fix_007.py
@@ -0,0 +1,155 @@
+"""
+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
new file mode 100644
index 0000000..399e4aa
--- /dev/null
+++ b/tests/test_fix_009.py
@@ -0,0 +1,229 @@
+"""
+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
new file mode 100644
index 0000000..f4a3019
--- /dev/null
+++ b/tests/test_fix_011.py
@@ -0,0 +1,116 @@
+"""
+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
new file mode 100644
index 0000000..324091a
--- /dev/null
+++ b/tests/test_fix_012.py
@@ -0,0 +1,172 @@
+"""
+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
new file mode 100644
index 0000000..5445895
--- /dev/null
+++ b/tests/test_fix_013.py
@@ -0,0 +1,194 @@
+"""
+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
new file mode 100644
index 0000000..e4748ba
--- /dev/null
+++ b/tests/test_fix_016.py
@@ -0,0 +1,163 @@
+"""
+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 0e55586..cf9641a 100644
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -123,14 +123,16 @@ def test_signal_request_no_geo():
assert req.geo is None
-def test_signal_request_missing_user_id():
- with pytest.raises(ValidationError):
- SignalRequest(timestamp=1742478000000) # type: ignore[call-arg]
+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_empty_user_id():
- with pytest.raises(ValidationError):
- SignalRequest(user_id="", timestamp=1742478000000)
+ """Empty string user_id is accepted (treated as None at endpoint level)."""
+ req = SignalRequest(user_id="", timestamp=1742478000000)
+ assert req.user_id == ""
def test_signal_request_timestamp_zero():
diff --git a/tests/test_signal.py b/tests/test_signal.py
index 1ed0fc2..ca9d547 100644
--- a/tests/test_signal.py
+++ b/tests/test_signal.py
@@ -78,14 +78,14 @@ async def test_signal_without_geo_success():
@pytest.mark.asyncio
-async def test_signal_missing_user_id_returns_422():
- """Missing user_id field must return 422."""
+async def test_signal_missing_auth_returns_401():
+ """Missing Authorization header must return 401."""
async with make_app_client() as client:
resp = await client.post(
"/api/signal",
json={"timestamp": 1742478000000},
)
- assert resp.status_code == 422
+ assert resp.status_code == 401
@pytest.mark.asyncio
diff --git a/tests/test_telegram.py b/tests/test_telegram.py
index c55a6a0..bd46f51 100644
--- a/tests/test_telegram.py
+++ b/tests/test_telegram.py
@@ -1,5 +1,5 @@
"""
-Tests for backend/telegram.py: send_message, set_webhook, SignalAggregator.
+Tests for backend/telegram.py: send_message, set_webhook, validate_bot_token.
NOTE: respx routes must be registered INSIDE the 'with mock:' block to be
intercepted properly. Registering them before entering the context does not
@@ -25,8 +25,6 @@ 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
@@ -34,7 +32,7 @@ import pytest
import respx
from backend import config
-from backend.telegram import SignalAggregator, send_message, set_webhook, validate_bot_token
+from backend.telegram import send_message, set_webhook, validate_bot_token
SEND_URL = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage"
@@ -186,127 +184,6 @@ async def test_set_webhook_raises_on_non_200():
await set_webhook(url="https://example.com/webhook", secret="s")
-# ---------------------------------------------------------------------------
-# 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_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,
- )
-
- 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_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 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_aggregator_empty_buffer_no_send():
- """Flushing an empty aggregator must NOT call send_message."""
- agg = SignalAggregator(interval=9999)
-
- # 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_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,
- )
- 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)
-
-
# ---------------------------------------------------------------------------
# BATON-007: 400 "chat not found" handling
# ---------------------------------------------------------------------------
@@ -371,33 +248,3 @@ async def test_send_message_all_5xx_retries_exhausted_does_not_raise():
# Must not raise — message is dropped, service stays alive
await send_message("test all retries exhausted")
-
-@pytest.mark.asyncio
-async def test_aggregator_unknown_user_shows_uuid_prefix():
- """If user_name is None, the message shows first 8 chars of uuid."""
- 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)