kin: BATON-FIX-011 Скрыть BOT_TOKEN из httpx/journalctl логов
This commit is contained in:
parent
42f4251184
commit
2ab5e9ab54
2 changed files with 416 additions and 0 deletions
300
docs/DESIGN_BATON008.md
Normal file
300
docs/DESIGN_BATON008.md
Normal 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`: 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 от аутентификации.
|
||||||
116
tests/test_fix_011.py
Normal file
116
tests/test_fix_011.py
Normal 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}"
|
||||||
|
)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue