kin: BATON-FIX-011 Скрыть BOT_TOKEN из httpx/journalctl логов

This commit is contained in:
Gros Frumos 2026-03-21 09:21:25 +02:00
parent 42f4251184
commit 2ab5e9ab54
2 changed files with 416 additions and 0 deletions

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 от аутентификации.

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