diff --git a/docs/DESIGN_BATON008.md b/docs/DESIGN_BATON008.md new file mode 100644 index 0000000..6bf2a49 --- /dev/null +++ b/docs/DESIGN_BATON008.md @@ -0,0 +1,300 @@ +# DESIGN_BATON008 — Регистрационный flow с Telegram-апрувом + +## Flow диаграмма + +``` +Пользователь Backend Telegram PWA / Service Worker + | | | | + |-- POST /api/auth/-------->| | | + | register | | | + | {email,login,pwd,push} | | | + | |-- validate input | | + | |-- hash password (PBKDF2) | | + | |-- INSERT registrations | | + | | (status=pending) | | + |<-- 201 {status:pending} --| | | + | |-- create_task ─────────────>| | + | | send_registration_ | | + | | notification() | | + | | | | + | | [Admin видит сообщение с кнопками] | + | | [✅ Одобрить / ❌ Отклонить] | + | | | | + | |<-- POST /api/webhook/ -----| (callback_query) | + | | telegram | | + | |-- parse callback_data | | + | |-- UPDATE registrations | | + | | SET status='approved' | | + | |-- answerCallbackQuery ─────>| | + | |-- editMessageText ─────────>| | + | |-- create_task | | + | | send_push() ─────────────────────────────────────>| + | | | [Push: "Одобрен!"] | + |<-- 200 {"ok": True} ------| | | +``` + +## API контракт + +### POST /api/auth/register + +**Request body:** +```json +{ + "email": "user@example.com", + "login": "user_name", + "password": "securepass", + "push_subscription": { + "endpoint": "https://fcm.googleapis.com/fcm/send/...", + "keys": { + "p256dh": "BNcR...", + "auth": "tBHI..." + } + } +} +``` +`push_subscription` — nullable. Если null, push при одобрении не отправляется. + +**Validation:** +- `email`: формат email (Pydantic EmailStr или regex `[^@]+@[^@]+\.[^@]+`) +- `login`: 3–30 символов, `[a-zA-Z0-9_-]` +- `password`: минимум 8 символов +- `push_subscription`: nullable object + +**Response 201:** +```json +{"status": "pending", "message": "Заявка отправлена на рассмотрение"} +``` + +**Response 409 (дубль email или login):** +```json +{"detail": "Пользователь с таким email или логином уже существует"} +``` + +**Response 429:** rate limit (через существующий `rate_limit_register` middleware) + +**Response 422:** невалидные поля (Pydantic автоматически) + +--- + +### POST /api/webhook/telegram (расширение) + +Существующий эндпоинт. Добавляется ветка обработки `callback_query`: + +**Входящий update (approve):** +```json +{ + "callback_query": { + "id": "123456789", + "data": "approve:42", + "message": { + "message_id": 777, + "chat": {"id": 5694335584} + } + } +} +``` + +**Поведение при `approve:{id}`:** +1. `UPDATE registrations SET status='approved' WHERE id=?` +2. Fetch registration row (для получения login и push_subscription) +3. `answerCallbackQuery(callback_query_id)` +4. `editMessageText(chat_id, message_id, "✅ Пользователь {login} одобрен")` +5. Если `push_subscription IS NOT NULL` → `create_task(send_push(...))` +6. Вернуть `{"ok": True}` + +**Поведение при `reject:{id}`:** +1. `UPDATE registrations SET status='rejected' WHERE id=?` +2. `answerCallbackQuery(callback_query_id)` +3. `editMessageText(chat_id, message_id, "❌ Пользователь {login} отклонён")` +4. Push НЕ отправляется +5. Вернуть `{"ok": True}` + +--- + +## SQL миграция + +```sql +-- В init_db(), добавить в executescript: +CREATE TABLE IF NOT EXISTS registrations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT UNIQUE NOT NULL, + login TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + push_subscription TEXT DEFAULT NULL, + created_at TEXT DEFAULT (datetime('now')) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_registrations_email + ON registrations(email); +CREATE UNIQUE INDEX IF NOT EXISTS idx_registrations_login + ON registrations(login); +CREATE INDEX IF NOT EXISTS idx_registrations_status + ON registrations(status); +``` + +Таблица создаётся через `CREATE TABLE IF NOT EXISTS` — backward compatible, не ломает существующие БД. + +--- + +## Список изменяемых файлов + +| Файл | Тип изменения | Суть | +|------|--------------|------| +| `backend/db.py` | Modify | Добавить таблицу `registrations` в `init_db()` + 3 функции CRUD | +| `backend/config.py` | Modify | Добавить `ADMIN_CHAT_ID`, `VAPID_PRIVATE_KEY`, `VAPID_PUBLIC_KEY`, `VAPID_CLAIMS_EMAIL` | +| `backend/models.py` | Modify | Добавить `PushKeys`, `PushSubscription`, `AuthRegisterRequest`, `AuthRegisterResponse` | +| `backend/telegram.py` | Modify | Добавить `send_registration_notification()`, `answer_callback_query()`, `edit_message_text()` | +| `backend/main.py` | Modify | Добавить `POST /api/auth/register` + callback_query ветку в webhook | +| `backend/push.py` | **New** | Отправка Web Push через pywebpush | +| `requirements.txt` | Modify | Добавить `pywebpush>=2.0.0` | +| `tests/test_baton_008.py` | **New** | Тесты для нового flow | + +**НЕ трогать:** `backend/middleware.py`, `/api/register`, `users` таблица. + +**Замечание:** CORS `allow_headers` уже содержит `Authorization` в `main.py:122` — изменение не требуется. + +--- + +## Интеграционные точки с существующим кодом + +### 1. `_hash_password()` в `main.py` +Функция уже существует (строки 41–48). Dev agent должен **переиспользовать её напрямую** в новом endpoint `POST /api/auth/register`, не дублируя логику. + +### 2. `rate_limit_register` middleware +Существующий middleware из `backend/middleware.py` может быть подключён к новому endpoint как `Depends(rate_limit_register)` — тот же ключ `reg:{ip}`, та же логика. + +### 3. `telegram.send_message()` — не модифицировать +Существующая функция использует `config.CHAT_ID` для SOS-сигналов. Для регистрационных уведомлений создаётся отдельная функция `send_registration_notification()`, которая использует `config.ADMIN_CHAT_ID`. Это разделяет два потока уведомлений. + +### 4. Webhook handler (строки 223–242 в `main.py`) +Добавляется ветка в начало функции (до `message = update.get("message", {})`): +```python +callback_query = update.get("callback_query") +if callback_query: + asyncio.create_task(_handle_callback_query(callback_query)) + return {"ok": True} +``` +Существующая логика `/start` остаётся нетронутой. + +### 5. `lifespan` в `main.py` +Никаких изменений — VAPID-ключи не требуют startup validation (unlike BOT_TOKEN), так как их инвалидация некритична для работы сервиса в целом. + +--- + +## Спецификация новых компонентов + +### `backend/db.py` — 3 новые функции + +``` +create_registration(email, login, password_hash, push_subscription) -> dict | None + INSERT INTO registrations ... + ON CONFLICT → raise aiosqlite.IntegrityError (caller catches → 409) + Returns: {"id", "email", "login", "created_at"} + +get_registration_by_id(reg_id: int) -> dict | None + SELECT id, email, login, status, push_subscription FROM registrations WHERE id=? + +update_registration_status(reg_id: int, status: str) -> dict | None + UPDATE registrations SET status=? WHERE id=? + Returns registration dict or None if not found +``` + +### `backend/config.py` — новые переменные + +```python +ADMIN_CHAT_ID: str = os.getenv("ADMIN_CHAT_ID", "5694335584") +VAPID_PRIVATE_KEY: str = os.getenv("VAPID_PRIVATE_KEY", "") +VAPID_PUBLIC_KEY: str = os.getenv("VAPID_PUBLIC_KEY", "") +VAPID_CLAIMS_EMAIL: str = os.getenv("VAPID_CLAIMS_EMAIL", "") +``` + +Все optional (не `_require`) — отсутствие VAPID только отключает Web Push, не ломает сервис. + +### `backend/models.py` — новые Pydantic модели + +```python +class PushKeys(BaseModel): + p256dh: str + auth: str + +class PushSubscription(BaseModel): + endpoint: str + keys: PushKeys + +class AuthRegisterRequest(BaseModel): + email: str = Field(..., pattern=r'^[^@\s]+@[^@\s]+\.[^@\s]+$') + login: str = Field(..., min_length=3, max_length=30, pattern=r'^[a-zA-Z0-9_-]+$') + password: str = Field(..., min_length=8) + push_subscription: Optional[PushSubscription] = None + +class AuthRegisterResponse(BaseModel): + status: str + message: str +``` + +### `backend/telegram.py` — 3 новые функции + +``` +send_registration_notification(login, email, reg_id, created_at) -> None + POST sendMessage с reply_markup=InlineKeyboardMarkup + chat_id = config.ADMIN_CHAT_ID + Swallows все ошибки (decision #1215) + +answer_callback_query(callback_query_id: str, text: str = "") -> None + POST answerCallbackQuery + Swallows все ошибки + +edit_message_text(chat_id: str | int, message_id: int, text: str) -> None + POST editMessageText + Swallows все ошибки +``` + +Все три используют тот же паттерн retry (3 попытки, 429/5xx) что и `send_message()`. + +### `backend/push.py` — новый файл + +``` +send_push(subscription_json: str, title: str, body: str) -> None + Парсит subscription_json → dict + webpush( + subscription_info=subscription_dict, + data=json.dumps({"title": title, "body": body, "icon": "/icon-192.png"}), + vapid_private_key=config.VAPID_PRIVATE_KEY, + vapid_claims={"sub": f"mailto:{config.VAPID_CLAIMS_EMAIL}"} + ) + Если VAPID_PRIVATE_KEY пустой → log warning, return (push disabled) + Swallows WebPushException и все прочие ошибки +``` + +--- + +## Edge Cases и решения + +| Кейс | Решение | +|------|---------| +| Email уже зарегистрирован | `IntegrityError` → HTTP 409 | +| Login уже занят | `IntegrityError` → HTTP 409 | +| Rejected пользователь пытается зарегистрироваться заново | 409 (статус не учитывается — оба поля UNIQUE) | +| push_subscription = null при approve | `if reg["push_subscription"]: send_push(...)` — skip gracefully | +| Истёкший/невалидный push endpoint | pywebpush raises → `logger.warning()` → swallow | +| Двойной клик Одобрить (admin кликает дважды) | UPDATE выполняется (idempotent), editMessageText может вернуть ошибку (уже отредактировано) → swallow | +| reg_id не существует в callback | `get_registration_by_id` returns None → log warning, answerCallbackQuery всё равно вызвать | +| VAPID ключи не настроены | Push не отправляется, log warning, сервис работает | +| Telegram недоступен при регистрации | Fire-and-forget + swallow — пользователь получает 201, уведомление теряется | + +--- + +## Решения по open questions (из context_packet) + +**VAPID ключи не сгенерированы:** Dev agent добавляет в README инструкцию по генерации: +```bash +python -c "from py_vapid import Vapid; v = Vapid(); v.generate_keys(); print(v.private_key, v.public_key)" +``` +Ключи добавляются в `.env` вручную оператором перед деплоем. + +**Повторный approve/reject:** Операция idempotent — UPDATE всегда выполняется без проверки текущего статуса. EditMessageText вернёт ошибку при повторном вызове — swallow. + +**Service Worker:** Фронтенд вне скоупа этого тикета. Backend отправляет корректный Web Push payload — обработка на стороне клиента. + +**Login после approve:** Механизм авторизации не входит в BATON-008. Регистрация — отдельный flow от аутентификации. diff --git a/tests/test_fix_011.py b/tests/test_fix_011.py new file mode 100644 index 0000000..f4a3019 --- /dev/null +++ b/tests/test_fix_011.py @@ -0,0 +1,116 @@ +""" +BATON-FIX-011: Проверяет, что BOT_TOKEN не попадает в httpx-логи. + +1. logging.getLogger('httpx').level >= logging.WARNING после импорта приложения. +2. Дочерние логгеры httpx._client и httpx._async_client также не пишут INFO. +3. При вызове send_message ни одна запись httpx-логгера с уровнем INFO + не содержит 'bot' или токен-подобный паттерн /bot[0-9]+:/. +""" +from __future__ import annotations + +import logging +import re + +import httpx +import pytest +import respx +from unittest.mock import patch, AsyncMock + +# conftest.py уже устанавливает BOT_TOKEN=test-bot-token до этого импорта +from backend import config +from backend.telegram import send_message + +SEND_URL = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage" +BOT_TOKEN_PATTERN = re.compile(r"bot[0-9]+:") + + +# --------------------------------------------------------------------------- +# Уровень логгера httpx +# --------------------------------------------------------------------------- + +def test_httpx_logger_level_is_warning_or_higher(): + """logging.getLogger('httpx').level должен быть WARNING (30) или выше после импорта приложения.""" + # Импортируем main, чтобы гарантировать, что setLevel уже вызван + import backend.main # noqa: F401 + + httpx_logger = logging.getLogger("httpx") + assert httpx_logger.level >= logging.WARNING, ( + f"Ожидался уровень >= WARNING (30), получен {httpx_logger.level}" + ) + + +def test_httpx_logger_info_not_enabled(): + """logging.getLogger('httpx').isEnabledFor(INFO) должен возвращать False.""" + import backend.main # noqa: F401 + + httpx_logger = logging.getLogger("httpx") + assert not httpx_logger.isEnabledFor(logging.INFO), ( + "httpx-логгер не должен обрабатывать INFO-сообщения" + ) + + +def test_httpx_client_logger_info_not_enabled(): + """Дочерний логгер httpx._client не должен обрабатывать INFO.""" + import backend.main # noqa: F401 + + child_logger = logging.getLogger("httpx._client") + assert not child_logger.isEnabledFor(logging.INFO), ( + "httpx._client не должен обрабатывать INFO-сообщения" + ) + + +def test_httpx_async_client_logger_info_not_enabled(): + """Дочерний логгер httpx._async_client не должен обрабатывать INFO.""" + import backend.main # noqa: F401 + + child_logger = logging.getLogger("httpx._async_client") + assert not child_logger.isEnabledFor(logging.INFO), ( + "httpx._async_client не должен обрабатывать INFO-сообщения" + ) + + +# --------------------------------------------------------------------------- +# BOT_TOKEN не появляется в httpx INFO-логах при send_message +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_send_message_no_httpx_records_at_warning_level(caplog): + """При вызове send_message httpx не выдаёт записей уровня WARNING и ниже с токеном. + + Проверяет фактическое состояние логгера в продакшне (WARNING): INFO-сообщения + с URL (включая BOT_TOKEN) не должны проходить через httpx-логгер. + """ + import backend.main # noqa: F401 — убеждаемся, что setLevel вызван + + with respx.mock(assert_all_called=False) as mock: + mock.post(SEND_URL).mock(return_value=httpx.Response(200, json={"ok": True})) + + # Захватываем логи при реальном уровне WARNING — INFO-сообщения не должны проходить + with caplog.at_level(logging.WARNING, logger="httpx"): + await send_message("test message for token leak check") + + bot_token = config.BOT_TOKEN + httpx_records = [r for r in caplog.records if r.name.startswith("httpx")] + for record in httpx_records: + assert bot_token not in record.message, ( + f"BOT_TOKEN найден в httpx-логе (уровень {record.levelname}): {record.message!r}" + ) + + +@pytest.mark.asyncio +async def test_send_message_no_token_pattern_in_httpx_info_logs(caplog): + """При вызове send_message httpx INFO-логи не содержат паттерн /bot[0-9]+:/.""" + with respx.mock(assert_all_called=False) as mock: + mock.post(SEND_URL).mock(return_value=httpx.Response(200, json={"ok": True})) + + with caplog.at_level(logging.INFO, logger="httpx"): + await send_message("check token pattern") + + info_records = [ + r for r in caplog.records + if r.name.startswith("httpx") and r.levelno <= logging.INFO + ] + for record in info_records: + assert not BOT_TOKEN_PATTERN.search(record.message), ( + f"Паттерн bot[0-9]+: найден в httpx INFO-логе: {record.message!r}" + )