Compare commits

...
Sign in to create a new pull request.

75 commits

Author SHA1 Message Date
Gros Frumos
cb89a90771 fix: viewport safe-area-inset for iOS PWA + disable pinch zoom
Topbar (avatar, network indicator) was hidden behind iOS status bar
in standalone PWA mode. Added safe-area-inset-top padding to topbar.
Disabled user-scalable to prevent accidental zoom.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 16:40:30 +02:00
Gros Frumos
6e2503dc3f fix: lock viewport height to prevent topbar scroll-off
Body fixed to 100dvh with overflow:hidden. SOS button capped at
min(60vmin, 70vw, 300px) to fit within viewport alongside topbar.
Bump SW cache to v3.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 16:25:21 +02:00
Gros Frumos
5da2a9a708 infra: add Docker setup for portable deployment
Dockerfile (Python 3.12 slim) + docker-compose (backend + nginx).
Backend on port 8000 inside container, nginx proxies API and serves
frontend static. SQLite persisted in named volume. Nginx listens on
127.0.0.1:8080 — external SSL handled by host reverse proxy.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 16:23:08 +02:00
Gros Frumos
6617c85cd5 fix: bump SW cache version to force app.js refresh
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 16:13:08 +02:00
Gros Frumos
268fb62bf3 feat: test signal via avatar/indicator tap on main screen
Tapping user avatar or network indicator sends a test signal with
geo data. Backend formats it as "Тест от username" (🧪) instead of
"Сигнал" (🚨). Only active after login on main screen.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 16:06:02 +02:00
Gros Frumos
0562cb4e47 sec: server-side email domain check + IP block on violations
Only @tutlot.com emails allowed for registration (checked server-side,
invisible to frontend inspect). Wrong domain → scary message + IP
violation tracked. 5 violations → IP permanently blocked from login
and registration. Block screen with OK button on frontend.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 15:58:16 +02:00
Gros Frumos
47b89ded8d feat: geo location as Google Maps link in Telegram notifications
When signal has geo, show clickable Google Maps link instead of raw
coordinates. Without geo, show "Гео нету". Added parse_mode=HTML
to send_message for link rendering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 14:21:41 +02:00
Gros Frumos
04f7bd79e2 auth: replace UUID-based login with JWT credential verification
Login now requires login/email + password verified against DB via
/api/auth/login. Only approved registrations can access the app.
Signal endpoint accepts JWT Bearer tokens alongside legacy api_key auth.
Old UUID-only registration flow removed from frontend.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 14:14:12 +02:00
Gros Frumos
1adcabf3a6 kin: BATON-008 На главной странице под логином сделать кнопку модулем регистрации - указать почту, логин и пароль, нажать зарегистрироваться. После этого сообщение о регистрации приходит в чат администратору 5694335584 и кнопка апрув или не апрув, если апрув то отправителя улетает пуш на pwa что он зарегистрирован, если отказ то ничего не происходит 2026-03-21 13:49:57 +02:00
Gros Frumos
baf05b6d84 kin: BATON-BIZ-004 Удалить дублирующую настройку логирования в telegram.py 2026-03-21 13:49:57 +02:00
Gros Frumos
6444b30d17 kin: BATON-BIZ-002 Убрать hardcoded VAPID key из meta-тега, читать с /api/push/public-key 2026-03-21 13:49:57 +02:00
Gros Frumos
ea06309a6e kin: BATON-BIZ-001-backend_dev 2026-03-21 13:49:57 +02:00
Gros Frumos
e266b6506e kin: BATON-BIZ-004-backend_dev 2026-03-21 13:49:57 +02:00
Gros Frumos
86a41a3b35 kin: BATON-BIZ-002-frontend_dev 2026-03-21 13:49:57 +02:00
Gros Frumos
40e1a9fa48 kin: BATON-008 На главной странице под логином сделать кнопку модулем регистрации - указать почту, логин и пароль, нажать зарегистрироваться. После этого сообщение о регистрации приходит в чат администратору 5694335584 и кнопка апрув или не апрув, если апрув то отправителя улетает пуш на pwa что он зарегистрирован, если отказ то ничего не происходит 2026-03-21 13:49:57 +02:00
Gros Frumos
8c4c46ee92 kin: BATON-FIX-016 [TECH DEBT] VAPID public key жёстко вшит как пустая строка в <meta>-тег — требует ручного заполнения при деплое 2026-03-21 13:49:57 +02:00
Gros Frumos
5fe9a603f8 kin: BATON-FIX-016-frontend_dev 2026-03-21 13:49:57 +02:00
Kin Agent
5fa3a35d27 fix: add ExecStartPre pip install to baton.service — prevents manual package installs
Fixes BATON-FIX-015: email-validator was installed manually as root because
deploy process had no pip install step. Added ExecStartPre to run
pip install -r requirements.txt on every service start/restart.
2026-03-21 09:17:06 +00:00
Gros Frumos
debd7895f4 kin: BATON-SEC-001 httpcore suppress in main.py 2026-03-21 10:56:55 +02:00
Gros Frumos
635991078c sec: suppress httpcore transport logger in main.py
Дублирует аналогичный fix в telegram.py — httpcore тоже логирует
URLs с BOT_TOKEN на transport уровне. Синхронизировано с ручным
патчем на сервере.

Refs: #1303, #1309

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 10:56:52 +02:00
Gros Frumos
a0dc6a7b22 kin: BATON-SEC-001 pre-commit hook + httpx logging hardening 2026-03-21 10:56:01 +02:00
Gros Frumos
dd556e2f05 sec: pre-commit hook + httpx exception logging hardening
1. .pre-commit-config.yaml — local pygrep hook блокирует коммиты
   с токенами формата \d{9,10}:AA[A-Za-z0-9_-]{35} (Telegram bot tokens).
   Проверено: срабатывает на токен, пропускает чистые файлы.

2. backend/telegram.py — три функции (send_registration_notification,
   answer_callback_query, edit_message_text) логировали exc напрямую,
   что раскрывало BOT_TOKEN в URL httpx-исключений в journalctl.
   Заменено на type(exc).__name__ — только тип ошибки, без URL.

Refs: #1303, #1309, #1283

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 10:55:34 +02:00
Gros Frumos
5401363ea9 kin: BATON-FIX-013 CORS allow_methods: добавить GET для /health эндпоинтов 2026-03-21 09:37:57 +02:00
Gros Frumos
c7661d7c1e Merge branch 'BATON-008-backend_dev' 2026-03-21 09:34:21 +02:00
Gros Frumos
fde7f57a7a kin: BATON-008-backend_dev 2026-03-21 09:34:21 +02:00
Gros Frumos
35eef641fd Merge branch 'BATON-FIX-013-backend_dev' 2026-03-21 09:34:18 +02:00
Gros Frumos
283ff61dc5 fix: sync allow_methods с main — добавить HEAD и OPTIONS 2026-03-21 09:33:53 +02:00
Gros Frumos
6d5d84a882 fix: CORS allow_methods добавить GET для /health эндпоинтов
CORSMiddleware: allow_methods=['POST'] → ['GET', 'POST']
Позволяет браузерам делать GET-запросы к /health и /api/health без CORS-блокировки.

BATON-FIX-013
2026-03-21 09:33:09 +02:00
Gros Frumos
257631436a Merge branch 'BATON-FIX-007-backend_dev' 2026-03-21 09:30:44 +02:00
Gros Frumos
b2fecc5993 kin: BATON-FIX-007-backend_dev 2026-03-21 09:30:44 +02:00
Gros Frumos
36087c3d9e kin: BATON-FIX-012 Починить 25 тестов регрессии от BATON-SEC-005 2026-03-21 09:29:27 +02:00
Gros Frumos
c838a775f7 kin: BATON-FIX-005 Ротировать Telegram bot token — утечка в journalctl логах 2026-03-21 09:27:37 +02:00
Gros Frumos
33844a02ac Merge branch 'BATON-FIX-012-debugger' 2026-03-21 09:26:57 +02:00
Gros Frumos
2f6a84f08b kin: BATON-FIX-012-debugger 2026-03-21 09:26:57 +02:00
Gros Frumos
370a2157b9 kin: BATON-FIX-008 [TECH DEBT] Серверный код (backend/main.py, middleware.py) расходится с worktree — у сервера нет rate_limit_signal в middleware, серверный main.py пропатчен вручную через sed 2026-03-21 09:25:08 +02:00
Gros Frumos
177a0d80dd Merge branch 'BATON-FIX-005-backend_dev' 2026-03-21 09:24:31 +02:00
Gros Frumos
85d156e9be fix(BATON-FIX-005): mask BOT_TOKEN in logs — suppress httpx URL logging
- Add logging.getLogger("httpx/httpcore").setLevel(WARNING) to prevent
  token-embedded API URLs from leaking through transport-level loggers
- Add _mask_token() helper showing only last 4 chars of token
- Fix validate_bot_token() exception handler: log exc type + masked token
  instead of raw exc which may contain the full URL in some httpx versions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 09:24:15 +02:00
Gros Frumos
2ab5e9ab54 kin: BATON-FIX-011 Скрыть BOT_TOKEN из httpx/journalctl логов 2026-03-21 09:21:25 +02:00
Gros Frumos
42f4251184 Merge branch 'BATON-008-backend_dev' 2026-03-21 09:19:50 +02:00
Gros Frumos
4c9fec17de kin: BATON-008-backend_dev 2026-03-21 09:19:50 +02:00
Gros Frumos
63be474cdc Merge branch 'BATON-FIX-011-backend_dev' 2026-03-21 09:19:29 +02:00
Gros Frumos
8896bc32f4 kin: BATON-FIX-011-backend_dev 2026-03-21 09:19:29 +02:00
Gros Frumos
e21bcb1eb4 kin: BATON-007 При нажатии на кнопку происходит анимация и сообщение что сигнал отправлен, но в телеграм группу ничего не приходит. 2026-03-21 09:05:43 +02:00
Gros Frumos
726bb0a82c Merge branch 'BATON-007-backend_dev' 2026-03-21 08:56:40 +02:00
Gros Frumos
a2b38ef815 fix(BATON-007): add validate_bot_token() for startup detection and fix test mocks
- Add validate_bot_token() to backend/telegram.py: calls getMe on startup,
  logs ERROR if token is invalid (never raises per #1215 contract)
- Call validate_bot_token() in lifespan() after db.init_db() for early detection
- Update conftest.py make_app_client() to mock getMe endpoint
- Add 3 tests for validate_bot_token (200, 401, network error cases)

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 08:54:07 +02:00
Gros Frumos
cbc15eeedc kin: BATON-007 При нажатии на кнопку происходит анимация и сообщение что сигнал отправлен, но в телеграм группу ничего не приходит. 2026-03-21 08:36:20 +02:00
Gros Frumos
6142770c0c kin: BATON-SEC-003 Добавить аутентификацию на /api/signal 2026-03-21 08:16:46 +02:00
Gros Frumos
4b37703335 Merge branch 'BATON-SEC-003-frontend_dev' 2026-03-21 08:13:14 +02:00
Gros Frumos
99638fe22b kin: BATON-SEC-003-frontend_dev 2026-03-21 08:13:14 +02:00
Gros Frumos
4916b292c5 kin: BATON-007 При нажатии на кнопку происходит анимация и сообщение что сигнал отправлен, но в телеграм группу ничего не приходит. 2026-03-21 08:12:49 +02:00
Gros Frumos
dbd1048a51 Merge branch 'BATON-SEC-003-backend_dev' 2026-03-21 08:12:01 +02:00
Gros Frumos
f17ee79edb kin: BATON-SEC-003-backend_dev 2026-03-21 08:12:01 +02:00
Gros Frumos
fd4f32c1c3 kin: BATON-FIX-001 Установить FRONTEND_ORIGIN=https://baton.itafrika.com в .env на проде 2026-03-21 07:59:50 +02:00
Gros Frumos
5c9176fcd9 nginx: добавить security-заголовки (HSTS, CSP, X-Frame-Options, X-Content-Type)
Заголовки повторены в location / из-за особенности nginx — дочерний блок
с add_header отменяет наследование родительского server-уровня.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 07:58:56 +02:00
Gros Frumos
1b2aa501c6 Merge branch 'BATON-SEC-006-backend_dev' 2026-03-21 07:56:44 +02:00
Gros Frumos
4b7e59d78d kin: BATON-SEC-006-backend_dev 2026-03-21 07:56:44 +02:00
Gros Frumos
097b7af949 kin: BATON-SEC-005 UUID-валидация в models.py для uuid и user_id 2026-03-21 07:43:25 +02:00
Gros Frumos
205cc8037c Merge branch 'BATON-SEC-007-backend_dev' 2026-03-21 07:39:41 +02:00
Gros Frumos
2cf141f6ed kin: BATON-SEC-007-backend_dev 2026-03-21 07:39:41 +02:00
Gros Frumos
7aae8c0f62 Merge branch 'BATON-SEC-005-backend_dev' 2026-03-21 07:36:36 +02:00
Gros Frumos
5d6695ecab kin: BATON-SEC-005-backend_dev 2026-03-21 07:36:36 +02:00
Gros Frumos
9b8a5558a3 Merge branch 'BATON-SEC-002-backend_dev' 2026-03-21 07:36:33 +02:00
Gros Frumos
4ab2f04de6 kin: BATON-SEC-002-backend_dev 2026-03-21 07:36:33 +02:00
Gros Frumos
9a450d2a84 fix: add /api/health alias endpoint
Adds GET /api/health as alias for /health — fixes frontend 404.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 07:18:56 +02:00
Gros Frumos
fd60863e9c kin: BATON-005 Сделать админку для заведения пользователей со сменой пароля, блокировкой и удалением пользователей. 2026-03-20 23:50:54 +02:00
Gros Frumos
989074673a Merge branch 'BATON-005-frontend_dev' 2026-03-20 23:44:58 +02:00
Gros Frumos
8607a9f981 kin: BATON-005-frontend_dev 2026-03-20 23:44:58 +02:00
Gros Frumos
e547e1ce09 Merge branch 'BATON-005-backend_dev' 2026-03-20 23:39:28 +02:00
Gros Frumos
cb95c9928f kin: BATON-005-backend_dev 2026-03-20 23:39:28 +02:00
Gros Frumos
5fcfc3a76b kin: BATON-006 не работает фронт: {'detail':'Not Found'} 2026-03-20 23:31:26 +02:00
Gros Frumos
68a1c90541 Merge branch 'BATON-006-frontend_dev' 2026-03-20 23:27:06 +02:00
Gros Frumos
3cd7db11e7 kin: BATON-006-frontend_dev 2026-03-20 23:27:06 +02:00
Gros Frumos
7db8b849e0 fix: исправить RuntimeError в aiosqlite — _get_conn как async context manager
`async with await _get_conn()` запускал тред дважды: первый раз внутри
`_get_conn` через `await aiosqlite.connect()`, второй раз в `__aenter__`
через `await self`. Преобразован в `@asynccontextmanager` с `yield` и
`finally: conn.close()`. Все вызывающие места обновлены. Тест
`test_init_db_synchronous` обновлён под новый API.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 23:16:12 +02:00
Gros Frumos
1c383191cc security: заменить реальный BOT_TOKEN на плейсхолдер в env.template
Добавить пример CHAT_ID в комментарий.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 22:40:53 +02:00
Gros Frumos
b70d5990c8 deploy: подготовить артефакты для деплоя на baton.itafrika.com
- nginx/baton.conf: заменить <YOUR_DOMAIN> на baton.itafrika.com
- deploy/baton.service: добавить systemd-юнит для uvicorn (/opt/baton, port 8000)
- deploy/baton-keepalive.service: прописать реальный URL health-эндпоинта
- deploy/env.template: шаблон .env для сервера (без секретов)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 22:32:05 +02:00
55 changed files with 8452 additions and 491 deletions

16
.dockerignore Normal file
View file

@ -0,0 +1,16 @@
.git
.gitignore
.env
.venv
venv
__pycache__
*.pyc
*.db
tests/
docs/
deploy/
frontend/
nginx/
*.md
.kin_worktrees/
PROGRESS.md

View file

@ -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=

11
.pre-commit-config.yaml Normal file
View file

@ -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$'

1
=2.0.0 Normal file
View file

@ -0,0 +1 @@
(eval):1: command not found: pip

12
Dockerfile Normal file
View file

@ -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"]

View file

@ -21,3 +21,10 @@ WEBHOOK_URL: str = _require("WEBHOOK_URL")
WEBHOOK_ENABLED: bool = os.getenv("WEBHOOK_ENABLED", "true").lower() == "true"
FRONTEND_ORIGIN: str = os.getenv("FRONTEND_ORIGIN", "http://localhost:3000")
APP_URL: str | None = os.getenv("APP_URL") # Публичный URL приложения для keep-alive self-ping
ADMIN_TOKEN: str = _require("ADMIN_TOKEN")
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

View file

@ -1,27 +1,36 @@
from __future__ import annotations
from typing import Optional
import time
from contextlib import asynccontextmanager
from typing import AsyncGenerator, Optional
import aiosqlite
from backend import config
async def _get_conn() -> aiosqlite.Connection:
@asynccontextmanager
async def _get_conn() -> AsyncGenerator[aiosqlite.Connection, None]:
conn = await aiosqlite.connect(config.DB_PATH)
await conn.execute("PRAGMA journal_mode=WAL")
await conn.execute("PRAGMA busy_timeout=5000")
await conn.execute("PRAGMA synchronous=NORMAL")
conn.row_factory = aiosqlite.Row
return conn
try:
yield conn
finally:
await conn.close()
async def init_db() -> None:
async with await _get_conn() as conn:
async with _get_conn() as conn:
await conn.executescript("""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
is_blocked INTEGER NOT NULL DEFAULT 0,
password_hash TEXT DEFAULT NULL,
api_key_hash TEXT DEFAULT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
@ -52,12 +61,63 @@ async def init_db() -> None:
ON signals(created_at);
CREATE INDEX IF NOT EXISTS idx_batches_status
ON telegram_batches(status);
CREATE TABLE IF NOT EXISTS rate_limits (
ip TEXT NOT NULL PRIMARY KEY,
count INTEGER NOT NULL DEFAULT 0,
window_start REAL NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS registrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
login TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
push_subscription TEXT DEFAULT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_registrations_status
ON registrations(status);
CREATE INDEX IF NOT EXISTS idx_registrations_email
ON registrations(email);
CREATE INDEX IF NOT EXISTS idx_registrations_login
ON registrations(login);
CREATE TABLE IF NOT EXISTS ip_blocks (
ip TEXT NOT NULL PRIMARY KEY,
violation_count INTEGER NOT NULL DEFAULT 0,
is_blocked INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
blocked_at TEXT DEFAULT NULL
);
""")
# Migrations for existing databases (silently ignore if columns already exist)
for stmt in [
"ALTER TABLE users ADD COLUMN is_blocked INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE users ADD COLUMN password_hash TEXT DEFAULT NULL",
"ALTER TABLE users ADD COLUMN api_key_hash TEXT DEFAULT NULL",
]:
try:
await conn.execute(stmt)
await conn.commit()
except Exception:
pass # Column already exists
await conn.commit()
async def register_user(uuid: str, name: str) -> dict:
async with await _get_conn() as conn:
async def register_user(uuid: str, name: str, api_key_hash: Optional[str] = None) -> dict:
async with _get_conn() as conn:
if api_key_hash is not None:
await conn.execute(
"""
INSERT INTO users (uuid, name, api_key_hash) VALUES (?, ?, ?)
ON CONFLICT(uuid) DO UPDATE SET api_key_hash = excluded.api_key_hash
""",
(uuid, name, api_key_hash),
)
else:
await conn.execute(
"INSERT OR IGNORE INTO users (uuid, name) VALUES (?, ?)",
(uuid, name),
@ -70,6 +130,15 @@ async def register_user(uuid: str, name: str) -> dict:
return {"user_id": row["id"], "uuid": row["uuid"]}
async def get_api_key_hash_by_uuid(uuid: str) -> Optional[str]:
async with _get_conn() as conn:
async with conn.execute(
"SELECT api_key_hash FROM users WHERE uuid = ?", (uuid,)
) as cur:
row = await cur.fetchone()
return row["api_key_hash"] if row else None
async def save_signal(
user_uuid: str,
timestamp: int,
@ -77,7 +146,7 @@ async def save_signal(
lon: Optional[float],
accuracy: Optional[float],
) -> int:
async with await _get_conn() as conn:
async with _get_conn() as conn:
async with conn.execute(
"""
INSERT INTO signals (user_uuid, timestamp, lat, lon, accuracy)
@ -91,7 +160,7 @@ async def save_signal(
async def get_user_name(uuid: str) -> Optional[str]:
async with await _get_conn() as conn:
async with _get_conn() as conn:
async with conn.execute(
"SELECT name FROM users WHERE uuid = ?", (uuid,)
) as cur:
@ -99,12 +168,228 @@ async def get_user_name(uuid: str) -> Optional[str]:
return row["name"] if row else None
async def is_user_blocked(uuid: str) -> bool:
async with _get_conn() as conn:
async with conn.execute(
"SELECT is_blocked FROM users WHERE uuid = ?", (uuid,)
) as cur:
row = await cur.fetchone()
return bool(row["is_blocked"]) if row else False
async def admin_list_users() -> list[dict]:
async with _get_conn() as conn:
async with conn.execute(
"SELECT id, uuid, name, is_blocked, created_at FROM users ORDER BY id"
) as cur:
rows = await cur.fetchall()
return [
{
"id": row["id"],
"uuid": row["uuid"],
"name": row["name"],
"is_blocked": bool(row["is_blocked"]),
"created_at": row["created_at"],
}
for row in rows
]
async def admin_get_user_by_id(user_id: int) -> Optional[dict]:
async with _get_conn() as conn:
async with conn.execute(
"SELECT id, uuid, name, is_blocked, created_at FROM users WHERE id = ?",
(user_id,),
) as cur:
row = await cur.fetchone()
if row is None:
return None
return {
"id": row["id"],
"uuid": row["uuid"],
"name": row["name"],
"is_blocked": bool(row["is_blocked"]),
"created_at": row["created_at"],
}
async def admin_create_user(
uuid: str, name: str, password_hash: Optional[str] = None
) -> Optional[dict]:
"""Returns None if UUID already exists."""
async with _get_conn() as conn:
try:
async with conn.execute(
"INSERT INTO users (uuid, name, password_hash) VALUES (?, ?, ?)",
(uuid, name, password_hash),
) as cur:
new_id = cur.lastrowid
except Exception:
return None # UNIQUE constraint violation — UUID already exists
await conn.commit()
async with conn.execute(
"SELECT id, uuid, name, is_blocked, created_at FROM users WHERE id = ?",
(new_id,),
) as cur:
row = await cur.fetchone()
return {
"id": row["id"],
"uuid": row["uuid"],
"name": row["name"],
"is_blocked": bool(row["is_blocked"]),
"created_at": row["created_at"],
}
async def admin_set_password(user_id: int, password_hash: str) -> bool:
async with _get_conn() as conn:
async with conn.execute(
"UPDATE users SET password_hash = ? WHERE id = ?",
(password_hash, user_id),
) as cur:
changed = cur.rowcount > 0
await conn.commit()
return changed
async def admin_set_blocked(user_id: int, is_blocked: bool) -> bool:
async with _get_conn() as conn:
async with conn.execute(
"UPDATE users SET is_blocked = ? WHERE id = ?",
(1 if is_blocked else 0, user_id),
) as cur:
changed = cur.rowcount > 0
await conn.commit()
return changed
async def admin_delete_user(user_id: int) -> bool:
async with _get_conn() as conn:
# Delete signals first (no FK cascade in SQLite by default)
async with conn.execute(
"DELETE FROM signals WHERE user_uuid = (SELECT uuid FROM users WHERE id = ?)",
(user_id,),
):
pass
async with conn.execute(
"DELETE FROM users WHERE id = ?",
(user_id,),
) as cur:
changed = cur.rowcount > 0
await conn.commit()
return changed
async def rate_limit_increment(key: str, window: float) -> int:
"""Increment rate-limit counter for key within window. Returns current count.
Cleans up the stale record for this key before incrementing (TTL by window_start).
"""
now = time.time()
async with _get_conn() as conn:
# TTL cleanup: remove stale record for this key if window has expired
await conn.execute(
"DELETE FROM rate_limits WHERE ip = ? AND ? - window_start >= ?",
(key, now, window),
)
# Upsert: insert new record or increment existing
await conn.execute(
"""
INSERT INTO rate_limits (ip, count, window_start)
VALUES (?, 1, ?)
ON CONFLICT(ip) DO UPDATE SET count = count + 1
""",
(key, now),
)
await conn.commit()
async with conn.execute(
"SELECT count FROM rate_limits WHERE ip = ?", (key,)
) as cur:
row = await cur.fetchone()
return row["count"] if row else 1
async def 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,
signal_ids: list[int],
) -> int:
async with await _get_conn() as conn:
async with _get_conn() as conn:
async with conn.execute(
"""
INSERT INTO telegram_batches (message_text, sent_at, signals_count, status)
@ -121,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

View file

@ -1,29 +1,82 @@
from __future__ import annotations
import asyncio
import hashlib
import hmac
import logging
import time
import os
import secrets
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from typing import Any
from typing import Any, Optional
import httpx
from fastapi import Depends, FastAPI, Request
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from backend import config, db, telegram
from backend.middleware import rate_limit_register, verify_webhook_secret
from backend 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,
SignalResponse,
)
_api_key_bearer = HTTPBearer(auto_error=False)
logging.basicConfig(level=logging.INFO)
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
logger = logging.getLogger(__name__)
def _hash_api_key(key: str) -> str:
"""SHA-256 хэш для API-ключа (без соли — для быстрого сравнения)."""
return hashlib.sha256(key.encode()).hexdigest()
def _hash_password(password: str) -> str:
"""Hash a password using PBKDF2-HMAC-SHA256 (stdlib, no external deps).
Stored format: ``<salt_hex>:<dk_hex>``
"""
salt = os.urandom(16)
dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 260_000)
return f"{salt.hex()}:{dk.hex()}"
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 минут
@ -45,10 +98,14 @@ async def _keep_alive_loop(app_url: str) -> None:
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup
app.state.rate_counters = {}
await db.init_db()
logger.info("Database initialized")
if not await telegram.validate_bot_token():
logger.error(
"CRITICAL: BOT_TOKEN is invalid — Telegram delivery is broken. Update .env and restart."
)
if config.WEBHOOK_ENABLED:
await telegram.set_webhook(url=config.WEBHOOK_URL, secret=config.WEBHOOK_SECRET)
logger.info("Webhook registered")
@ -90,61 +147,277 @@ app = FastAPI(lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=[config.FRONTEND_ORIGIN],
allow_methods=["POST"],
allow_headers=["Content-Type"],
allow_methods=["GET", "HEAD", "OPTIONS", "POST"],
allow_headers=["Content-Type", "Authorization"],
)
@app.get("/health")
@app.get("/api/health")
async def health() -> dict[str, Any]:
return {"status": "ok", "timestamp": int(time.time())}
return {"status": "ok"}
@app.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:
result = await db.register_user(uuid=body.uuid, name=body.name)
return RegisterResponse(user_id=result["user_id"], uuid=result["uuid"])
api_key = secrets.token_hex(32)
result = await db.register_user(uuid=body.uuid, name=body.name, api_key_hash=_hash_api_key(api_key))
return RegisterResponse(user_id=result["user_id"], uuid=result["uuid"], api_key=api_key)
@app.post("/api/signal", response_model=SignalResponse)
async def signal(body: SignalRequest) -> SignalResponse:
async def signal(
body: SignalRequest,
credentials: Optional[HTTPAuthorizationCredentials] = Depends(_api_key_bearer),
_: None = Depends(rate_limit_signal),
) -> SignalResponse:
if credentials is None:
raise HTTPException(status_code=401, detail="Unauthorized")
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
lon = geo.lon if geo else None
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"📍 <a href=\"https://maps.google.com/maps?q={lat},{lon}\">{lat}, {lon}</a>{accuracy:.0f}м)"
if geo
else "Без геолокации"
else "Гео нету"
)
if body.is_test:
text = (
f"🚨 Сигнал от {name}\n"
f"🧪 Тест от {user_name}\n"
f"{ts.strftime('%H:%M:%S')} UTC\n"
f"{geo_info}"
)
await telegram.send_message(text)
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()
@app.post("/admin/users", status_code=201, dependencies=[Depends(verify_admin_token)])
async def admin_create_user(body: AdminCreateUserRequest) -> dict:
password_hash = _hash_password(body.password) if body.password else None
result = await db.admin_create_user(body.uuid, body.name, password_hash)
if result is None:
raise HTTPException(status_code=409, detail="User with this UUID already exists")
return result
@app.put("/admin/users/{user_id}/password", dependencies=[Depends(verify_admin_token)])
async def admin_set_password(user_id: int, body: AdminSetPasswordRequest) -> dict:
changed = await db.admin_set_password(user_id, _hash_password(body.password))
if not changed:
raise HTTPException(status_code=404, detail="User not found")
return {"ok": True}
@app.put("/admin/users/{user_id}/block", dependencies=[Depends(verify_admin_token)])
async def admin_block_user(user_id: int, body: AdminBlockRequest) -> dict:
changed = await db.admin_set_blocked(user_id, body.is_blocked)
if not changed:
raise HTTPException(status_code=404, detail="User not found")
user = await db.admin_get_user_by_id(user_id)
return user # type: ignore[return-value]
@app.delete("/admin/users/{user_id}", status_code=204, dependencies=[Depends(verify_admin_token)])
async def admin_delete_user(user_id: int) -> None:
deleted = await db.admin_delete_user(user_id)
if not deleted:
raise HTTPException(status_code=404, detail="User not found")
@app.post("/api/webhook/telegram")
async def webhook_telegram(
request: Request,
_: 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", "")

View file

@ -1,15 +1,49 @@
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import secrets
import time
from typing import Optional
from fastapi import Header, HTTPException, Request
from fastapi import Depends, Header, HTTPException, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from backend import config
from backend import config, db
# 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
_RATE_WINDOW = 600 # 10 minutes
_SIGNAL_RATE_LIMIT = 10
_SIGNAL_RATE_WINDOW = 60 # 1 minute
_AUTH_REGISTER_RATE_LIMIT = 3
_AUTH_REGISTER_RATE_WINDOW = 600 # 10 minutes
def _get_client_ip(request: Request) -> str:
return (
request.headers.get("X-Real-IP")
or request.headers.get("X-Forwarded-For", "").split(",")[0].strip()
or (request.client.host if request.client else "unknown")
)
async def check_ip_not_blocked(request: Request) -> None:
ip = _get_client_ip(request)
if await db.is_ip_blocked(ip):
raise HTTPException(status_code=403, detail="Доступ запрещён")
async def verify_webhook_secret(
x_telegram_bot_api_secret_token: str = Header(default=""),
@ -20,15 +54,102 @@ async def verify_webhook_secret(
raise HTTPException(status_code=403, detail="Forbidden")
async def verify_admin_token(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(_bearer),
) -> None:
if credentials is None or not secrets.compare_digest(
credentials.credentials, config.ADMIN_TOKEN
):
raise HTTPException(status_code=401, detail="Unauthorized")
async def rate_limit_register(request: Request) -> None:
counters = request.app.state.rate_counters
client_ip = request.client.host if request.client else "unknown"
now = time.time()
count, window_start = counters.get(client_ip, (0, now))
if now - window_start >= _RATE_WINDOW:
count = 0
window_start = now
count += 1
counters[client_ip] = (count, window_start)
key = f"reg:{_get_client_ip(request)}"
count = await db.rate_limit_increment(key, _RATE_WINDOW)
if count > _RATE_LIMIT:
raise HTTPException(status_code=429, detail="Too Many Requests")
async def rate_limit_signal(request: Request) -> None:
key = f"sig:{_get_client_ip(request)}"
count = await db.rate_limit_increment(key, _SIGNAL_RATE_WINDOW)
if count > _SIGNAL_RATE_LIMIT:
raise HTTPException(status_code=429, detail="Too Many Requests")
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

View file

@ -1,17 +1,18 @@
from __future__ import annotations
from typing import Optional
from pydantic import BaseModel, Field
from pydantic import BaseModel, EmailStr, Field
class RegisterRequest(BaseModel):
uuid: str = Field(..., min_length=1)
uuid: str = Field(..., pattern=r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$')
name: str = Field(..., min_length=1, max_length=100)
class RegisterResponse(BaseModel):
user_id: int
uuid: str
api_key: str
class GeoData(BaseModel):
@ -21,11 +22,58 @@ class GeoData(BaseModel):
class SignalRequest(BaseModel):
user_id: str = Field(..., min_length=1)
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):
status: str
signal_id: int
class AdminCreateUserRequest(BaseModel):
uuid: str = Field(..., min_length=1)
name: str = Field(..., min_length=1, max_length=100)
password: Optional[str] = None
class AdminSetPasswordRequest(BaseModel):
password: str = Field(..., min_length=1)
class AdminBlockRequest(BaseModel):
is_blocked: bool
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

35
backend/push.py Normal file
View file

@ -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)

View file

@ -14,27 +14,123 @@ logger = logging.getLogger(__name__)
_TELEGRAM_API = "https://api.telegram.org/bot{token}/{method}"
def _mask_token(token: str) -> str:
"""Return a safe representation of the bot token for logging."""
if not token or len(token) < 4:
return "***REDACTED***"
return f"***{token[-4:]}"
async def validate_bot_token() -> bool:
"""Validate BOT_TOKEN by calling getMe. Logs ERROR if invalid. Never raises."""
url = _TELEGRAM_API.format(token=config.BOT_TOKEN, method="getMe")
async with httpx.AsyncClient(timeout=10) as client:
try:
resp = await client.get(url)
if resp.status_code == 200:
bot_name = resp.json().get("result", {}).get("username", "?")
logger.info("Telegram token valid, bot: @%s", bot_name)
return True
logger.error(
"BOT_TOKEN invalid — getMe returned %s: %s", resp.status_code, resp.text
)
return False
except Exception as exc:
# Do not log `exc` directly — it may contain the API URL with the token
# embedded (httpx includes request URL in some exception types/versions).
logger.error(
"BOT_TOKEN validation failed (network error): %s — token ends with %s",
type(exc).__name__,
_mask_token(config.BOT_TOKEN),
)
return False
async def send_message(text: str) -> None:
url = _TELEGRAM_API.format(token=config.BOT_TOKEN, method="sendMessage")
async with httpx.AsyncClient(timeout=10) as client:
while True:
resp = await client.post(url, json={"chat_id": config.CHAT_ID, "text": text})
for attempt in range(3):
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)
logger.warning("Telegram 429, sleeping %s sec", retry_after)
await asyncio.sleep(retry_after)
sleep = retry_after * (attempt + 1)
logger.warning("Telegram 429, sleeping %s sec (attempt %d)", sleep, attempt + 1)
await asyncio.sleep(sleep)
continue
if resp.status_code >= 500:
logger.error("Telegram 5xx: %s", resp.text)
await asyncio.sleep(30)
resp2 = await client.post(
url, json={"chat_id": config.CHAT_ID, "text": text}
)
if resp2.status_code != 200:
logger.error("Telegram retry failed: %s", resp2.text)
continue
elif resp.status_code != 200:
logger.error("Telegram error %s: %s", resp.status_code, resp.text)
break
else:
logger.error("Telegram send_message: all 3 attempts failed, message dropped")
async def 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:
@ -47,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

View file

@ -5,6 +5,6 @@ Description=Baton keep-alive ping
[Service]
Type=oneshot
# Замените URL на реальный адрес вашего приложения
ExecStart=curl -sf https://your-app.example.com/health
ExecStart=curl -sf https://baton.itafrika.com/health
StandardOutput=null
StandardError=journal

19
deploy/baton.service Normal file
View file

@ -0,0 +1,19 @@
[Unit]
Description=Baton — Telegram bot FastAPI backend
After=network.target
Wants=network-online.target
[Service]
Type=simple
User=www-data
WorkingDirectory=/opt/baton
EnvironmentFile=/opt/baton/.env
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
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

22
deploy/env.template Normal file
View file

@ -0,0 +1,22 @@
# /opt/baton/.env — заполнить перед деплоем
# ВНИМАНИЕ: этот файл НЕ для git, только шаблон для ручного создания на сервере
# Telegram Bot — получить токен через @BotFather
BOT_TOKEN=YOUR_BOT_TOKEN_HERE
# Chat ID для уведомлений (example: CHAT_ID=5190015988)
CHAT_ID=
# Webhook secret — случайная строка 32+ символа (сгенерировать: openssl rand -hex 32)
WEBHOOK_SECRET=
# Webhook URL
WEBHOOK_URL=https://baton.itafrika.com/api/webhook/telegram
WEBHOOK_ENABLED=true
FRONTEND_ORIGIN=https://baton.itafrika.com
APP_URL=https://baton.itafrika.com
DB_PATH=/opt/baton/baton.db
# Admin API token — случайная строка 32+ символа (сгенерировать: openssl rand -hex 32)
ADMIN_TOKEN=

23
docker-compose.yml Normal file
View file

@ -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:

300
docs/DESIGN_BATON008.md Normal file
View file

@ -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`: 330 символов, `[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`
Функция уже существует (строки 4148). 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 (строки 223242 в `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 от аутентификации.

379
frontend/admin.html Normal file
View file

@ -0,0 +1,379 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Baton — Admin</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #000000;
--bg2: #0d0d0d;
--text: #ffffff;
--muted: #9ca3af;
--input-bg: #1a1a1a;
--border: #374151;
--border-focus: #6b7280;
--btn-bg: #374151;
--btn-hover: #4b5563;
--danger: #991b1b;
--danger-hover: #7f1d1d;
--warn: #78350f;
--warn-hover: #92400e;
--success-bg: #14532d;
--blocked-row: #1c1008;
}
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
font-size: 14px;
min-height: 100vh;
}
/* ===== Token screen ===== */
#screen-token {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 24px;
}
.login-card {
width: 100%;
max-width: 360px;
background: var(--bg2);
border: 1px solid var(--border);
border-radius: 16px;
padding: 32px 28px;
display: flex;
flex-direction: column;
gap: 16px;
}
.login-title {
font-size: 22px;
font-weight: 700;
text-align: center;
}
.login-subtitle {
font-size: 13px;
color: var(--muted);
text-align: center;
margin-top: -8px;
}
/* ===== Panel screen ===== */
#screen-panel { display: none; flex-direction: column; min-height: 100vh; }
#screen-panel.active { display: flex; }
.panel-header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.panel-title {
font-size: 18px;
font-weight: 700;
flex: 1;
}
.panel-body {
flex: 1;
overflow: auto;
padding: 20px;
}
/* ===== Table ===== */
.users-wrap {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
min-width: 600px;
}
thead tr {
background: var(--bg2);
}
th {
text-align: left;
padding: 10px 12px;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
td {
padding: 10px 12px;
border-bottom: 1px solid #1f2937;
vertical-align: middle;
}
tr.is-blocked td { background: var(--blocked-row); }
tr:hover td { background: #111827; }
tr.is-blocked:hover td { background: #231508; }
.col-id { width: 50px; color: var(--muted); }
.col-uuid { max-width: 120px; font-family: monospace; font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.col-date { white-space: nowrap; color: var(--muted); font-size: 12px; }
.col-actions { white-space: nowrap; }
.empty-row td {
text-align: center;
color: var(--muted);
padding: 32px;
}
/* ===== Badges ===== */
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 9999px;
font-size: 12px;
font-weight: 500;
}
.badge--active { background: #14532d; color: #4ade80; }
.badge--blocked { background: #7f1d1d; color: #fca5a5; }
/* ===== Inputs ===== */
input[type="text"],
input[type="password"] {
width: 100%;
padding: 10px 14px;
background: var(--input-bg);
border: 1.5px solid var(--border);
border-radius: 10px;
color: var(--text);
font-size: 15px;
outline: none;
transition: border-color 0.15s;
-webkit-appearance: none;
}
input::placeholder { color: var(--muted); }
input:focus { border-color: var(--border-focus); }
/* ===== Buttons ===== */
.btn {
padding: 10px 18px;
background: var(--btn-bg);
border: none;
border-radius: 10px;
color: var(--text);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
white-space: nowrap;
}
.btn:hover:not(:disabled) { background: var(--btn-hover); }
.btn:disabled { opacity: 0.4; cursor: default; }
.btn--full { width: 100%; }
.btn--danger { background: var(--danger); }
.btn--danger:hover:not(:disabled) { background: var(--danger-hover); }
/* Small inline buttons */
.btn-sm {
padding: 4px 10px;
background: var(--btn-bg);
border: none;
border-radius: 6px;
color: var(--text);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
white-space: nowrap;
margin-right: 4px;
}
.btn-sm:last-child { margin-right: 0; }
.btn-sm:hover { background: var(--btn-hover); }
.btn-sm--danger { background: var(--danger); }
.btn-sm--danger:hover { background: var(--danger-hover); }
.btn-sm--warn { background: var(--warn); }
.btn-sm--warn:hover { background: var(--warn-hover); }
/* ===== Error / info messages ===== */
.msg-error {
color: #f87171;
font-size: 13px;
text-align: center;
}
.msg-info {
color: var(--muted);
font-size: 13px;
padding: 12px 0;
}
/* ===== Modals ===== */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.75);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
z-index: 100;
}
.modal-backdrop[hidden] { display: none; }
.modal-box {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: 14px;
padding: 28px 24px;
width: 100%;
max-width: 380px;
display: flex;
flex-direction: column;
gap: 14px;
}
.modal-title {
font-size: 16px;
font-weight: 700;
}
.modal-subtitle {
font-size: 13px;
color: var(--muted);
margin-top: -6px;
}
.modal-actions {
display: flex;
gap: 10px;
}
.modal-actions .btn { flex: 1; }
/* ===== Label ===== */
.field-label {
font-size: 12px;
color: var(--muted);
margin-bottom: 4px;
}
.field { display: flex; flex-direction: column; gap: 4px; }
</style>
</head>
<body>
<!-- ===== Token screen ===== -->
<div id="screen-token">
<div class="login-card">
<h1 class="login-title">Baton Admin</h1>
<p class="login-subtitle">Введите токен для доступа</p>
<input type="password" id="token-input" placeholder="Admin token" autocomplete="current-password">
<button type="button" id="btn-login" class="btn btn--full">Войти</button>
<p id="login-error" class="msg-error" hidden></p>
</div>
</div>
<!-- ===== Admin panel screen ===== -->
<div id="screen-panel">
<header class="panel-header">
<h1 class="panel-title">Пользователи</h1>
<button type="button" id="btn-create" class="btn">+ Создать</button>
<button type="button" id="btn-logout" class="btn">Выйти</button>
</header>
<div class="panel-body">
<p id="panel-error" class="msg-error" hidden></p>
<div class="users-wrap">
<table id="users-table">
<thead>
<tr>
<th class="col-id">#</th>
<th>Имя</th>
<th class="col-uuid">UUID</th>
<th>Статус</th>
<th class="col-date">Создан</th>
<th class="col-actions">Действия</th>
</tr>
</thead>
<tbody id="users-tbody">
<tr class="empty-row"><td colspan="6">Загрузка…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- ===== Modal: change password ===== -->
<div id="modal-password" class="modal-backdrop" hidden>
<div class="modal-box">
<h2 class="modal-title">Сменить пароль</h2>
<p id="modal-pw-subtitle" class="modal-subtitle"></p>
<input type="hidden" id="modal-pw-user-id">
<div class="field">
<div class="field-label">Новый пароль</div>
<input type="password" id="new-password" placeholder="Минимум 1 символ" autocomplete="new-password">
</div>
<p id="modal-pw-error" class="msg-error" hidden></p>
<div class="modal-actions">
<button type="button" id="btn-pw-cancel" class="btn">Отмена</button>
<button type="button" id="btn-pw-save" class="btn">Сохранить</button>
</div>
</div>
</div>
<!-- ===== Modal: create user ===== -->
<div id="modal-create" class="modal-backdrop" hidden>
<div class="modal-box">
<h2 class="modal-title">Создать пользователя</h2>
<div class="field">
<div class="field-label">UUID</div>
<input type="text" id="create-uuid" autocomplete="off" spellcheck="false">
</div>
<div class="field">
<div class="field-label">Имя</div>
<input type="text" id="create-name" placeholder="Имя пользователя" autocomplete="off">
</div>
<div class="field">
<div class="field-label">Пароль (необязательно)</div>
<input type="password" id="create-password" placeholder="Оставьте пустым если не нужен" autocomplete="new-password">
</div>
<p id="create-error" class="msg-error" hidden></p>
<div class="modal-actions">
<button type="button" id="btn-create-cancel" class="btn">Отмена</button>
<button type="button" id="btn-create-submit" class="btn">Создать</button>
</div>
</div>
</div>
<script src="/admin.js"></script>
</body>
</html>

333
frontend/admin.js Normal file
View file

@ -0,0 +1,333 @@
'use strict';
// ========== Token (sessionStorage — cleared on browser close) ==========
function _getToken() {
return sessionStorage.getItem('baton_admin_token') || '';
}
function _saveToken(t) {
sessionStorage.setItem('baton_admin_token', t);
}
function _clearToken() {
sessionStorage.removeItem('baton_admin_token');
}
// ========== API wrapper ==========
async function _api(method, path, body) {
const opts = {
method,
headers: { 'Authorization': 'Bearer ' + _getToken() },
};
if (body !== undefined) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body);
}
const res = await fetch(path, opts);
if (res.status === 204) return null;
const text = await res.text().catch(() => '');
if (!res.ok) {
let detail = text;
try { detail = JSON.parse(text).detail || text; } catch (_) {}
throw new Error('HTTP ' + res.status + (detail ? ': ' + detail : ''));
}
try { return JSON.parse(text); } catch (_) { return null; }
}
// ========== UI helpers ==========
function _esc(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function _setError(id, msg) {
const el = document.getElementById(id);
el.textContent = msg;
el.hidden = !msg;
}
function _showPanel() {
document.getElementById('screen-token').style.display = 'none';
document.getElementById('screen-panel').classList.add('active');
}
function _showTokenScreen() {
document.getElementById('screen-panel').classList.remove('active');
document.getElementById('screen-token').style.display = '';
document.getElementById('token-input').value = '';
}
// ========== Users table ==========
function _renderTable(users) {
const tbody = document.getElementById('users-tbody');
tbody.innerHTML = '';
if (!users.length) {
const tr = document.createElement('tr');
tr.className = 'empty-row';
tr.innerHTML = '<td colspan="6">Нет пользователей</td>';
tbody.appendChild(tr);
return;
}
users.forEach((u) => {
const tr = document.createElement('tr');
if (u.is_blocked) tr.classList.add('is-blocked');
const date = u.created_at ? u.created_at.slice(0, 16).replace('T', ' ') : '—';
const uuidShort = u.uuid ? u.uuid.slice(0, 8) + '…' : '—';
tr.innerHTML = `
<td class="col-id">${u.id}</td>
<td>${_esc(u.name)}</td>
<td class="col-uuid" title="${_esc(u.uuid)}">${_esc(uuidShort)}</td>
<td>
<span class="badge ${u.is_blocked ? 'badge--blocked' : 'badge--active'}">
${u.is_blocked ? 'Заблокирован' : 'Активен'}
</span>
</td>
<td class="col-date">${_esc(date)}</td>
<td class="col-actions">
<button class="btn-sm"
data-action="password"
data-id="${u.id}"
data-name="${_esc(u.name)}">Пароль</button>
<button class="btn-sm ${u.is_blocked ? 'btn-sm--warn' : ''}"
data-action="block"
data-id="${u.id}"
data-blocked="${u.is_blocked ? '1' : '0'}">
${u.is_blocked ? 'Разблокировать' : 'Заблокировать'}
</button>
<button class="btn-sm btn-sm--danger"
data-action="delete"
data-id="${u.id}"
data-name="${_esc(u.name)}">Удалить</button>
</td>
`;
tbody.appendChild(tr);
});
}
// ========== Load users ==========
async function _loadUsers() {
_setError('panel-error', '');
try {
const users = await _api('GET', '/admin/users');
_renderTable(users);
} catch (err) {
_setError('panel-error', err.message);
}
}
// ========== Login / Logout ==========
async function _handleLogin() {
const input = document.getElementById('token-input');
const btn = document.getElementById('btn-login');
const token = input.value.trim();
if (!token) return;
btn.disabled = true;
_setError('login-error', '');
_saveToken(token);
try {
const users = await _api('GET', '/admin/users');
_renderTable(users);
_showPanel();
} catch (err) {
_clearToken();
const msg = err.message.includes('401') ? 'Неверный токен' : err.message;
_setError('login-error', msg);
btn.disabled = false;
}
}
function _handleLogout() {
_clearToken();
_showTokenScreen();
}
// ========== Table action dispatcher (event delegation) ==========
async function _handleTableClick(e) {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const { action, id, name, blocked } = btn.dataset;
if (action === 'password') {
_openPasswordModal(id, name);
} else if (action === 'block') {
await _toggleBlock(id, blocked === '1');
} else if (action === 'delete') {
await _handleDelete(id, name);
}
}
// ========== Block / Unblock ==========
async function _toggleBlock(userId, currentlyBlocked) {
_setError('panel-error', '');
try {
await _api('PUT', `/admin/users/${userId}/block`, { is_blocked: !currentlyBlocked });
await _loadUsers();
} catch (err) {
_setError('panel-error', err.message);
}
}
// ========== Delete ==========
async function _handleDelete(userId, userName) {
if (!confirm(`Удалить пользователя "${userName}"?\n\nБудут удалены все его сигналы. Действие нельзя отменить.`)) return;
_setError('panel-error', '');
try {
await _api('DELETE', `/admin/users/${userId}`);
await _loadUsers();
} catch (err) {
_setError('panel-error', err.message);
}
}
// ========== Password modal ==========
function _openPasswordModal(userId, userName) {
document.getElementById('modal-pw-subtitle').textContent = `Пользователь: ${userName}`;
document.getElementById('modal-pw-user-id').value = userId;
document.getElementById('new-password').value = '';
_setError('modal-pw-error', '');
document.getElementById('btn-pw-save').disabled = false;
document.getElementById('modal-password').hidden = false;
document.getElementById('new-password').focus();
}
function _closePasswordModal() {
document.getElementById('modal-password').hidden = true;
}
async function _handleSetPassword() {
const userId = document.getElementById('modal-pw-user-id').value;
const password = document.getElementById('new-password').value;
const btn = document.getElementById('btn-pw-save');
if (!password) {
_setError('modal-pw-error', 'Введите пароль');
return;
}
btn.disabled = true;
_setError('modal-pw-error', '');
try {
await _api('PUT', `/admin/users/${userId}/password`, { password });
_closePasswordModal();
} catch (err) {
_setError('modal-pw-error', err.message);
btn.disabled = false;
}
}
// ========== Create user modal ==========
function _openCreateModal() {
document.getElementById('create-uuid').value = crypto.randomUUID();
document.getElementById('create-name').value = '';
document.getElementById('create-password').value = '';
_setError('create-error', '');
document.getElementById('btn-create-submit').disabled = false;
document.getElementById('modal-create').hidden = false;
document.getElementById('create-name').focus();
}
function _closeCreateModal() {
document.getElementById('modal-create').hidden = true;
}
async function _handleCreateUser() {
const uuid = document.getElementById('create-uuid').value.trim();
const name = document.getElementById('create-name').value.trim();
const password = document.getElementById('create-password').value;
const btn = document.getElementById('btn-create-submit');
if (!uuid || !name) {
_setError('create-error', 'UUID и имя обязательны');
return;
}
btn.disabled = true;
_setError('create-error', '');
const body = { uuid, name };
if (password) body.password = password;
try {
await _api('POST', '/admin/users', body);
_closeCreateModal();
await _loadUsers();
} catch (err) {
const msg = err.message.includes('409') ? 'Пользователь с таким UUID уже существует' : err.message;
_setError('create-error', msg);
btn.disabled = false;
}
}
// ========== Init ==========
function _init() {
// Login screen
document.getElementById('btn-login').addEventListener('click', _handleLogin);
document.getElementById('token-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') _handleLogin();
});
// Panel
document.getElementById('btn-logout').addEventListener('click', _handleLogout);
document.getElementById('btn-create').addEventListener('click', _openCreateModal);
// Table (event delegation)
document.getElementById('users-table').addEventListener('click', _handleTableClick);
// Password modal
document.getElementById('btn-pw-cancel').addEventListener('click', _closePasswordModal);
document.getElementById('btn-pw-save').addEventListener('click', _handleSetPassword);
document.getElementById('new-password').addEventListener('keydown', (e) => {
if (e.key === 'Enter') _handleSetPassword();
});
document.getElementById('modal-password').addEventListener('click', (e) => {
if (e.target.id === 'modal-password') _closePasswordModal();
});
// Create modal
document.getElementById('btn-create-cancel').addEventListener('click', _closeCreateModal);
document.getElementById('btn-create-submit').addEventListener('click', _handleCreateUser);
document.getElementById('create-password').addEventListener('keydown', (e) => {
if (e.key === 'Enter') _handleCreateUser();
});
document.getElementById('modal-create').addEventListener('click', (e) => {
if (e.target.id === 'modal-create') _closeCreateModal();
});
// Auto-login if token is already saved in sessionStorage
if (_getToken()) {
_showPanel();
_loadUsers().catch(() => {
_clearToken();
_showTokenScreen();
});
}
}
document.addEventListener('DOMContentLoaded', _init);

View file

@ -39,26 +39,26 @@ function _initStorage() {
// ========== User identity ==========
function _getOrCreateUserId() {
let id = _storage.getItem('baton_user_id');
if (!id) {
id = crypto.randomUUID();
_storage.setItem('baton_user_id', id);
}
return id;
}
function _isRegistered() {
return _storage.getItem('baton_registered') === '1';
return !!_storage.getItem('baton_auth_token');
}
function _getUserName() {
return _storage.getItem('baton_user_name') || '';
return _storage.getItem('baton_login') || '';
}
function _saveRegistration(name) {
_storage.setItem('baton_user_name', name);
_storage.setItem('baton_registered', '1');
function _getAuthToken() {
return _storage.getItem('baton_auth_token') || '';
}
function _saveAuth(token, login) {
_storage.setItem('baton_auth_token', token);
_storage.setItem('baton_login', login);
}
function _clearAuth() {
_storage.removeItem('baton_auth_token');
_storage.removeItem('baton_login');
}
function _getInitials(name) {
@ -87,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;
@ -102,15 +125,17 @@ function _updateUserAvatar() {
// ========== API calls ==========
async function _apiPost(path, body) {
async function _apiPost(path, body, extraHeaders) {
const res = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', ...extraHeaders },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error('HTTP ' + res.status + (text ? ': ' + text : ''));
const err = new Error('HTTP ' + res.status + (text ? ': ' + text : ''));
err.status = res.status;
throw err;
}
return res.json();
}
@ -135,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();
await _apiPost('/api/register', { uuid, name });
_saveRegistration(name);
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;
}
}
@ -163,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;
}
@ -175,21 +248,26 @@ 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;
await _apiPost('/api/signal', body);
await _apiPost('/api/signal', body, { Authorization: 'Bearer ' + token });
_setSosState('success');
_setStatus('Signal sent!', 'success');
_setStatus('Сигнал отправлен!', 'success');
setTimeout(() => {
_setSosState('default');
_setStatus('', '');
}, 2000);
} catch (_) {
} catch (err) {
_setSosState('default');
_setStatus('Error sending. Try again.', 'error');
if (err && err.status === 401) {
_clearAuth();
_setStatus('Сессия истекла. Войдите заново.', 'error');
setTimeout(() => _showOnboarding(), 1500);
} else {
_setStatus('Ошибка отправки. Попробуйте ещё раз.', 'error');
}
}
}
@ -197,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();
btnLogin.addEventListener('click', _handleLogin);
const btnToRegister = document.getElementById('btn-switch-to-register');
if (btnToRegister) {
btnToRegister.addEventListener('click', () => {
_setRegStatus('', '');
_setLoginStatus('', '');
_showView('view-register');
});
btn.addEventListener('click', _handleRegister);
}
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() {
@ -219,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 ==========
@ -230,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 =
'<div class="screen-content">' +
'<p class="block-message">' + msg + '</p>' +
'<button type="button" class="btn-confirm" id="btn-block-ok">OK</button>' +
'</div>';
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;
@ -250,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', () => {

View file

@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<meta name="description" content="Emergency signal button">
<!-- PWA meta tags -->
@ -36,23 +36,80 @@
<!-- Onboarding screen: shown on first visit (no UUID registered yet) -->
<div id="screen-onboarding" class="screen" role="main" hidden>
<div class="screen-content">
<!-- View: login with credentials -->
<div class="screen-content" id="view-login">
<input
type="text"
id="name-input"
id="login-input"
class="name-input"
placeholder="Your name"
maxlength="100"
autocomplete="name"
placeholder="Логин или email"
maxlength="255"
autocomplete="username"
autocorrect="off"
autocapitalize="words"
autocapitalize="none"
spellcheck="false"
aria-label="Your name"
aria-label="Логин или email"
>
<button type="button" id="btn-confirm" class="btn-confirm" disabled>
Confirm
<input
type="password"
id="login-password"
class="name-input"
placeholder="Пароль"
autocomplete="current-password"
aria-label="Пароль"
>
<button type="button" id="btn-login" class="btn-confirm" disabled>
Войти
</button>
<div id="login-status" class="reg-status" hidden></div>
<button type="button" id="btn-switch-to-register" class="btn-link">
Зарегистрироваться
</button>
</div>
<!-- View: account registration -->
<div class="screen-content" id="view-register" hidden>
<input
type="email"
id="reg-email"
class="name-input"
placeholder="Email"
autocomplete="email"
autocorrect="off"
autocapitalize="none"
spellcheck="false"
aria-label="Email"
>
<input
type="text"
id="reg-login"
class="name-input"
placeholder="Логин"
maxlength="64"
autocomplete="username"
autocorrect="off"
autocapitalize="none"
spellcheck="false"
aria-label="Логин"
>
<input
type="password"
id="reg-password"
class="name-input"
placeholder="Пароль"
autocomplete="new-password"
aria-label="Пароль"
>
<button type="button" id="btn-register" class="btn-confirm">
Зарегистрироваться
</button>
<button type="button" id="btn-switch-to-login" class="btn-link">
← Назад
</button>
<div id="reg-status" class="reg-status" hidden></div>
</div>
</div>
<!-- Main screen: SOS button -->

View file

@ -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;
}

View file

@ -1,6 +1,6 @@
'use strict';
const CACHE_NAME = 'baton-v1';
const CACHE_NAME = 'baton-v4';
// App shell assets to precache
const APP_SHELL = [

View file

@ -31,7 +31,7 @@ log_format baton_secure '$remote_addr - $remote_user [$time_local] '
# ---------------------------------------------------------------------------
server {
listen 80;
server_name <YOUR_DOMAIN>;
server_name baton.itafrika.com;
return 301 https://$server_name$request_uri;
}
@ -41,10 +41,10 @@ server {
# ---------------------------------------------------------------------------
server {
listen 443 ssl;
server_name <YOUR_DOMAIN>;
server_name baton.itafrika.com;
ssl_certificate /etc/letsencrypt/live/<YOUR_DOMAIN>/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/<YOUR_DOMAIN>/privkey.pem;
ssl_certificate /etc/letsencrypt/live/baton.itafrika.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/baton.itafrika.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
@ -55,16 +55,63 @@ server {
# Заголовки X-Telegram-Bot-Api-Secret-Token НЕ логируются —
# они передаются только в proxy_pass и не попадают в access_log.
location / {
# API → FastAPI
location /api/ {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Таймауты для webhook-запросов от Telegram
proxy_read_timeout 30s;
proxy_send_timeout 30s;
proxy_connect_timeout 5s;
}
# Health → FastAPI
location /health {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Admin API → FastAPI (UI-страница /admin.html раздаётся статикой ниже)
location /admin/users {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 30s;
proxy_send_timeout 30s;
proxy_connect_timeout 5s;
}
# ---------------------------------------------------------------------------
# Security headers
# IMPORTANT: must be repeated in every location block that uses add_header,
# because nginx does not inherit parent add_header when child block defines its own.
# ---------------------------------------------------------------------------
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'" always;
# Статика фронтенда (SPA)
location / {
root /opt/baton/frontend;
try_files $uri /index.html;
expires 1h;
# Security headers repeated here because add_header in location blocks
# overrides parent-level add_header directives (nginx inheritance rule)
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'" always;
add_header Cache-Control "public" always;
}
}

61
nginx/docker.conf Normal file
View file

@ -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;
}
}

View file

@ -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

View file

@ -21,6 +21,8 @@ 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")
# ── 2. aiosqlite monkey-patch ────────────────────────────────────────────────
import aiosqlite
@ -68,16 +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():
@ -85,12 +96,30 @@ def make_app_client():
from backend.main import app
mock_router = respx.mock(assert_all_called=False)
mock_router.get(get_me_url).mock(
return_value=httpx.Response(200, json={"ok": True, "result": {"username": "testbot"}})
)
mock_router.post(tg_set_url).mock(
return_value=httpx.Response(200, json={"ok": True, "result": True})
)
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})
)
with mock_router:
async with app.router.lifespan_context(app):

View file

@ -5,6 +5,10 @@ Acceptance criteria:
1. No asyncio task for the aggregator is created at lifespan startup.
2. POST /api/signal calls telegram.send_message directly (no aggregator intermediary).
3. SignalAggregator class in telegram.py is preserved with '# v2.0 feature' marker.
UUID notes: all UUIDs satisfy the UUID v4 pattern.
BATON-SEC-003: POST /api/signal now requires Authorization: Bearer <api_key>.
Tests that send signals register first and use the returned api_key.
"""
from __future__ import annotations
@ -15,6 +19,7 @@ os.environ.setdefault("CHAT_ID", "-1001234567890")
os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret")
os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram")
os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000")
os.environ.setdefault("ADMIN_TOKEN", "test-admin-token")
from pathlib import Path
from unittest.mock import AsyncMock, patch
@ -25,6 +30,20 @@ from tests.conftest import make_app_client
_BACKEND_DIR = Path(__file__).parent.parent / "backend"
# Valid UUID v4 constants
_UUID_S1 = "a0100001-0000-4000-8000-000000000001"
_UUID_S2 = "a0100002-0000-4000-8000-000000000002"
_UUID_S3 = "a0100003-0000-4000-8000-000000000003"
_UUID_S4 = "a0100004-0000-4000-8000-000000000004"
_UUID_S5 = "a0100005-0000-4000-8000-000000000005"
async def _register(client, uuid: str, name: str) -> str:
"""Register user and return api_key."""
r = await client.post("/api/register", json={"uuid": uuid, "name": name})
assert r.status_code == 200
return r.json()["api_key"]
# ---------------------------------------------------------------------------
# Criterion 1 — No asyncio task for aggregator created at startup (static)
@ -72,11 +91,12 @@ def test_aggregator_instantiation_commented_out_in_main():
async def test_signal_calls_telegram_send_message_directly():
"""POST /api/signal must call telegram.send_message directly, not via aggregator (ADR-004)."""
async with make_app_client() as client:
await client.post("/api/register", json={"uuid": "adr-uuid-s1", "name": "Tester"})
api_key = await _register(client, _UUID_S1, "Tester")
with patch("backend.telegram.send_message", new_callable=AsyncMock) as mock_send:
resp = await client.post(
"/api/signal",
json={"user_id": "adr-uuid-s1", "timestamp": 1742478000000},
json={"user_id": _UUID_S1, "timestamp": 1742478000000},
headers={"Authorization": f"Bearer {api_key}"},
)
assert resp.status_code == 200
mock_send.assert_called_once()
@ -86,11 +106,12 @@ async def test_signal_calls_telegram_send_message_directly():
async def test_signal_message_contains_registered_username():
"""Message passed to send_message must include the registered user's name."""
async with make_app_client() as client:
await client.post("/api/register", json={"uuid": "adr-uuid-s2", "name": "Alice"})
api_key = await _register(client, _UUID_S2, "Alice")
with patch("backend.telegram.send_message", new_callable=AsyncMock) as mock_send:
await client.post(
"/api/signal",
json={"user_id": "adr-uuid-s2", "timestamp": 1742478000000},
json={"user_id": _UUID_S2, "timestamp": 1742478000000},
headers={"Authorization": f"Bearer {api_key}"},
)
text = mock_send.call_args[0][0]
assert "Alice" in text
@ -98,31 +119,33 @@ 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:
await client.post("/api/register", json={"uuid": "adr-uuid-s3", "name": "Bob"})
api_key = await _register(client, _UUID_S3, "Bob")
with patch("backend.telegram.send_message", new_callable=AsyncMock) as mock_send:
await client.post(
"/api/signal",
json={"user_id": "adr-uuid-s3", "timestamp": 1742478000000, "geo": None},
json={"user_id": _UUID_S3, "timestamp": 1742478000000, "geo": None},
headers={"Authorization": f"Bearer {api_key}"},
)
text = mock_send.call_args[0][0]
assert "Без геолокации" in text
assert "Гео нету" in text
@pytest.mark.asyncio
async def test_signal_message_with_geo_contains_coordinates():
"""When geo is provided, message must contain lat and lon values."""
async with make_app_client() as client:
await client.post("/api/register", json={"uuid": "adr-uuid-s4", "name": "Charlie"})
api_key = await _register(client, _UUID_S4, "Charlie")
with patch("backend.telegram.send_message", new_callable=AsyncMock) as mock_send:
await client.post(
"/api/signal",
json={
"user_id": "adr-uuid-s4",
"user_id": _UUID_S4,
"timestamp": 1742478000000,
"geo": {"lat": 55.7558, "lon": 37.6173, "accuracy": 15.0},
},
headers={"Authorization": f"Bearer {api_key}"},
)
text = mock_send.call_args[0][0]
assert "55.7558" in text
@ -133,49 +156,29 @@ async def test_signal_message_with_geo_contains_coordinates():
async def test_signal_message_contains_utc_marker():
"""Message passed to send_message must contain 'UTC' timestamp marker."""
async with make_app_client() as client:
await client.post("/api/register", json={"uuid": "adr-uuid-s5", "name": "Dave"})
api_key = await _register(client, _UUID_S5, "Dave")
with patch("backend.telegram.send_message", new_callable=AsyncMock) as mock_send:
await client.post(
"/api/signal",
json={"user_id": "adr-uuid-s5", "timestamp": 1742478000000},
json={"user_id": _UUID_S5, "timestamp": 1742478000000},
headers={"Authorization": f"Bearer {api_key}"},
)
text = mock_send.call_args[0][0]
assert "UTC" in text
@pytest.mark.asyncio
async def test_signal_unknown_user_message_uses_uuid_prefix():
"""When user is not registered, message uses first 8 chars of uuid as name."""
async with make_app_client() as client:
with patch("backend.telegram.send_message", new_callable=AsyncMock) as mock_send:
await client.post(
"/api/signal",
json={"user_id": "unknown-uuid-xyz", "timestamp": 1742478000000},
)
text = mock_send.call_args[0][0]
assert "unknown-" in text # "unknown-uuid-xyz"[:8] == "unknown-"
# ---------------------------------------------------------------------------
# Criterion 3 — SignalAggregator preserved with '# v2.0 feature' marker (static)
# 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

View file

@ -6,6 +6,9 @@ Acceptance criteria:
5 requests pass (200), 6th returns 429; counter resets after the 10-minute window.
2. Token comparison is timing-safe:
secrets.compare_digest is used in middleware.py (no == / != for token comparison).
UUID notes: RegisterRequest.uuid requires a valid UUID v4 pattern.
All UUID constants below satisfy this constraint.
"""
from __future__ import annotations
@ -20,6 +23,7 @@ os.environ.setdefault("CHAT_ID", "-1001234567890")
os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret")
os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram")
os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000")
os.environ.setdefault("ADMIN_TOKEN", "test-admin-token")
import pytest
from tests.conftest import make_app_client
@ -38,6 +42,24 @@ _SAMPLE_UPDATE = {
},
}
# Valid UUID v4 constants for rate-limit tests
# Pattern: [0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}
_UUIDS_OK = [
f"d0{i:06d}-0000-4000-8000-000000000001"
for i in range(10)
]
_UUIDS_BLK = [
f"d1{i:06d}-0000-4000-8000-000000000001"
for i in range(10)
]
_UUIDS_EXP = [
f"d2{i:06d}-0000-4000-8000-000000000001"
for i in range(10)
]
_UUID_BLK_999 = "d1000999-0000-4000-8000-000000000001"
_UUID_EXP_BLK = "d2000999-0000-4000-8000-000000000001"
_UUID_EXP_AFTER = "d2001000-0000-4000-8000-000000000001"
# ---------------------------------------------------------------------------
# Criterion 1 — Rate limiting: first 5 requests pass
@ -51,7 +73,7 @@ async def test_register_rate_limit_allows_five_requests():
for i in range(5):
resp = await client.post(
"/api/register",
json={"uuid": f"rl-ok-{i:03d}", "name": f"User{i}"},
json={"uuid": _UUIDS_OK[i], "name": f"User{i}"},
)
assert resp.status_code == 200, (
f"Request {i + 1}/5 unexpectedly returned {resp.status_code}"
@ -70,11 +92,11 @@ async def test_register_rate_limit_blocks_sixth_request():
for i in range(5):
await client.post(
"/api/register",
json={"uuid": f"rl-blk-{i:03d}", "name": f"User{i}"},
json={"uuid": _UUIDS_BLK[i], "name": f"User{i}"},
)
resp = await client.post(
"/api/register",
json={"uuid": "rl-blk-999", "name": "Attacker"},
json={"uuid": _UUID_BLK_999, "name": "Attacker"},
)
assert resp.status_code == 429
@ -94,13 +116,13 @@ async def test_register_rate_limit_resets_after_window_expires():
for i in range(5):
await client.post(
"/api/register",
json={"uuid": f"rl-exp-{i:03d}", "name": f"User{i}"},
json={"uuid": _UUIDS_EXP[i], "name": f"User{i}"},
)
# Verify the 6th is blocked before window expiry
blocked = await client.post(
"/api/register",
json={"uuid": "rl-exp-blk", "name": "Attacker"},
json={"uuid": _UUID_EXP_BLK, "name": "Attacker"},
)
assert blocked.status_code == 429, (
"Expected 429 after exhausting rate limit, got " + str(blocked.status_code)
@ -110,7 +132,7 @@ async def test_register_rate_limit_resets_after_window_expires():
with patch("time.time", return_value=base_time + 601):
resp_after = await client.post(
"/api/register",
json={"uuid": "rl-exp-after", "name": "Legit"},
json={"uuid": _UUID_EXP_AFTER, "name": "Legit"},
)
assert resp_after.status_code == 200, (

View file

@ -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"
# ---------------------------------------------------------------------------

View file

@ -54,14 +54,14 @@ async def test_health_returns_status_ok():
@pytest.mark.asyncio
async def test_health_returns_timestamp():
"""GET /health должен вернуть поле timestamp в JSON."""
async def test_health_no_timestamp():
"""GET /health не должен возвращать поле timestamp (устраняет time-based fingerprinting)."""
async with make_app_client() as client:
response = await client.get("/health")
data = response.json()
assert "timestamp" in data
assert isinstance(data["timestamp"], int)
assert "timestamp" not in data
assert data == {"status": "ok"}
@pytest.mark.asyncio

527
tests/test_baton_005.py Normal file
View file

@ -0,0 +1,527 @@
"""
Tests for BATON-005: Admin panel user creation, password change, block/unblock, delete.
Acceptance criteria:
1. Создание пользователя пользователь появляется в БД (GET /admin/users)
2. Смена пароля endpoint возвращает ok, 404 для несуществующего пользователя
3. Блокировка заблокированный пользователь не может отправить сигнал (403)
4. Разблокировка восстанавливает доступ (сигнал снова проходит)
5. Удаление пользователь исчезает из GET /admin/users, возвращается 204
6. Защита: неавторизованный запрос к /admin/* возвращает 401
7. Отсутствие регрессии с основным функционалом
BATON-SEC-003: POST /api/signal now requires Authorization: Bearer <api_key>.
Tests 3 and 4 (block/unblock + signal) use /api/register to obtain an api_key,
then admin block/unblock the user by their DB id.
"""
from __future__ import annotations
import os
import re
from pathlib import Path
os.environ.setdefault("BOT_TOKEN", "test-bot-token")
os.environ.setdefault("CHAT_ID", "-1001234567890")
os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret")
os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram")
os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000")
os.environ.setdefault("ADMIN_TOKEN", "test-admin-token")
import pytest
from tests.conftest import make_app_client
PROJECT_ROOT = Path(__file__).parent.parent
NGINX_CONF = PROJECT_ROOT / "nginx" / "baton.conf"
ADMIN_HEADERS = {"Authorization": "Bearer test-admin-token"}
WRONG_HEADERS = {"Authorization": "Bearer wrong-token"}
# Valid UUID v4 for signal-related tests (registered via /api/register)
_UUID_BLOCK = "f0000001-0000-4000-8000-000000000001"
_UUID_UNBLOCK = "f0000002-0000-4000-8000-000000000002"
_UUID_SIG_OK = "f0000003-0000-4000-8000-000000000003"
# 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
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_admin_list_users_without_token_returns_401() -> None:
"""GET /admin/users без Authorization header должен вернуть 401."""
async with make_app_client() as client:
resp = await client.get("/admin/users")
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_admin_list_users_wrong_token_returns_401() -> None:
"""GET /admin/users с неверным токеном должен вернуть 401."""
async with make_app_client() as client:
resp = await client.get("/admin/users", headers=WRONG_HEADERS)
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_admin_create_user_without_token_returns_401() -> None:
"""POST /admin/users без токена должен вернуть 401."""
async with make_app_client() as client:
resp = await client.post(
"/admin/users",
json={"uuid": _UUID_ADM_UNAUTH, "name": "Ghost"},
)
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_admin_set_password_without_token_returns_401() -> None:
"""PUT /admin/users/1/password без токена должен вернуть 401."""
async with make_app_client() as client:
resp = await client.put(
"/admin/users/1/password",
json={"password": "newpass"},
)
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_admin_block_user_without_token_returns_401() -> None:
"""PUT /admin/users/1/block без токена должен вернуть 401."""
async with make_app_client() as client:
resp = await client.put(
"/admin/users/1/block",
json={"is_blocked": True},
)
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_admin_delete_user_without_token_returns_401() -> None:
"""DELETE /admin/users/1 без токена должен вернуть 401."""
async with make_app_client() as client:
resp = await client.delete("/admin/users/1")
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# Criterion 1 — Create user: appears in DB
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_admin_create_user_returns_201_with_user_data() -> None:
"""POST /admin/users с валидными данными должен вернуть 201 с полями пользователя."""
async with make_app_client() as client:
resp = await client.post(
"/admin/users",
json={"uuid": _UUID_ADM_CREATE_1, "name": "Alice Admin"},
headers=ADMIN_HEADERS,
)
assert resp.status_code == 201
data = resp.json()
assert data["uuid"] == _UUID_ADM_CREATE_1
assert data["name"] == "Alice Admin"
assert data["id"] > 0
assert data["is_blocked"] is False
@pytest.mark.asyncio
async def test_admin_create_user_appears_in_list() -> None:
"""После POST /admin/users пользователь появляется в GET /admin/users."""
async with make_app_client() as client:
await client.post(
"/admin/users",
json={"uuid": _UUID_ADM_CREATE_2, "name": "Bob Admin"},
headers=ADMIN_HEADERS,
)
resp = await client.get("/admin/users", headers=ADMIN_HEADERS)
assert resp.status_code == 200
users = resp.json()
uuids = [u["uuid"] for u in users]
assert _UUID_ADM_CREATE_2 in uuids
@pytest.mark.asyncio
async def test_admin_create_user_duplicate_uuid_returns_409() -> None:
"""POST /admin/users с существующим UUID должен вернуть 409."""
async with make_app_client() as client:
await client.post(
"/admin/users",
json={"uuid": _UUID_ADM_CREATE_3, "name": "Carol"},
headers=ADMIN_HEADERS,
)
resp = await client.post(
"/admin/users",
json={"uuid": _UUID_ADM_CREATE_3, "name": "Carol Duplicate"},
headers=ADMIN_HEADERS,
)
assert resp.status_code == 409
@pytest.mark.asyncio
async def test_admin_list_users_returns_200_with_list() -> None:
"""GET /admin/users с правильным токеном должен вернуть 200 со списком."""
async with make_app_client() as client:
resp = await client.get("/admin/users", headers=ADMIN_HEADERS)
assert resp.status_code == 200
assert isinstance(resp.json(), list)
# ---------------------------------------------------------------------------
# Criterion 2 — Password change
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_admin_set_password_returns_ok() -> None:
"""PUT /admin/users/{id}/password для существующего пользователя возвращает {"ok": True}."""
async with make_app_client() as client:
create_resp = await client.post(
"/admin/users",
json={"uuid": _UUID_ADM_PASS_1, "name": "PassUser"},
headers=ADMIN_HEADERS,
)
user_id = create_resp.json()["id"]
resp = await client.put(
f"/admin/users/{user_id}/password",
json={"password": "newpassword123"},
headers=ADMIN_HEADERS,
)
assert resp.status_code == 200
assert resp.json() == {"ok": True}
@pytest.mark.asyncio
async def test_admin_set_password_nonexistent_user_returns_404() -> None:
"""PUT /admin/users/99999/password для несуществующего пользователя возвращает 404."""
async with make_app_client() as client:
resp = await client.put(
"/admin/users/99999/password",
json={"password": "somepassword"},
headers=ADMIN_HEADERS,
)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_admin_set_password_user_still_accessible_after_change() -> None:
"""Пользователь остаётся доступен в GET /admin/users после смены пароля."""
async with make_app_client() as client:
create_resp = await client.post(
"/admin/users",
json={"uuid": _UUID_ADM_PASS_2, "name": "PassUser2"},
headers=ADMIN_HEADERS,
)
user_id = create_resp.json()["id"]
await client.put(
f"/admin/users/{user_id}/password",
json={"password": "updatedpass"},
headers=ADMIN_HEADERS,
)
list_resp = await client.get("/admin/users", headers=ADMIN_HEADERS)
uuids = [u["uuid"] for u in list_resp.json()]
assert _UUID_ADM_PASS_2 in uuids
# ---------------------------------------------------------------------------
# Criterion 3 — Block user: blocked user cannot send signal
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_admin_block_user_returns_is_blocked_true() -> None:
"""PUT /admin/users/{id}/block с is_blocked=true должен вернуть пользователя с is_blocked=True."""
async with make_app_client() as client:
create_resp = await client.post(
"/admin/users",
json={"uuid": _UUID_ADM_BLOCK, "name": "BlockUser"},
headers=ADMIN_HEADERS,
)
user_id = create_resp.json()["id"]
resp = await client.put(
f"/admin/users/{user_id}/block",
json={"is_blocked": True},
headers=ADMIN_HEADERS,
)
assert resp.status_code == 200
assert resp.json()["is_blocked"] is True
@pytest.mark.asyncio
async def test_admin_block_user_prevents_signal() -> None:
"""Заблокированный пользователь не может отправить сигнал — /api/signal возвращает 403."""
async with make_app_client() as client:
# Регистрируем через /api/register чтобы получить api_key
reg_resp = await client.post(
"/api/register",
json={"uuid": _UUID_BLOCK, "name": "BlockSignalUser"},
)
assert reg_resp.status_code == 200
api_key = reg_resp.json()["api_key"]
user_uuid = reg_resp.json()["uuid"]
# Находим ID пользователя
users_resp = await client.get("/admin/users", headers=ADMIN_HEADERS)
user = next(u for u in users_resp.json() if u["uuid"] == user_uuid)
user_id = user["id"]
# Блокируем
await client.put(
f"/admin/users/{user_id}/block",
json={"is_blocked": True},
headers=ADMIN_HEADERS,
)
# Заблокированный пользователь должен получить 403
signal_resp = await client.post(
"/api/signal",
json={"user_id": user_uuid, "timestamp": 1700000000000, "geo": None},
headers={"Authorization": f"Bearer {api_key}"},
)
assert signal_resp.status_code == 403
@pytest.mark.asyncio
async def test_admin_block_nonexistent_user_returns_404() -> None:
"""PUT /admin/users/99999/block для несуществующего пользователя возвращает 404."""
async with make_app_client() as client:
resp = await client.put(
"/admin/users/99999/block",
json={"is_blocked": True},
headers=ADMIN_HEADERS,
)
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# Criterion 4 — Unblock user: restores access
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_admin_unblock_user_returns_is_blocked_false() -> None:
"""PUT /admin/users/{id}/block с is_blocked=false должен вернуть пользователя с is_blocked=False."""
async with make_app_client() as client:
create_resp = await client.post(
"/admin/users",
json={"uuid": _UUID_ADM_UNBLOCK, "name": "UnblockUser"},
headers=ADMIN_HEADERS,
)
user_id = create_resp.json()["id"]
await client.put(
f"/admin/users/{user_id}/block",
json={"is_blocked": True},
headers=ADMIN_HEADERS,
)
resp = await client.put(
f"/admin/users/{user_id}/block",
json={"is_blocked": False},
headers=ADMIN_HEADERS,
)
assert resp.status_code == 200
assert resp.json()["is_blocked"] is False
@pytest.mark.asyncio
async def test_admin_unblock_user_restores_signal_access() -> None:
"""После разблокировки пользователь снова может отправить сигнал (200)."""
async with make_app_client() as client:
# Регистрируем через /api/register чтобы получить api_key
reg_resp = await client.post(
"/api/register",
json={"uuid": _UUID_UNBLOCK, "name": "UnblockSignalUser"},
)
assert reg_resp.status_code == 200
api_key = reg_resp.json()["api_key"]
user_uuid = reg_resp.json()["uuid"]
# Находим ID
users_resp = await client.get("/admin/users", headers=ADMIN_HEADERS)
user = next(u for u in users_resp.json() if u["uuid"] == user_uuid)
user_id = user["id"]
# Блокируем
await client.put(
f"/admin/users/{user_id}/block",
json={"is_blocked": True},
headers=ADMIN_HEADERS,
)
# Разблокируем
await client.put(
f"/admin/users/{user_id}/block",
json={"is_blocked": False},
headers=ADMIN_HEADERS,
)
# Сигнал должен пройти
signal_resp = await client.post(
"/api/signal",
json={"user_id": user_uuid, "timestamp": 1700000000000, "geo": None},
headers={"Authorization": f"Bearer {api_key}"},
)
assert signal_resp.status_code == 200
assert signal_resp.json()["status"] == "ok"
# ---------------------------------------------------------------------------
# Criterion 5 — Delete user: disappears from DB
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_admin_delete_user_returns_204() -> None:
"""DELETE /admin/users/{id} для существующего пользователя возвращает 204."""
async with make_app_client() as client:
create_resp = await client.post(
"/admin/users",
json={"uuid": _UUID_ADM_DELETE_1, "name": "DeleteUser"},
headers=ADMIN_HEADERS,
)
user_id = create_resp.json()["id"]
resp = await client.delete(
f"/admin/users/{user_id}",
headers=ADMIN_HEADERS,
)
assert resp.status_code == 204
@pytest.mark.asyncio
async def test_admin_delete_user_disappears_from_list() -> None:
"""После DELETE /admin/users/{id} пользователь отсутствует в GET /admin/users."""
async with make_app_client() as client:
create_resp = await client.post(
"/admin/users",
json={"uuid": _UUID_ADM_DELETE_2, "name": "DeleteUser2"},
headers=ADMIN_HEADERS,
)
user_id = create_resp.json()["id"]
await client.delete(
f"/admin/users/{user_id}",
headers=ADMIN_HEADERS,
)
list_resp = await client.get("/admin/users", headers=ADMIN_HEADERS)
uuids = [u["uuid"] for u in list_resp.json()]
assert _UUID_ADM_DELETE_2 not in uuids
@pytest.mark.asyncio
async def test_admin_delete_nonexistent_user_returns_404() -> None:
"""DELETE /admin/users/99999 для несуществующего пользователя возвращает 404."""
async with make_app_client() as client:
resp = await client.delete(
"/admin/users/99999",
headers=ADMIN_HEADERS,
)
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# nginx config — location /admin/users block (BATON-006 fix)
# ---------------------------------------------------------------------------
def test_nginx_conf_has_admin_users_location_block() -> None:
"""nginx/baton.conf должен содержать блок location /admin/users."""
content = NGINX_CONF.read_text(encoding="utf-8")
assert re.search(r"location\s+/admin/users\b", content), (
"nginx/baton.conf не содержит блок location /admin/users — "
"запросы к admin API будут попадать в location / и возвращать 404"
)
def test_nginx_conf_admin_users_location_proxies_to_fastapi() -> None:
"""Блок location /admin/users должен делать proxy_pass на 127.0.0.1:8000."""
content = NGINX_CONF.read_text(encoding="utf-8")
admin_block = re.search(
r"location\s+/admin/users\s*\{([^}]+)\}", content, re.DOTALL
)
assert admin_block is not None, "Блок location /admin/users { ... } не найден"
assert re.search(r"proxy_pass\s+http://127\.0\.0\.1:8000", admin_block.group(1)), (
"Блок location /admin/users не содержит proxy_pass http://127.0.0.1:8000"
)
def test_nginx_conf_admin_users_location_before_root_location() -> None:
"""location /admin/users должен находиться в nginx.conf до location / для корректного prefix-matching."""
content = NGINX_CONF.read_text(encoding="utf-8")
admin_pos = content.find("location /admin/users")
root_pos = re.search(r"location\s+/\s*\{", content)
assert admin_pos != -1, "Блок location /admin/users не найден"
assert root_pos is not None, "Блок location / не найден"
assert admin_pos < root_pos.start(), (
"location /admin/users должен быть определён ДО location / в nginx.conf"
)
# ---------------------------------------------------------------------------
# Criterion 7 — No regression with main functionality
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_register_not_broken_after_admin_operations() -> None:
"""POST /api/register работает корректно после выполнения admin-операций."""
async with make_app_client() as client:
# Admin операции
await client.post(
"/admin/users",
json={"uuid": _UUID_ADM_REGRESS, "name": "AdminCreated"},
headers=ADMIN_HEADERS,
)
# Основной функционал
resp = await client.post(
"/api/register",
json={"uuid": _UUID_SIG_OK, "name": "RegularUser"},
)
assert resp.status_code == 200
assert resp.json()["uuid"] == _UUID_SIG_OK
@pytest.mark.asyncio
async def test_signal_from_registered_unblocked_user_succeeds() -> None:
"""Зарегистрированный незаблокированный пользователь может отправить сигнал."""
async with make_app_client() as client:
reg_resp = await client.post(
"/api/register",
json={"uuid": _UUID_SIG_OK, "name": "SignalUser"},
)
assert reg_resp.status_code == 200
api_key = reg_resp.json()["api_key"]
user_uuid = reg_resp.json()["uuid"]
signal_resp = await client.post(
"/api/signal",
json={"user_id": user_uuid, "timestamp": 1700000000000, "geo": None},
headers={"Authorization": f"Bearer {api_key}"},
)
assert signal_resp.status_code == 200
assert signal_resp.json()["status"] == "ok"

244
tests/test_baton_006.py Normal file
View file

@ -0,0 +1,244 @@
"""
Tests for BATON-006: не работает фронт {"detail":"Not Found"}
Acceptance criteria:
1. nginx/baton.conf содержит location /api/ (prefix match), проксирует на FastAPI.
2. nginx/baton.conf содержит location /health, проксирует на FastAPI.
3. nginx/baton.conf содержит location / с root и try_files (SPA-поведение).
4. GET / на FastAPI возвращает 404 (маршрут / не зарегистрирован в main.py
статику должен отдавать nginx, а не FastAPI).
5. GET /health возвращает 200 (FastAPI-маршрут работает).
6. POST /api/register возвращает 200 (FastAPI-маршрут не сломан).
7. POST /api/signal возвращает 200 (FastAPI-маршрут не сломан).
8. POST /api/webhook/telegram возвращает 200 с корректным секретом.
BATON-SEC-003: POST /api/signal now requires Authorization: Bearer <api_key>.
UUID constants satisfy the UUID v4 pattern.
"""
from __future__ import annotations
import os
import re
from pathlib import Path
os.environ.setdefault("BOT_TOKEN", "test-bot-token")
os.environ.setdefault("CHAT_ID", "-1001234567890")
os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret")
os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram")
os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000")
os.environ.setdefault("ADMIN_TOKEN", "test-admin-token")
import pytest
from tests.conftest import make_app_client
PROJECT_ROOT = Path(__file__).parent.parent
NGINX_CONF = PROJECT_ROOT / "nginx" / "baton.conf"
# Valid UUID v4 constants
_UUID_REG = "e0000001-0000-4000-8000-000000000001"
_UUID_SIG = "e0000002-0000-4000-8000-000000000002"
# ---------------------------------------------------------------------------
# Criterion 1 — location /api/ proxies to FastAPI
# ---------------------------------------------------------------------------
def test_nginx_conf_exists() -> None:
"""nginx/baton.conf должен существовать в репозитории."""
assert NGINX_CONF.is_file(), f"nginx/baton.conf не найден: {NGINX_CONF}"
def test_nginx_conf_has_api_location_block() -> None:
"""nginx/baton.conf должен содержать location /api/ (prefix match)."""
content = NGINX_CONF.read_text(encoding="utf-8")
assert re.search(r"location\s+/api/", content), (
"nginx/baton.conf не содержит блок location /api/"
)
def test_nginx_conf_api_location_proxies_to_fastapi() -> None:
"""Блок location /api/ должен делать proxy_pass на 127.0.0.1:8000."""
content = NGINX_CONF.read_text(encoding="utf-8")
api_block = re.search(
r"location\s+/api/\s*\{([^}]+)\}", content, re.DOTALL
)
assert api_block is not None, "Блок location /api/ { ... } не найден"
assert re.search(r"proxy_pass\s+http://127\.0\.0\.1:8000", api_block.group(1)), (
"Блок location /api/ не содержит proxy_pass http://127.0.0.1:8000"
)
# ---------------------------------------------------------------------------
# Criterion 2 — location /health proxies to FastAPI
# ---------------------------------------------------------------------------
def test_nginx_conf_has_health_location_block() -> None:
"""nginx/baton.conf должен содержать отдельный location /health."""
content = NGINX_CONF.read_text(encoding="utf-8")
assert re.search(r"location\s+/health\b", content), (
"nginx/baton.conf не содержит блок location /health"
)
def test_nginx_conf_health_location_proxies_to_fastapi() -> None:
"""Блок location /health должен делать proxy_pass на 127.0.0.1:8000."""
content = NGINX_CONF.read_text(encoding="utf-8")
health_block = re.search(
r"location\s+/health\s*\{([^}]+)\}", content, re.DOTALL
)
assert health_block is not None, "Блок location /health { ... } не найден"
assert re.search(r"proxy_pass\s+http://127\.0\.0\.1:8000", health_block.group(1)), (
"Блок location /health не содержит proxy_pass http://127.0.0.1:8000"
)
# ---------------------------------------------------------------------------
# Criterion 3 — location / serves static files (SPA)
# ---------------------------------------------------------------------------
def test_nginx_conf_root_location_has_root_directive() -> None:
"""location / в nginx.conf должен содержать директиву root (статика)."""
content = NGINX_CONF.read_text(encoding="utf-8")
root_block = re.search(
r"location\s+/\s*\{([^}]+)\}", content, re.DOTALL
)
assert root_block is not None, "Блок location / { ... } не найден"
assert re.search(r"root\s+", root_block.group(1)), (
"Блок location / не содержит директиву root — SPA статика не настроена"
)
def test_nginx_conf_root_location_has_try_files_for_spa() -> None:
"""location / должен содержать try_files с fallback на /index.html (SPA)."""
content = NGINX_CONF.read_text(encoding="utf-8")
root_block = re.search(
r"location\s+/\s*\{([^}]+)\}", content, re.DOTALL
)
assert root_block is not None, "Блок location / { ... } не найден"
assert re.search(r"try_files\s+\$uri\s+/index\.html", root_block.group(1)), (
"Блок location / не содержит try_files $uri /index.html — "
"SPA-роутинг не работает"
)
def test_nginx_conf_root_location_does_not_proxy_to_fastapi() -> None:
"""location / НЕ должен делать proxy_pass на FastAPI (только статика)."""
content = NGINX_CONF.read_text(encoding="utf-8")
root_block = re.search(
r"location\s+/\s*\{([^}]+)\}", content, re.DOTALL
)
assert root_block is not None, "Блок location / { ... } не найден"
assert not re.search(r"proxy_pass", root_block.group(1)), (
"Блок location / содержит proxy_pass — GET / будет проксирован в FastAPI, "
"что вернёт 404 {'detail':'Not Found'} (исходная ошибка BATON-006)"
)
# ---------------------------------------------------------------------------
# Criterion 4 — FastAPI не имеет маршрута GET /
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_fastapi_root_returns_404() -> None:
"""GET / должен возвращать 404 от FastAPI — маршрут не зарегистрирован.
Это ожидаемое поведение: статику отдаёт nginx (location / с root + try_files),
а не FastAPI. Регрессия: если когда-нибудь GET / начнёт возвращать 200 от FastAPI,
это нарушит архитектуру (FastAPI не должен отдавать статику).
"""
async with make_app_client() as client:
response = await client.get("/")
assert response.status_code == 404, (
f"GET / должен возвращать 404 от FastAPI (статику отдаёт nginx). "
f"Получено: {response.status_code}"
)
# ---------------------------------------------------------------------------
# Criterion 5 — GET /health работает
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_health_endpoint_returns_200() -> None:
"""GET /health должен возвращать 200 после изменений nginx-конфига."""
async with make_app_client() as client:
response = await client.get("/health")
assert response.status_code == 200
assert response.json().get("status") == "ok"
# ---------------------------------------------------------------------------
# Criterion 6 — POST /api/register не сломан
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_api_register_not_broken_after_nginx_change() -> None:
"""POST /api/register должен вернуть 200 — функция не сломана изменением nginx."""
async with make_app_client() as client:
response = await client.post(
"/api/register",
json={"uuid": _UUID_REG, "name": "TestUser"},
)
assert response.status_code == 200
data = response.json()
assert data["user_id"] > 0
assert data["uuid"] == _UUID_REG
assert "api_key" in data
# ---------------------------------------------------------------------------
# Criterion 7 — POST /api/signal не сломан
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_api_signal_not_broken_after_nginx_change() -> None:
"""POST /api/signal должен вернуть 200 — функция не сломана изменением nginx."""
async with make_app_client() as client:
reg_resp = await client.post(
"/api/register",
json={"uuid": _UUID_SIG, "name": "SignalUser"},
)
assert reg_resp.status_code == 200
api_key = reg_resp.json()["api_key"]
response = await client.post(
"/api/signal",
json={
"user_id": _UUID_SIG,
"timestamp": 1700000000000,
"geo": None,
},
headers={"Authorization": f"Bearer {api_key}"},
)
assert response.status_code == 200
assert response.json().get("status") == "ok"
# ---------------------------------------------------------------------------
# Criterion 8 — POST /api/webhook/telegram не сломан
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_api_webhook_telegram_not_broken_after_nginx_change() -> None:
"""POST /api/webhook/telegram с корректным секретом должен вернуть 200."""
async with make_app_client() as client:
response = await client.post(
"/api/webhook/telegram",
json={"update_id": 200, "message": {"text": "hello"}},
headers={"X-Telegram-Bot-Api-Secret-Token": "test-webhook-secret"},
)
assert response.status_code == 200
assert response.json() == {"ok": True}

381
tests/test_baton_007.py Normal file
View file

@ -0,0 +1,381 @@
"""
Tests for BATON-007: Verifying real Telegram delivery when a signal is sent.
Acceptance criteria:
1. After pressing the button, a message physically appears in the Telegram group.
(verified: send_message is called with correct content containing user name)
2. journalctl -u baton does NOT throw ERROR during send.
(verified: no exception is raised when Telegram returns 200)
3. A repeated request is also delivered.
(verified: two consecutive signals each trigger send_message)
NOTE: These tests verify that send_message is called with correct parameters.
Physical delivery to an actual Telegram group is outside unit test scope.
"""
from __future__ import annotations
import asyncio
import logging
import os
os.environ.setdefault("BOT_TOKEN", "test-bot-token")
os.environ.setdefault("CHAT_ID", "-1001234567890")
os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret")
os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram")
os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000")
os.environ.setdefault("ADMIN_TOKEN", "test-admin-token")
import json
from unittest.mock import AsyncMock, patch
import httpx
import pytest
import respx
from httpx import AsyncClient, ASGITransport
from tests.conftest import make_app_client, temp_db
# Valid UUID v4 constants — must not collide with UUIDs in other test files
_UUID_A = "d0000001-0000-4000-8000-000000000001"
_UUID_B = "d0000002-0000-4000-8000-000000000002"
_UUID_C = "d0000003-0000-4000-8000-000000000003"
_UUID_D = "d0000004-0000-4000-8000-000000000004"
_UUID_E = "d0000005-0000-4000-8000-000000000005"
_UUID_F = "d0000006-0000-4000-8000-000000000006"
async def _register(client: AsyncClient, uuid: str, name: str) -> str:
r = await client.post("/api/register", json={"uuid": uuid, "name": name})
assert r.status_code == 200, f"Registration failed: {r.status_code} {r.text}"
return r.json()["api_key"]
# ---------------------------------------------------------------------------
# Criterion 1 — send_message is called with text containing the user's name
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_signal_send_message_called_with_user_name():
"""Criterion 1: send_message is invoked with text that includes the sender's name."""
sent_texts: list[str] = []
async def _capture(text: str) -> None:
sent_texts.append(text)
async with make_app_client() as client:
api_key = await _register(client, _UUID_A, "AliceBaton")
with patch("backend.telegram.send_message", side_effect=_capture):
resp = await client.post(
"/api/signal",
json={"user_id": _UUID_A, "timestamp": 1742478000000},
headers={"Authorization": f"Bearer {api_key}"},
)
await asyncio.sleep(0) # yield to event loop so background task runs
assert resp.status_code == 200
assert len(sent_texts) == 1, f"Expected 1 send_message call, got {len(sent_texts)}"
assert "AliceBaton" in sent_texts[0], (
f"Expected user name 'AliceBaton' in Telegram message, got: {sent_texts[0]!r}"
)
@pytest.mark.asyncio
async def test_signal_send_message_text_contains_signal_keyword():
"""Criterion 1: Telegram message text contains the word 'Сигнал'."""
sent_texts: list[str] = []
async def _capture(text: str) -> None:
sent_texts.append(text)
async with make_app_client() as client:
api_key = await _register(client, _UUID_B, "BobBaton")
with patch("backend.telegram.send_message", side_effect=_capture):
await client.post(
"/api/signal",
json={"user_id": _UUID_B, "timestamp": 1742478000000},
headers={"Authorization": f"Bearer {api_key}"},
)
await asyncio.sleep(0)
assert len(sent_texts) == 1
assert "Сигнал" in sent_texts[0], (
f"Expected 'Сигнал' keyword in message, got: {sent_texts[0]!r}"
)
@pytest.mark.asyncio
async def test_signal_with_geo_send_message_contains_coordinates():
"""Criterion 1: when geo is provided, Telegram message includes lat/lon coordinates."""
sent_texts: list[str] = []
async def _capture(text: str) -> None:
sent_texts.append(text)
async with make_app_client() as client:
api_key = await _register(client, _UUID_C, "GeoUser")
with patch("backend.telegram.send_message", side_effect=_capture):
await client.post(
"/api/signal",
json={
"user_id": _UUID_C,
"timestamp": 1742478000000,
"geo": {"lat": 55.7558, "lon": 37.6173, "accuracy": 15.0},
},
headers={"Authorization": f"Bearer {api_key}"},
)
await asyncio.sleep(0)
assert len(sent_texts) == 1
assert "55.7558" in sent_texts[0], (
f"Expected lat '55.7558' in message, got: {sent_texts[0]!r}"
)
assert "37.6173" in sent_texts[0], (
f"Expected lon '37.6173' in message, got: {sent_texts[0]!r}"
)
@pytest.mark.asyncio
async def test_signal_without_geo_send_message_contains_no_geo_label():
"""Criterion 1: when geo is null, Telegram message contains 'Гео нету'."""
sent_texts: list[str] = []
async def _capture(text: str) -> None:
sent_texts.append(text)
async with make_app_client() as client:
api_key = await _register(client, _UUID_D, "NoGeoUser")
with patch("backend.telegram.send_message", side_effect=_capture):
await client.post(
"/api/signal",
json={"user_id": _UUID_D, "timestamp": 1742478000000, "geo": None},
headers={"Authorization": f"Bearer {api_key}"},
)
await asyncio.sleep(0)
assert len(sent_texts) == 1
assert "Гео нету" in sent_texts[0], (
f"Expected 'Гео нету' in message, got: {sent_texts[0]!r}"
)
# ---------------------------------------------------------------------------
# Criterion 2 — No ERROR logged on successful send (service stays alive)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_signal_send_message_no_error_on_200_response():
"""Criterion 2: send_message does not raise when Telegram returns 200."""
from backend import config as _cfg
from backend.telegram import send_message
send_url = f"https://api.telegram.org/bot{_cfg.BOT_TOKEN}/sendMessage"
# Must complete without exception
with respx.mock(assert_all_called=False) as mock:
mock.post(send_url).mock(return_value=httpx.Response(200, json={"ok": True}))
await send_message("Test signal delivery") # should not raise
@pytest.mark.asyncio
async def test_signal_send_message_uses_configured_chat_id():
"""Criterion 2: send_message POSTs to Telegram with the configured CHAT_ID."""
from backend import config as _cfg
from backend.telegram import send_message
send_url = f"https://api.telegram.org/bot{_cfg.BOT_TOKEN}/sendMessage"
with respx.mock(assert_all_called=False) as mock:
route = mock.post(send_url).mock(
return_value=httpx.Response(200, json={"ok": True})
)
await send_message("Delivery check")
assert route.called
body = json.loads(route.calls[0].request.content)
assert body["chat_id"] == _cfg.CHAT_ID, (
f"Expected chat_id={_cfg.CHAT_ID!r}, got {body['chat_id']!r}"
)
# ---------------------------------------------------------------------------
# Criterion 3 — Repeated requests are also delivered
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_repeated_signals_each_trigger_send_message():
"""Criterion 3: two consecutive signals each cause a separate send_message call."""
sent_texts: list[str] = []
async def _capture(text: str) -> None:
sent_texts.append(text)
async with make_app_client() as client:
api_key = await _register(client, _UUID_E, "RepeatUser")
with patch("backend.telegram.send_message", side_effect=_capture):
r1 = await client.post(
"/api/signal",
json={"user_id": _UUID_E, "timestamp": 1742478000001},
headers={"Authorization": f"Bearer {api_key}"},
)
await asyncio.sleep(0)
r2 = await client.post(
"/api/signal",
json={"user_id": _UUID_E, "timestamp": 1742478000002},
headers={"Authorization": f"Bearer {api_key}"},
)
await asyncio.sleep(0)
assert r1.status_code == 200
assert r2.status_code == 200
assert len(sent_texts) == 2, (
f"Expected 2 send_message calls for 2 signals, got {len(sent_texts)}"
)
@pytest.mark.asyncio
async def test_repeated_signals_produce_incrementing_signal_ids():
"""Criterion 3: repeated signals are each stored and return distinct incrementing signal_ids."""
async with make_app_client() as client:
api_key = await _register(client, _UUID_E, "RepeatUser2")
r1 = await client.post(
"/api/signal",
json={"user_id": _UUID_E, "timestamp": 1742478000001},
headers={"Authorization": f"Bearer {api_key}"},
)
r2 = await client.post(
"/api/signal",
json={"user_id": _UUID_E, "timestamp": 1742478000002},
headers={"Authorization": f"Bearer {api_key}"},
)
assert r1.status_code == 200
assert r2.status_code == 200
assert r2.json()["signal_id"] > r1.json()["signal_id"], (
"Second signal must have a higher signal_id than the first"
)
# ---------------------------------------------------------------------------
# Director revision: regression #1214, #1226
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_send_message_uses_negative_chat_id_from_config():
"""Regression #1226: send_message must POST to Telegram with a negative chat_id.
Root cause of BATON-007: CHAT_ID=5190015988 (positive = user ID) was set in .env
instead of -5190015988 (negative = group ID). This test inspects the actual
chat_id value in the HTTP request body not just call_count.
"""
from backend import config as _cfg
from backend.telegram import send_message
send_url = f"https://api.telegram.org/bot{_cfg.BOT_TOKEN}/sendMessage"
with respx.mock(assert_all_called=False) as mock:
route = mock.post(send_url).mock(
return_value=httpx.Response(200, json={"ok": True})
)
await send_message("regression #1226")
assert route.called
body = json.loads(route.calls[0].request.content)
chat_id = body["chat_id"]
assert chat_id == _cfg.CHAT_ID, (
f"Expected chat_id={_cfg.CHAT_ID!r}, got {chat_id!r}"
)
assert str(chat_id).startswith("-"), (
f"Regression #1226: chat_id must be negative (group ID), got: {chat_id!r}. "
"Positive chat_id is a user ID, not a Telegram group."
)
@pytest.mark.asyncio
async def test_send_message_4xx_does_not_trigger_retry_loop():
"""Regression #1214: on Telegram 4xx (wrong chat_id), retry loop must NOT run.
Only one HTTP call should be made. Retrying a 4xx is pointless it will
keep failing. send_message must break immediately on any 4xx response.
"""
from backend import config as _cfg
from backend.telegram import send_message
send_url = f"https://api.telegram.org/bot{_cfg.BOT_TOKEN}/sendMessage"
with respx.mock(assert_all_called=False) as mock:
route = mock.post(send_url).mock(
return_value=httpx.Response(
400,
json={"ok": False, "error_code": 400, "description": "Bad Request: chat not found"},
)
)
await send_message("retry test #1214")
assert route.call_count == 1, (
f"Regression #1214: expected exactly 1 HTTP call on 4xx, got {route.call_count}. "
"send_message must break immediately on client errors — no retry loop."
)
@pytest.mark.asyncio
async def test_signal_endpoint_returns_200_on_telegram_4xx(caplog):
"""Regression: /api/signal must return 200 even when Telegram Bot API returns 4xx.
When CHAT_ID is wrong (or any Telegram 4xx), the error must be logged by
send_message but the /api/signal endpoint must still return 200 the signal
was saved to DB, only the Telegram notification failed.
"""
from backend import config as _cfg
from backend.main import app
send_url = f"https://api.telegram.org/bot{_cfg.BOT_TOKEN}/sendMessage"
tg_set_url = f"https://api.telegram.org/bot{_cfg.BOT_TOKEN}/setWebhook"
get_me_url = f"https://api.telegram.org/bot{_cfg.BOT_TOKEN}/getMe"
with temp_db():
with respx.mock(assert_all_called=False) as mock_tg:
mock_tg.get(get_me_url).mock(
return_value=httpx.Response(200, json={"ok": True, "result": {"username": "testbot"}})
)
mock_tg.post(tg_set_url).mock(
return_value=httpx.Response(200, json={"ok": True, "result": True})
)
mock_tg.post(send_url).mock(
return_value=httpx.Response(
400,
json={"ok": False, "error_code": 400, "description": "Bad Request: chat not found"},
)
)
async with app.router.lifespan_context(app):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://testserver") as client:
reg = await client.post("/api/register", json={"uuid": _UUID_F, "name": "Tg4xxUser"})
assert reg.status_code == 200, f"Register failed: {reg.text}"
api_key = reg.json()["api_key"]
with caplog.at_level(logging.ERROR, logger="backend.telegram"):
resp = await client.post(
"/api/signal",
json={"user_id": _UUID_F, "timestamp": 1742478000000},
headers={"Authorization": f"Bearer {api_key}"},
)
await asyncio.sleep(0)
assert resp.status_code == 200, (
f"Expected /api/signal to return 200 even when Telegram returns 4xx, got {resp.status_code}"
)
assert any("400" in r.message for r in caplog.records), (
"Expected ERROR log containing '400' when Telegram returns 4xx. "
"Error must be logged, not silently swallowed."
)

888
tests/test_baton_008.py Normal file
View file

@ -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 '<salt_hex>:<dk_hex>' 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()}"

View file

@ -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'<input[^>]*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'<input[^>]*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'<meta[^>]+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 812)
# ---------------------------------------------------------------------------
@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}"
)

338
tests/test_biz_001.py Normal file
View file

@ -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

203
tests/test_biz_002.py Normal file
View file

@ -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 не должен содержать <meta name='vapid-public-key'> вообще (decision #1333)."""
content = INDEX_HTML.read_text(encoding="utf-8")
match = re.search(
r'<meta[^>]+name\s*=\s*["\']vapid-public-key["\']',
content,
re.IGNORECASE,
)
assert match is None, (
f"index.html содержит удалённый тег <meta name='vapid-public-key'>: {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'<meta[^>]*(?:vapid|application-server-key)[^>]*content\s*=',
content,
re.IGNORECASE,
)
assert match is None, (
f"index.html содержит <meta>-тег с 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 отсутствует при пустом конфиге"

96
tests/test_biz_004.py Normal file
View file

@ -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"
)

View file

@ -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."""
@ -109,10 +117,9 @@ async def test_init_db_synchronous():
await db.init_db()
# Check synchronous on a new connection via _get_conn()
from backend.db import _get_conn
conn = await _get_conn()
async with _get_conn() as conn:
async with conn.execute("PRAGMA synchronous") as cur:
row = await cur.fetchone()
await conn.close()
# 1 == NORMAL
assert row[0] == 1
finally:
@ -129,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)
@ -143,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)
@ -160,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)
@ -189,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,
@ -209,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,
@ -240,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)

172
tests/test_fix_005.py Normal file
View file

@ -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}"
)

155
tests/test_fix_007.py Normal file
View file

@ -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}"
)

229
tests/test_fix_009.py Normal file
View file

@ -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])
)

116
tests/test_fix_011.py Normal file
View file

@ -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}"
)

172
tests/test_fix_012.py Normal file
View file

@ -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

194
tests/test_fix_013.py Normal file
View file

@ -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"
)

163
tests/test_fix_016.py Normal file
View file

@ -0,0 +1,163 @@
"""
Tests for BATON-FIX-016: VAPID public key убедиться, что ключ не вшит
как пустая строка в frontend-коде и читается через API.
Acceptance criteria:
1. В frontend-коде нет хардкода пустой строки в качестве VAPID key в <meta>-теге.
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 <meta> tag (index.html)
# ---------------------------------------------------------------------------
def test_index_html_has_no_vapid_meta_tag_with_empty_content() -> None:
"""index.html не должен содержать <meta>-тег с application-server-key и пустым content."""
content = INDEX_HTML.read_text(encoding="utf-8")
match = re.search(
r'<meta[^>]*(?:application-server-key|vapid)[^>]*content\s*=\s*["\']["\']',
content,
re.IGNORECASE,
)
assert match is None, (
f"index.html содержит <meta>-тег с пустым 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}"
)

View file

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

View file

@ -1,5 +1,11 @@
"""
Integration tests for POST /api/register.
UUID notes: RegisterRequest.uuid requires a valid UUID v4 pattern
(^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$).
All UUID constants below satisfy this constraint.
BATON-SEC-003: /api/register now returns api_key in the response.
"""
from __future__ import annotations
@ -10,23 +16,34 @@ os.environ.setdefault("CHAT_ID", "-1001234567890")
os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret")
os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram")
os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000")
os.environ.setdefault("ADMIN_TOKEN", "test-admin-token")
import pytest
from tests.conftest import make_app_client
# Valid UUID v4 constants for register tests
_UUID_REG_1 = "b0000001-0000-4000-8000-000000000001"
_UUID_REG_2 = "b0000002-0000-4000-8000-000000000002"
_UUID_REG_3 = "b0000003-0000-4000-8000-000000000003"
_UUID_REG_4 = "b0000004-0000-4000-8000-000000000004"
_UUID_REG_5 = "b0000005-0000-4000-8000-000000000005"
_UUID_REG_6 = "b0000006-0000-4000-8000-000000000006"
@pytest.mark.asyncio
async def test_register_new_user_success():
"""POST /api/register returns 200 with user_id > 0."""
"""POST /api/register returns 200 with user_id > 0 and api_key."""
async with make_app_client() as client:
resp = await client.post(
"/api/register",
json={"uuid": "reg-uuid-001", "name": "Alice"},
json={"uuid": _UUID_REG_1, "name": "Alice"},
)
assert resp.status_code == 200
data = resp.json()
assert data["user_id"] > 0
assert data["uuid"] == "reg-uuid-001"
assert data["uuid"] == _UUID_REG_1
assert "api_key" in data
assert len(data["api_key"]) == 64 # secrets.token_hex(32) = 64 hex chars
@pytest.mark.asyncio
@ -35,24 +52,42 @@ async def test_register_idempotent():
async with make_app_client() as client:
r1 = await client.post(
"/api/register",
json={"uuid": "reg-uuid-002", "name": "Bob"},
json={"uuid": _UUID_REG_2, "name": "Bob"},
)
r2 = await client.post(
"/api/register",
json={"uuid": "reg-uuid-002", "name": "Bob"},
json={"uuid": _UUID_REG_2, "name": "Bob"},
)
assert r1.status_code == 200
assert r2.status_code == 200
assert r1.json()["user_id"] == r2.json()["user_id"]
@pytest.mark.asyncio
async def test_register_idempotent_returns_api_key_on_every_call():
"""Each registration call returns an api_key (key rotation on re-register)."""
async with make_app_client() as client:
r1 = await client.post(
"/api/register",
json={"uuid": _UUID_REG_3, "name": "Carol"},
)
r2 = await client.post(
"/api/register",
json={"uuid": _UUID_REG_3, "name": "Carol"},
)
assert r1.status_code == 200
assert r2.status_code == 200
assert "api_key" in r1.json()
assert "api_key" in r2.json()
@pytest.mark.asyncio
async def test_register_empty_name_returns_422():
"""Empty name must fail validation with 422."""
async with make_app_client() as client:
resp = await client.post(
"/api/register",
json={"uuid": "reg-uuid-003", "name": ""},
json={"uuid": _UUID_REG_4, "name": ""},
)
assert resp.status_code == 422
@ -74,7 +109,18 @@ async def test_register_missing_name_returns_422():
async with make_app_client() as client:
resp = await client.post(
"/api/register",
json={"uuid": "reg-uuid-004"},
json={"uuid": _UUID_REG_4},
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_register_invalid_uuid_format_returns_422():
"""Non-UUID4 string as uuid must return 422."""
async with make_app_client() as client:
resp = await client.post(
"/api/register",
json={"uuid": "not-a-uuid", "name": "Dave"},
)
assert resp.status_code == 422
@ -85,11 +131,11 @@ async def test_register_user_stored_in_db():
async with make_app_client() as client:
r1 = await client.post(
"/api/register",
json={"uuid": "reg-uuid-005", "name": "Dana"},
json={"uuid": _UUID_REG_5, "name": "Dana"},
)
r2 = await client.post(
"/api/register",
json={"uuid": "reg-uuid-005", "name": "Dana"},
json={"uuid": _UUID_REG_5, "name": "Dana"},
)
assert r1.json()["user_id"] == r2.json()["user_id"]
@ -100,6 +146,6 @@ async def test_register_response_contains_uuid():
async with make_app_client() as client:
resp = await client.post(
"/api/register",
json={"uuid": "reg-uuid-006", "name": "Eve"},
json={"uuid": _UUID_REG_6, "name": "Eve"},
)
assert resp.json()["uuid"] == "reg-uuid-006"
assert resp.json()["uuid"] == _UUID_REG_6

282
tests/test_sec_002.py Normal file
View file

@ -0,0 +1,282 @@
"""
Tests for BATON-SEC-002:
1. _get_client_ip() extracts real IP from X-Real-IP / X-Forwarded-For headers.
2. POST /api/signal returns 429 when the per-IP rate limit is exceeded.
3. Rate counters for register and signal are independent (separate key namespaces).
UUID notes: RegisterRequest.uuid and SignalRequest.user_id both require a valid
UUID v4 pattern (^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$).
All constants below satisfy this constraint.
BATON-SEC-003: POST /api/signal now requires Authorization: Bearer <api_key>.
_register_and_get_key() helper returns the api_key from the registration response.
"""
from __future__ import annotations
import os
os.environ.setdefault("BOT_TOKEN", "test-bot-token")
os.environ.setdefault("CHAT_ID", "-1001234567890")
os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret")
os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram")
os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000")
os.environ.setdefault("ADMIN_TOKEN", "test-admin-token")
import pytest
from starlette.requests import Request
from backend.middleware import _get_client_ip
from tests.conftest import make_app_client
# ── Valid UUID v4 constants ──────────────────────────────────────────────────
# Pattern: xxxxxxxx-xxxx-4xxx-[89ab]xxx-xxxxxxxxxxxx (all hex chars)
_UUID_SIG_RL = "a0000001-0000-4000-8000-000000000001" # rate-limit 429 test
_UUID_SIG_OK = "a0000002-0000-4000-8000-000000000002" # first-10-allowed test
_UUID_IND_SIG = "a0000003-0000-4000-8000-000000000003" # independence (exhaust signal)
_UUID_IND_SIG2 = "a0000033-0000-4000-8000-000000000033" # second register after exhaust
_UUID_IND_REG = "a0000004-0000-4000-8000-000000000004" # independence (exhaust register)
_UUID_IP_A = "a0000005-0000-4000-8000-000000000005" # per-IP isolation, user A
_UUID_IP_B = "a0000006-0000-4000-8000-000000000006" # per-IP isolation, user B
# ── Helpers ─────────────────────────────────────────────────────────────────
async def _register_and_get_key(client, uuid: str, name: str) -> str:
"""Register user and return api_key."""
r = await client.post("/api/register", json={"uuid": uuid, "name": name})
assert r.status_code == 200
return r.json()["api_key"]
def _make_request(headers: dict | None = None, client_host: str = "127.0.0.1") -> Request:
"""Build a minimal Starlette Request with given headers and remote address."""
scope = {
"type": "http",
"method": "POST",
"path": "/",
"headers": [
(k.lower().encode(), v.encode())
for k, v in (headers or {}).items()
],
"client": (client_host, 12345),
}
return Request(scope)
# ── Unit: _get_client_ip ────────────────────────────────────────────────────
def test_get_client_ip_returns_x_real_ip_when_present():
"""X-Real-IP header is returned as-is (highest priority)."""
req = _make_request({"X-Real-IP": "203.0.113.10"}, client_host="127.0.0.1")
assert _get_client_ip(req) == "203.0.113.10"
def test_get_client_ip_ignores_client_host_when_x_real_ip_set():
"""When X-Real-IP is present, client.host (127.0.0.1) must NOT be returned."""
req = _make_request({"X-Real-IP": "10.20.30.40"}, client_host="127.0.0.1")
assert _get_client_ip(req) != "127.0.0.1"
def test_get_client_ip_uses_x_forwarded_for_when_no_x_real_ip():
"""X-Forwarded-For is used when X-Real-IP is absent."""
req = _make_request({"X-Forwarded-For": "198.51.100.5"}, client_host="127.0.0.1")
assert _get_client_ip(req) == "198.51.100.5"
def test_get_client_ip_x_forwarded_for_returns_first_ip_in_chain():
"""When X-Forwarded-For contains a chain, only the first (original) IP is returned."""
req = _make_request(
{"X-Forwarded-For": "192.0.2.1, 10.0.0.1, 172.16.0.1"},
client_host="127.0.0.1",
)
assert _get_client_ip(req) == "192.0.2.1"
def test_get_client_ip_x_real_ip_takes_priority_over_x_forwarded_for():
"""X-Real-IP beats X-Forwarded-For when both headers are present."""
req = _make_request(
{"X-Real-IP": "1.1.1.1", "X-Forwarded-For": "2.2.2.2"},
client_host="127.0.0.1",
)
assert _get_client_ip(req) == "1.1.1.1"
def test_get_client_ip_falls_back_to_client_host_when_no_proxy_headers():
"""Without proxy headers, client.host is returned."""
req = _make_request(client_host="203.0.113.99")
assert _get_client_ip(req) == "203.0.113.99"
def test_get_client_ip_returns_unknown_when_no_client_and_no_headers():
"""If no proxy headers and client is None, 'unknown' is returned."""
scope = {
"type": "http",
"method": "POST",
"path": "/",
"headers": [],
"client": None,
}
req = Request(scope)
assert _get_client_ip(req) == "unknown"
# ── Integration: signal rate limit (429) ────────────────────────────────────
@pytest.mark.asyncio
async def test_signal_rate_limit_returns_429_after_10_requests():
"""POST /api/signal returns 429 on the 11th request from the same IP."""
async with make_app_client() as client:
api_key = await _register_and_get_key(client, _UUID_SIG_RL, "RL")
payload = {"user_id": _UUID_SIG_RL, "timestamp": 1742478000000}
ip_hdrs = {"X-Real-IP": "5.5.5.5", "Authorization": f"Bearer {api_key}"}
statuses = []
for _ in range(11):
r = await client.post("/api/signal", json=payload, headers=ip_hdrs)
statuses.append(r.status_code)
assert statuses[-1] == 429, f"Expected 429 on 11th request, got {statuses}"
@pytest.mark.asyncio
async def test_signal_first_10_requests_are_allowed():
"""First 10 POST /api/signal requests from the same IP must all return 200."""
async with make_app_client() as client:
api_key = await _register_and_get_key(client, _UUID_SIG_OK, "OK")
payload = {"user_id": _UUID_SIG_OK, "timestamp": 1742478000000}
ip_hdrs = {"X-Real-IP": "6.6.6.6", "Authorization": f"Bearer {api_key}"}
statuses = []
for _ in range(10):
r = await client.post("/api/signal", json=payload, headers=ip_hdrs)
statuses.append(r.status_code)
assert all(s == 200 for s in statuses), (
f"Some request(s) before limit returned non-200: {statuses}"
)
# ── Integration: independence of register and signal rate limits ─────────────
@pytest.mark.asyncio
async def test_signal_rate_limit_does_not_affect_register_counter():
"""
Exhausting the signal rate limit (11 requests) must NOT cause /api/register
to return 429 the counters use different keys ('sig:IP' vs 'IP').
"""
async with make_app_client() as client:
ip_hdrs_reg = {"X-Real-IP": "7.7.7.7"}
# Register a user (increments register counter, key='7.7.7.7', count=1)
r_reg = await client.post(
"/api/register",
json={"uuid": _UUID_IND_SIG, "name": "Ind"},
headers=ip_hdrs_reg,
)
assert r_reg.status_code == 200
api_key = r_reg.json()["api_key"]
# Exhaust signal rate limit for same IP (11 requests, key='sig:7.7.7.7')
payload = {"user_id": _UUID_IND_SIG, "timestamp": 1742478000000}
ip_hdrs_sig = {"X-Real-IP": "7.7.7.7", "Authorization": f"Bearer {api_key}"}
for _ in range(11):
await client.post("/api/signal", json=payload, headers=ip_hdrs_sig)
# Register counter is still at 1 — must allow another registration
r_reg2 = await client.post(
"/api/register",
json={"uuid": _UUID_IND_SIG2, "name": "Ind2"},
headers=ip_hdrs_reg,
)
assert r_reg2.status_code == 200, (
f"Register returned {r_reg2.status_code}"
"signal exhaustion incorrectly bled into register counter"
)
@pytest.mark.asyncio
async def test_register_rate_limit_does_not_affect_signal_counter():
"""
Exhausting the register rate limit (6 requests 6th returns 429) must NOT
prevent subsequent /api/signal requests from the same IP.
"""
async with make_app_client() as client:
ip_hdrs = {"X-Real-IP": "8.8.8.8"}
# First register succeeds and creates the user we'll signal later
r0 = await client.post(
"/api/register",
json={"uuid": _UUID_IND_REG, "name": "Reg"},
headers=ip_hdrs,
)
assert r0.status_code == 200
api_key = r0.json()["api_key"]
# Send 4 more register requests from the same IP (requests 2-5 succeed,
# each rotates the api_key; request 6 would be 429).
# We keep track of the last api_key since re-registration rotates it.
for _ in range(4):
r = await client.post(
"/api/register",
json={"uuid": _UUID_IND_REG, "name": "Reg"},
headers=ip_hdrs,
)
if r.status_code == 200:
api_key = r.json()["api_key"]
# 6th request → 429 (exhausts limit without rotating key)
await client.post(
"/api/register",
json={"uuid": _UUID_IND_REG, "name": "Reg"},
headers=ip_hdrs,
)
# Signal must still succeed — signal counter (key='sig:8.8.8.8') is still 0
r_sig = await client.post(
"/api/signal",
json={"user_id": _UUID_IND_REG, "timestamp": 1742478000000},
headers={"X-Real-IP": "8.8.8.8", "Authorization": f"Bearer {api_key}"},
)
assert r_sig.status_code == 200, (
f"Signal returned {r_sig.status_code}"
"register exhaustion incorrectly bled into signal counter"
)
# ── Integration: signal rate limit is per-IP ─────────────────────────────────
@pytest.mark.asyncio
async def test_signal_rate_limit_is_per_ip_different_ips_are_independent():
"""
Rate limit counters are per-IP exhausting for IP A must not block IP B.
"""
async with make_app_client() as client:
api_key_a = await _register_and_get_key(client, _UUID_IP_A, "IPA")
api_key_b = await _register_and_get_key(client, _UUID_IP_B, "IPB")
# Exhaust rate limit for IP A (11 requests → 11th is 429)
for _ in range(11):
await client.post(
"/api/signal",
json={"user_id": _UUID_IP_A, "timestamp": 1742478000000},
headers={"X-Real-IP": "11.11.11.11", "Authorization": f"Bearer {api_key_a}"},
)
# IP B should still be allowed (independent counter)
r = await client.post(
"/api/signal",
json={"user_id": _UUID_IP_B, "timestamp": 1742478000000},
headers={"X-Real-IP": "22.22.22.22", "Authorization": f"Bearer {api_key_b}"},
)
assert r.status_code == 200, f"IP B was incorrectly blocked: {r.status_code}"

298
tests/test_sec_003.py Normal file
View file

@ -0,0 +1,298 @@
"""
Tests for BATON-SEC-003: API-ключи для аутентификации /api/signal.
Acceptance criteria:
1. POST /api/register возвращает api_key длиной 64 hex-символа.
2. POST /api/signal без Authorization header 401.
3. POST /api/signal с неверным api_key 401.
4. POST /api/signal с правильным api_key 200.
5. Повторная регистрация генерирует новый api_key (ротация ключа).
6. Старый api_key становится недействительным после ротации.
7. Новый api_key работает после ротации.
8. SHA-256 хэш api_key сохраняется в БД, сырой ключ нет (проверка через DB функцию).
UUID notes: все UUID ниже удовлетворяют паттерну UUID v4.
"""
from __future__ import annotations
import hashlib
import os
os.environ.setdefault("BOT_TOKEN", "test-bot-token")
os.environ.setdefault("CHAT_ID", "-1001234567890")
os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret")
os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram")
os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000")
os.environ.setdefault("ADMIN_TOKEN", "test-admin-token")
import pytest
from backend import db
from tests.conftest import make_app_client, temp_db
from backend import config
# Valid UUID v4 constants
_UUID_1 = "aa000001-0000-4000-8000-000000000001"
_UUID_2 = "aa000002-0000-4000-8000-000000000002"
_UUID_3 = "aa000003-0000-4000-8000-000000000003"
_UUID_4 = "aa000004-0000-4000-8000-000000000004"
_UUID_5 = "aa000005-0000-4000-8000-000000000005"
_UUID_6 = "aa000006-0000-4000-8000-000000000006"
_UUID_7 = "aa000007-0000-4000-8000-000000000007"
_UUID_8 = "aa000008-0000-4000-8000-000000000008"
_UUID_9 = "aa000009-0000-4000-8000-000000000009"
_UUID_10 = "aa00000a-0000-4000-8000-00000000000a"
# ---------------------------------------------------------------------------
# Criterion 1 — /api/register returns api_key of correct length
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_register_returns_api_key():
"""POST /api/register должен вернуть поле api_key в ответе."""
async with make_app_client() as client:
resp = await client.post(
"/api/register",
json={"uuid": _UUID_1, "name": "Alice"},
)
assert resp.status_code == 200
assert "api_key" in resp.json()
@pytest.mark.asyncio
async def test_register_api_key_is_64_hex_chars():
"""api_key должен быть строкой из 64 hex-символов (secrets.token_hex(32))."""
async with make_app_client() as client:
resp = await client.post(
"/api/register",
json={"uuid": _UUID_2, "name": "Bob"},
)
api_key = resp.json()["api_key"]
assert len(api_key) == 64
assert all(c in "0123456789abcdef" for c in api_key), (
f"api_key contains non-hex characters: {api_key}"
)
# ---------------------------------------------------------------------------
# Criterion 2 — Missing Authorization → 401
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_signal_without_auth_header_returns_401():
"""POST /api/signal без Authorization header должен вернуть 401."""
async with make_app_client() as client:
await client.post("/api/register", json={"uuid": _UUID_3, "name": "Charlie"})
resp = await client.post(
"/api/signal",
json={"user_id": _UUID_3, "timestamp": 1742478000000},
)
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_signal_without_bearer_scheme_returns_401():
"""POST /api/signal с неверной схемой (Basic вместо Bearer) должен вернуть 401."""
async with make_app_client() as client:
await client.post("/api/register", json={"uuid": _UUID_3, "name": "Charlie"})
resp = await client.post(
"/api/signal",
json={"user_id": _UUID_3, "timestamp": 1742478000000},
headers={"Authorization": "Basic wrongtoken"},
)
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# Criterion 3 — Wrong api_key → 401
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_signal_with_wrong_api_key_returns_401():
"""POST /api/signal с неверным api_key должен вернуть 401."""
async with make_app_client() as client:
await client.post("/api/register", json={"uuid": _UUID_4, "name": "Dave"})
resp = await client.post(
"/api/signal",
json={"user_id": _UUID_4, "timestamp": 1742478000000},
headers={"Authorization": "Bearer " + "0" * 64},
)
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_signal_with_unknown_user_returns_401():
"""POST /api/signal с api_key незарегистрированного пользователя должен вернуть 401."""
async with make_app_client() as client:
resp = await client.post(
"/api/signal",
json={"user_id": _UUID_5, "timestamp": 1742478000000},
headers={"Authorization": "Bearer " + "a" * 64},
)
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# Criterion 4 — Correct api_key → 200
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_signal_with_valid_api_key_returns_200():
"""POST /api/signal с правильным api_key должен вернуть 200."""
async with make_app_client() as client:
reg = await client.post(
"/api/register",
json={"uuid": _UUID_6, "name": "Eve"},
)
assert reg.status_code == 200
api_key = reg.json()["api_key"]
resp = await client.post(
"/api/signal",
json={"user_id": _UUID_6, "timestamp": 1742478000000},
headers={"Authorization": f"Bearer {api_key}"},
)
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
# ---------------------------------------------------------------------------
# Criterion 5-7 — Key rotation on re-register
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_re_register_produces_new_api_key():
"""Повторная регистрация должна возвращать новый api_key (ротация)."""
async with make_app_client() as client:
r1 = await client.post("/api/register", json={"uuid": _UUID_7, "name": "Frank"})
r2 = await client.post("/api/register", json={"uuid": _UUID_7, "name": "Frank"})
assert r1.status_code == 200
assert r2.status_code == 200
# Ключи могут совпасть (очень маловероятно), но оба должны быть длиной 64
assert len(r2.json()["api_key"]) == 64
@pytest.mark.asyncio
async def test_old_api_key_invalid_after_re_register():
"""После повторной регистрации старый api_key не должен работать."""
async with make_app_client() as client:
r1 = await client.post("/api/register", json={"uuid": _UUID_8, "name": "Grace"})
old_key = r1.json()["api_key"]
# Повторная регистрация — ротация ключа
r2 = await client.post("/api/register", json={"uuid": _UUID_8, "name": "Grace"})
new_key = r2.json()["api_key"]
# Старый ключ больше не должен работать
old_resp = await client.post(
"/api/signal",
json={"user_id": _UUID_8, "timestamp": 1742478000000},
headers={"Authorization": f"Bearer {old_key}"},
)
# Новый ключ должен работать
new_resp = await client.post(
"/api/signal",
json={"user_id": _UUID_8, "timestamp": 1742478000000},
headers={"Authorization": f"Bearer {new_key}"},
)
assert old_resp.status_code == 401, "Старый ключ должен быть недействителен после ротации"
assert new_resp.status_code == 200, "Новый ключ должен работать"
# ---------------------------------------------------------------------------
# Criterion 5 (task brief) — Token from another user → 401
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_signal_with_other_user_token_returns_401():
"""POST /api/signal с токеном другого пользователя должен вернуть 401.
Невозможно отправить сигнал от чужого имени даже зная UUID.
"""
async with make_app_client() as client:
# Регистрируем двух пользователей
r_a = await client.post("/api/register", json={"uuid": _UUID_9, "name": "UserA"})
r_b = await client.post("/api/register", json={"uuid": _UUID_10, "name": "UserB"})
assert r_a.status_code == 200
assert r_b.status_code == 200
api_key_a = r_a.json()["api_key"]
api_key_b = r_b.json()["api_key"]
# UserA пытается отправить сигнал с токеном UserB
resp_a_with_b_key = await client.post(
"/api/signal",
json={"user_id": _UUID_9, "timestamp": 1742478000000},
headers={"Authorization": f"Bearer {api_key_b}"},
)
# UserB пытается отправить сигнал с токеном UserA
resp_b_with_a_key = await client.post(
"/api/signal",
json={"user_id": _UUID_10, "timestamp": 1742478000000},
headers={"Authorization": f"Bearer {api_key_a}"},
)
assert resp_a_with_b_key.status_code == 401, (
"Нельзя отправить сигнал от имени UserA с токеном UserB"
)
assert resp_b_with_a_key.status_code == 401, (
"Нельзя отправить сигнал от имени UserB с токеном UserA"
)
# ---------------------------------------------------------------------------
# Criterion 8 — SHA-256 hash is stored, not the raw key
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_api_key_hash_stored_in_db_not_raw_key():
"""В БД должен храниться SHA-256 хэш api_key, а не сырой ключ."""
with temp_db():
from backend.main import app
import contextlib
import httpx
import respx
from httpx import AsyncClient, ASGITransport
tg_set_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/setWebhook"
send_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage"
mock_router = respx.mock(assert_all_called=False)
mock_router.post(tg_set_url).mock(
return_value=httpx.Response(200, json={"ok": True, "result": True})
)
mock_router.post(send_url).mock(
return_value=httpx.Response(200, json={"ok": True})
)
with mock_router:
async with app.router.lifespan_context(app):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://testserver") as client:
reg = await client.post(
"/api/register",
json={"uuid": _UUID_1, "name": "HashTest"},
)
assert reg.status_code == 200
raw_api_key = reg.json()["api_key"]
# Читаем хэш из БД напрямую
stored_hash = await db.get_api_key_hash_by_uuid(_UUID_1)
expected_hash = hashlib.sha256(raw_api_key.encode()).hexdigest()
assert stored_hash is not None, "api_key_hash должен быть в БД"
assert stored_hash == expected_hash, (
"В БД должен быть SHA-256 хэш, а не сырой ключ"
)
assert stored_hash != raw_api_key, "В БД не должен храниться сырой ключ"

337
tests/test_sec_006.py Normal file
View file

@ -0,0 +1,337 @@
"""
Tests for BATON-SEC-006: Персистентное хранение rate-limit счётчиков.
Acceptance criteria:
1. Счётчики сохраняются между пересозданием экземпляра приложения (симуляция рестарта).
2. TTL-очистка корректно сбрасывает устаревшие записи после истечения окна.
3. Превышение лимита возвращает HTTP 429.
4. X-Real-IP и X-Forwarded-For корректно парсятся для подсчёта.
UUID note: All UUIDs below satisfy the v4 pattern validated since BATON-SEC-005.
"""
from __future__ import annotations
import os
os.environ.setdefault("BOT_TOKEN", "test-bot-token")
os.environ.setdefault("CHAT_ID", "-1001234567890")
os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret")
os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram")
os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000")
os.environ.setdefault("ADMIN_TOKEN", "test-admin-token")
import tempfile
import unittest.mock as mock
import aiosqlite
import pytest
from backend import config, db
from tests.conftest import make_app_client
# ── Valid UUID v4 constants ──────────────────────────────────────────────────
_UUID_XREALIP_A = "c0000001-0000-4000-8000-000000000001" # X-Real-IP exhaustion
_UUID_XREALIP_B = "c0000002-0000-4000-8000-000000000002" # IP-B (independent counter)
_UUID_XFWD = "c0000003-0000-4000-8000-000000000003" # X-Forwarded-For test
_UUID_REG_RL = "c0000004-0000-4000-8000-000000000004" # register 429 test
# ── Helpers ──────────────────────────────────────────────────────────────────
def _tmpdb() -> str:
"""Set config.DB_PATH to a fresh temp file and return the path."""
path = tempfile.mktemp(suffix=".db")
config.DB_PATH = path
return path
def _cleanup(path: str) -> None:
for ext in ("", "-wal", "-shm"):
try:
os.unlink(path + ext)
except FileNotFoundError:
pass
# ── Criterion 1: Persistence across restart ───────────────────────────────────
@pytest.mark.asyncio
async def test_rate_limits_table_created_by_init_db():
"""init_db() creates the rate_limits table in SQLite."""
path = _tmpdb()
try:
await db.init_db()
async with aiosqlite.connect(path) as conn:
async with conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='rate_limits'"
) as cur:
row = await cur.fetchone()
assert row is not None, "rate_limits table not found after init_db()"
finally:
_cleanup(path)
@pytest.mark.asyncio
async def test_rate_limit_counter_persists_after_db_reinit():
"""Counter survives re-initialization of the DB (simulates app restart).
Before: in-memory app.state.rate_counters was lost on restart.
After: SQLite-backed rate_limits table persists across init_db() calls.
"""
path = _tmpdb()
try:
await db.init_db()
c1 = await db.rate_limit_increment("persist:test", 600)
c2 = await db.rate_limit_increment("persist:test", 600)
c3 = await db.rate_limit_increment("persist:test", 600)
assert c3 == 3, f"Expected 3 after 3 increments, got {c3}"
# Simulate restart: re-initialize DB against the same file
await db.init_db()
# Counter must continue from 3, not reset to 0
c4 = await db.rate_limit_increment("persist:test", 600)
assert c4 == 4, (
f"Expected 4 after reinit + 1 more increment (counter must persist), got {c4}"
)
finally:
_cleanup(path)
@pytest.mark.asyncio
async def test_rate_limit_increment_returns_sequential_counts():
"""rate_limit_increment returns 1, 2, 3 on successive calls within window."""
path = _tmpdb()
try:
await db.init_db()
c1 = await db.rate_limit_increment("seq:test", 600)
c2 = await db.rate_limit_increment("seq:test", 600)
c3 = await db.rate_limit_increment("seq:test", 600)
assert (c1, c2, c3) == (1, 2, 3), f"Expected (1,2,3), got ({c1},{c2},{c3})"
finally:
_cleanup(path)
# ── Criterion 2: TTL cleanup resets stale entries ────────────────────────────
@pytest.mark.asyncio
async def test_rate_limit_ttl_resets_counter_after_window_expires():
"""Counter resets to 1 when the time window has expired (TTL cleanup).
time.time() is mocked no real sleep required.
"""
path = _tmpdb()
try:
await db.init_db()
with mock.patch("backend.db.time") as mock_time:
mock_time.time.return_value = 1000.0 # window_start = t0
c1 = await db.rate_limit_increment("ttl:test", 10)
c2 = await db.rate_limit_increment("ttl:test", 10)
c3 = await db.rate_limit_increment("ttl:test", 10)
assert c3 == 3
# Jump 11 seconds ahead (window = 10s → expired)
mock_time.time.return_value = 1011.0
c4 = await db.rate_limit_increment("ttl:test", 10)
assert c4 == 1, (
f"Expected counter reset to 1 after window expired, got {c4}"
)
finally:
_cleanup(path)
@pytest.mark.asyncio
async def test_rate_limit_ttl_does_not_reset_within_window():
"""Counter is NOT reset when the window has NOT expired yet."""
path = _tmpdb()
try:
await db.init_db()
with mock.patch("backend.db.time") as mock_time:
mock_time.time.return_value = 1000.0
await db.rate_limit_increment("ttl:within", 10)
await db.rate_limit_increment("ttl:within", 10)
c3 = await db.rate_limit_increment("ttl:within", 10)
assert c3 == 3
# Only 5 seconds passed (window = 10s, still active)
mock_time.time.return_value = 1005.0
c4 = await db.rate_limit_increment("ttl:within", 10)
assert c4 == 4, (
f"Expected 4 (counter continues inside window), got {c4}"
)
finally:
_cleanup(path)
@pytest.mark.asyncio
async def test_rate_limit_ttl_boundary_exactly_at_window_end():
"""Counter resets when elapsed time equals exactly the window duration."""
path = _tmpdb()
try:
await db.init_db()
with mock.patch("backend.db.time") as mock_time:
mock_time.time.return_value = 1000.0
await db.rate_limit_increment("ttl:boundary", 10)
await db.rate_limit_increment("ttl:boundary", 10)
# Exactly at window boundary (elapsed == window → stale)
mock_time.time.return_value = 1010.0
c = await db.rate_limit_increment("ttl:boundary", 10)
assert c == 1, (
f"Expected reset at exact window boundary (elapsed == window), got {c}"
)
finally:
_cleanup(path)
# ── Criterion 3: HTTP 429 when rate limit exceeded ────────────────────────────
@pytest.mark.asyncio
async def test_register_returns_429_after_rate_limit_exceeded():
"""POST /api/register returns 429 on the 6th request from the same IP.
Register limit = 5 requests per 600s window.
"""
async with make_app_client() as client:
ip_hdrs = {"X-Real-IP": "192.0.2.10"}
statuses = []
for _ in range(6):
r = await client.post(
"/api/register",
json={"uuid": _UUID_REG_RL, "name": "RateLimitTest"},
headers=ip_hdrs,
)
statuses.append(r.status_code)
assert statuses[-1] == 429, (
f"Expected 429 on 6th register request, got statuses: {statuses}"
)
@pytest.mark.asyncio
async def test_register_first_5_requests_are_allowed():
"""First 5 POST /api/register requests from the same IP must all return 200."""
async with make_app_client() as client:
ip_hdrs = {"X-Real-IP": "192.0.2.11"}
statuses = []
for _ in range(5):
r = await client.post(
"/api/register",
json={"uuid": _UUID_REG_RL, "name": "RateLimitTest"},
headers=ip_hdrs,
)
statuses.append(r.status_code)
assert all(s == 200 for s in statuses), (
f"Expected all 5 register requests to return 200, got: {statuses}"
)
# ── Criterion 4: X-Real-IP and X-Forwarded-For for rate counting ──────────────
@pytest.mark.asyncio
async def test_x_real_ip_header_is_used_for_rate_counting():
"""Rate counter keys are derived from X-Real-IP: two requests sharing
the same X-Real-IP share the same counter and collectively hit the 429 limit.
"""
async with make_app_client() as client:
await client.post(
"/api/register", json={"uuid": _UUID_XREALIP_A, "name": "RealIPUser"}
)
ip_hdrs = {"X-Real-IP": "203.0.113.10"}
payload = {"user_id": _UUID_XREALIP_A, "timestamp": 1742478000000}
statuses = []
for _ in range(11):
r = await client.post("/api/signal", json=payload, headers=ip_hdrs)
statuses.append(r.status_code)
assert statuses[-1] == 429, (
f"Expected 429 on 11th signal with same X-Real-IP, got: {statuses}"
)
@pytest.mark.asyncio
async def test_x_forwarded_for_header_is_used_for_rate_counting():
"""Rate counter keys are derived from X-Forwarded-For (first IP) when
X-Real-IP is absent: requests sharing the same forwarded IP hit the limit.
"""
async with make_app_client() as client:
await client.post(
"/api/register", json={"uuid": _UUID_XFWD, "name": "FwdUser"}
)
# Chain: first IP is the original client (only that one is used)
fwd_hdrs = {"X-Forwarded-For": "198.51.100.5, 10.0.0.1, 172.16.0.1"}
payload = {"user_id": _UUID_XFWD, "timestamp": 1742478000000}
statuses = []
for _ in range(11):
r = await client.post("/api/signal", json=payload, headers=fwd_hdrs)
statuses.append(r.status_code)
assert statuses[-1] == 429, (
f"Expected 429 on 11th request with same X-Forwarded-For first IP, got: {statuses}"
)
@pytest.mark.asyncio
async def test_different_x_real_ip_values_have_independent_counters():
"""Exhausting the rate limit for IP-A must not block IP-B.
Verifies that rate-limit keys are truly per-IP.
"""
async with make_app_client() as client:
r_a = await client.post(
"/api/register", json={"uuid": _UUID_XREALIP_A, "name": "IPA"}
)
r_b = await client.post(
"/api/register", json={"uuid": _UUID_XREALIP_B, "name": "IPB"}
)
api_key_a = r_a.json()["api_key"]
api_key_b = r_b.json()["api_key"]
# Exhaust limit for IP-A (with valid auth so requests reach the rate limiter)
for _ in range(11):
await client.post(
"/api/signal",
json={"user_id": _UUID_XREALIP_A, "timestamp": 1742478000000},
headers={
"X-Real-IP": "198.51.100.100",
"Authorization": f"Bearer {api_key_a}",
},
)
# IP-B has its own independent counter — must not be blocked
r = await client.post(
"/api/signal",
json={"user_id": _UUID_XREALIP_B, "timestamp": 1742478000000},
headers={
"X-Real-IP": "198.51.100.200",
"Authorization": f"Bearer {api_key_b}",
},
)
assert r.status_code == 200, (
f"IP-B was incorrectly blocked after IP-A exhausted its counter: {r.status_code}"
)

240
tests/test_sec_007.py Normal file
View file

@ -0,0 +1,240 @@
"""
Regression tests for BATON-SEC-007:
1. Retry loop in telegram.py is bounded to exactly 3 attempts.
2. Exponential backoff applies correctly: sleep = retry_after * (attempt + 1).
3. POST /api/signal uses asyncio.create_task HTTP response is not blocked
by Telegram rate-limit pauses.
4. GET /health returns only {"status": "ok"} no timestamp field.
BATON-SEC-003: POST /api/signal now requires Authorization: Bearer <api_key>.
Tests that send signals now register first and use the returned api_key.
"""
from __future__ import annotations
import asyncio
import logging
import os
os.environ.setdefault("BOT_TOKEN", "test-bot-token")
os.environ.setdefault("CHAT_ID", "-1001234567890")
os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret")
os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram")
os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000")
os.environ.setdefault("ADMIN_TOKEN", "test-admin-token")
from unittest.mock import AsyncMock, patch
import httpx
import pytest
import respx
from backend import config
from backend.telegram import send_message
from tests.conftest import make_app_client
SEND_URL = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage"
# Valid UUID v4 constants
_UUID_CT = "d4e5f6a7-b8c9-4d0e-a1b2-c3d4e5f6a7b8"
_UUID_SLOW = "e5f6a7b8-c9d0-4e1f-a2b3-c4d5e6f7a8b9"
# ---------------------------------------------------------------------------
# Criterion 1 — retry loop is bounded to max 3 attempts
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_retry_loop_stops_after_3_attempts_on_all_429():
"""When all 3 responses are 429, send_message makes exactly 3 HTTP requests and stops."""
responses = [
httpx.Response(429, json={"ok": False, "parameters": {"retry_after": 1}}),
httpx.Response(429, json={"ok": False, "parameters": {"retry_after": 1}}),
httpx.Response(429, json={"ok": False, "parameters": {"retry_after": 1}}),
]
with respx.mock(assert_all_called=False) as mock:
route = mock.post(SEND_URL).mock(side_effect=responses)
with patch("backend.telegram.asyncio.sleep", new_callable=AsyncMock):
await send_message("test max 3 attempts")
assert route.call_count == 3
@pytest.mark.asyncio
async def test_retry_loop_does_not_make_4th_attempt_on_all_429():
"""send_message must never attempt a 4th request when the first 3 all return 429."""
call_count = 0
async def _count_and_return_429(_request):
nonlocal call_count
call_count += 1
return httpx.Response(429, json={"ok": False, "parameters": {"retry_after": 1}})
with respx.mock(assert_all_called=False) as mock:
mock.post(SEND_URL).mock(side_effect=_count_and_return_429)
with patch("backend.telegram.asyncio.sleep", new_callable=AsyncMock):
await send_message("test no 4th attempt")
assert call_count == 3
# ---------------------------------------------------------------------------
# Criterion 2 — exponential backoff: sleep = retry_after * (attempt + 1)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_retry_429_first_attempt_sleeps_retry_after_times_1():
"""First 429 (attempt 0): sleep duration must be retry_after * 1."""
retry_after = 7
responses = [
httpx.Response(429, json={"ok": False, "parameters": {"retry_after": retry_after}}),
httpx.Response(200, json={"ok": True}),
]
with respx.mock(assert_all_called=False) as mock:
mock.post(SEND_URL).mock(side_effect=responses)
with patch("backend.telegram.asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
await send_message("test attempt 0 backoff")
mock_sleep.assert_called_once_with(retry_after * 1)
@pytest.mark.asyncio
async def test_retry_429_exponential_backoff_sleep_sequence():
"""Two consecutive 429 responses produce sleep = retry_after*1 then retry_after*2."""
retry_after = 10
responses = [
httpx.Response(429, json={"ok": False, "parameters": {"retry_after": retry_after}}),
httpx.Response(429, json={"ok": False, "parameters": {"retry_after": retry_after}}),
httpx.Response(200, json={"ok": True}),
]
with respx.mock(assert_all_called=False) as mock:
mock.post(SEND_URL).mock(side_effect=responses)
with patch("backend.telegram.asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
await send_message("test backoff sequence")
sleep_args = [c.args[0] for c in mock_sleep.call_args_list]
assert retry_after * 1 in sleep_args, f"Expected sleep({retry_after}) not found in {sleep_args}"
assert retry_after * 2 in sleep_args, f"Expected sleep({retry_after * 2}) not found in {sleep_args}"
@pytest.mark.asyncio
async def test_retry_429_third_attempt_sleeps_retry_after_times_3():
"""Third 429 (attempt 2): sleep duration must be retry_after * 3."""
retry_after = 5
responses = [
httpx.Response(429, json={"ok": False, "parameters": {"retry_after": retry_after}}),
httpx.Response(429, json={"ok": False, "parameters": {"retry_after": retry_after}}),
httpx.Response(429, json={"ok": False, "parameters": {"retry_after": retry_after}}),
]
with respx.mock(assert_all_called=False) as mock:
mock.post(SEND_URL).mock(side_effect=responses)
with patch("backend.telegram.asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
await send_message("test attempt 2 backoff")
sleep_args = [c.args[0] for c in mock_sleep.call_args_list]
assert retry_after * 3 in sleep_args, f"Expected sleep({retry_after * 3}) not found in {sleep_args}"
# ---------------------------------------------------------------------------
# After exhausting all 3 attempts — error is logged
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_send_message_all_attempts_exhausted_logs_error(caplog):
"""After 3 failed 429 attempts, an ERROR containing 'all 3 attempts' is logged."""
responses = [
httpx.Response(429, json={"ok": False, "parameters": {"retry_after": 1}}),
httpx.Response(429, json={"ok": False, "parameters": {"retry_after": 1}}),
httpx.Response(429, json={"ok": False, "parameters": {"retry_after": 1}}),
]
with respx.mock(assert_all_called=False) as mock:
mock.post(SEND_URL).mock(side_effect=responses)
with patch("backend.telegram.asyncio.sleep", new_callable=AsyncMock):
with caplog.at_level(logging.ERROR, logger="backend.telegram"):
await send_message("test exhausted log")
error_messages = [r.message for r in caplog.records if r.levelno == logging.ERROR]
assert any("all 3 attempts" in m.lower() for m in error_messages), (
f"Expected 'all 3 attempts' in error logs, got: {error_messages}"
)
# ---------------------------------------------------------------------------
# Criterion 3 — POST /api/signal uses asyncio.create_task (non-blocking)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_signal_uses_create_task_for_telegram_send_message():
"""POST /api/signal must wrap telegram.send_message in asyncio.create_task."""
with patch("backend.main.asyncio.create_task", wraps=asyncio.create_task) as mock_ct:
async with make_app_client() as client:
reg = await client.post("/api/register", json={"uuid": _UUID_CT, "name": "CT"})
assert reg.status_code == 200
api_key = reg.json()["api_key"]
resp = await client.post(
"/api/signal",
json={"user_id": _UUID_CT, "timestamp": 1742478000000},
headers={"Authorization": f"Bearer {api_key}"},
)
assert resp.status_code == 200
assert mock_ct.called, "asyncio.create_task was never called — send_message may have been awaited directly"
@pytest.mark.asyncio
async def test_signal_response_returns_before_telegram_completes():
"""POST /api/signal returns 200 even when Telegram send_message is delayed."""
slow_sleep_called = False
async def slow_send_message(_text: str) -> None:
nonlocal slow_sleep_called
slow_sleep_called = True
await asyncio.sleep(9999) # would block forever if awaited
with patch("backend.main.telegram.send_message", side_effect=slow_send_message):
with patch("backend.main.asyncio.create_task", wraps=asyncio.create_task):
async with make_app_client() as client:
reg = await client.post(
"/api/register",
json={"uuid": _UUID_SLOW, "name": "Slow"},
)
assert reg.status_code == 200
api_key = reg.json()["api_key"]
resp = await client.post(
"/api/signal",
json={
"user_id": _UUID_SLOW,
"timestamp": 1742478000000,
},
headers={"Authorization": f"Bearer {api_key}"},
)
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# Criterion 4 — GET /health exact response body (regression guard)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_health_response_is_exactly_status_ok():
"""GET /health body must be exactly {"status": "ok"} — no extra fields."""
async with make_app_client() as client:
response = await client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
@pytest.mark.asyncio
async def test_health_no_timestamp_field():
"""GET /health must not expose a timestamp field (time-based fingerprinting prevention)."""
async with make_app_client() as client:
response = await client.get("/health")
assert "timestamp" not in response.json()

View file

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

View file

@ -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,11 +32,57 @@ import pytest
import respx
from backend import config
from backend.telegram import SignalAggregator, send_message, set_webhook
from backend.telegram import send_message, set_webhook, validate_bot_token
SEND_URL = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage"
WEBHOOK_URL_API = f"https://api.telegram.org/bot{config.BOT_TOKEN}/setWebhook"
GET_ME_URL = f"https://api.telegram.org/bot{config.BOT_TOKEN}/getMe"
# ---------------------------------------------------------------------------
# validate_bot_token
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_validate_bot_token_returns_true_on_200():
"""validate_bot_token returns True when getMe responds 200."""
with respx.mock(assert_all_called=False) as mock:
mock.get(GET_ME_URL).mock(
return_value=httpx.Response(200, json={"ok": True, "result": {"username": "batonbot"}})
)
result = await validate_bot_token()
assert result is True
@pytest.mark.asyncio
async def test_validate_bot_token_returns_false_on_401(caplog):
"""validate_bot_token returns False and logs ERROR when getMe responds 401."""
import logging
with respx.mock(assert_all_called=False) as mock:
mock.get(GET_ME_URL).mock(
return_value=httpx.Response(401, json={"ok": False, "description": "Unauthorized"})
)
with caplog.at_level(logging.ERROR, logger="backend.telegram"):
result = await validate_bot_token()
assert result is False
assert any("401" in record.message for record in caplog.records)
@pytest.mark.asyncio
async def test_validate_bot_token_returns_false_on_network_error(caplog):
"""validate_bot_token returns False and logs ERROR on network failure — never raises."""
import logging
with respx.mock(assert_all_called=False) as mock:
mock.get(GET_ME_URL).mock(side_effect=httpx.ConnectError("connection refused"))
with caplog.at_level(logging.ERROR, logger="backend.telegram"):
result = await validate_bot_token()
assert result is False
assert len(caplog.records) >= 1
# ---------------------------------------------------------------------------
@ -141,152 +185,66 @@ async def test_set_webhook_raises_on_non_200():
# ---------------------------------------------------------------------------
# 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
# BATON-007: 400 "chat not found" handling
# ---------------------------------------------------------------------------
@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,
)
async def test_send_message_400_chat_not_found_does_not_raise():
"""400 'chat not found' must not raise an exception (service stays alive)."""
with respx.mock(assert_all_called=False) as mock:
send_route = mock.post(SEND_URL).mock(
return_value=httpx.Response(200, json={"ok": True})
mock.post(SEND_URL).mock(
return_value=httpx.Response(
400,
json={"ok": False, "error_code": 400, "description": "Bad Request: chat not found"},
)
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)
)
# Must not raise — service must stay alive even with wrong CHAT_ID
await send_message("test")
@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,
)
async def test_send_message_400_chat_not_found_logs_error(caplog):
"""400 response from Telegram must be logged as ERROR with the status code."""
import logging
with respx.mock(assert_all_called=False) as mock:
send_route = mock.post(SEND_URL).mock(
return_value=httpx.Response(200, json={"ok": True})
mock.post(SEND_URL).mock(
return_value=httpx.Response(
400,
json={"ok": False, "error_code": 400, "description": "Bad Request: chat not found"},
)
with patch("backend.telegram.db.save_telegram_batch", new_callable=AsyncMock):
with patch("backend.telegram.asyncio.sleep", new_callable=AsyncMock):
await agg.flush()
)
with caplog.at_level(logging.ERROR, logger="backend.telegram"):
await send_message("test chat not found")
assert send_route.call_count == 1
finally:
_cleanup(path)
assert any("400" in record.message for record in caplog.records), (
"Expected ERROR log containing '400' but got: " + str([r.message for r in caplog.records])
)
@pytest.mark.asyncio
async def test_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
async def test_send_message_400_breaks_after_first_attempt():
"""On 400, send_message breaks immediately (no retry loop) — only one HTTP call made."""
with respx.mock(assert_all_called=False) as mock:
send_route = mock.post(SEND_URL).mock(
return_value=httpx.Response(200, json={"ok": True})
route = mock.post(SEND_URL).mock(
return_value=httpx.Response(
400,
json={"ok": False, "error_code": 400, "description": "Bad Request: chat not found"},
)
await agg.flush()
)
await send_message("test no retry on 400")
assert send_route.call_count == 0
assert route.call_count == 1, f"Expected 1 call on 400, got {route.call_count}"
@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
async def test_send_message_all_5xx_retries_exhausted_does_not_raise():
"""When all 3 attempts fail with 5xx, send_message logs error but does NOT raise."""
with respx.mock(assert_all_called=False) as mock:
mock.post(SEND_URL).mock(return_value=httpx.Response(200, json={"ok": True}))
with patch("backend.telegram.db.save_telegram_batch", new_callable=AsyncMock):
with patch("backend.telegram.asyncio.sleep", new_callable=AsyncMock):
await agg.flush()
assert len(agg._buffer) == 0
finally:
_cleanup(path)
@pytest.mark.asyncio
async def test_aggregator_unknown_user_shows_uuid_prefix():
"""If user_name is None, the message shows first 8 chars of uuid."""
path = await _init_db_with_tmp()
try:
agg = SignalAggregator(interval=9999)
test_uuid = "abcdef1234567890"
await agg.add_signal(
user_uuid=test_uuid,
user_name=None,
timestamp=1742478000000,
geo=None,
signal_id=1,
mock.post(SEND_URL).mock(
return_value=httpx.Response(500, text="Internal Server Error")
)
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()
# Must not raise — message is dropped, service stays alive
await send_message("test all retries exhausted")
assert len(sent_texts) == 1
assert test_uuid[:8] in sent_texts[0]
finally:
_cleanup(path)