Сайт закрыт Caddy basic_auth, тред привязывается к X-Authenticated-User (нет анонимов). Новые web_support_threads/web_support_messages (миграция 187) — отдельно от tg_support_* (186): у веб-клиента нет Telegram chat_id, смешение identity-схем в одной таблице потребовало бы NULLABLE chat_id/username и XOR CHECK-ограничений без реальной выгоды (обоснование в самой миграции). API (app/api/v1/support.py, /api/v1/trade-in/support/*): POST /messages — отправка (sendMessage-зеркало в топик, "[С САЙТА] user: ...") GET /messages — polling своего треда (?since=id) GET /unread — счётчик непрочитанного POST /read — отметить прочитанным Тред резолвится ИСКЛЮЧИТЕЛЬНО по username — нет параметра, которым можно адресовать чужой тред (структурная защита от IDOR, не только access-check). bridge.py: _handle_group_reply получил ветку резолва reply в web-тред (после существующего tg-резолва, без изменения Telegram-пути) — оператор отвечает одинаково, вне зависимости от канала клиента. Rate-limit: новый SlidingWindowLimiter (ratelimit.py) — 12 msg/60s per user, жёстче общего RateLimitMiddleware (общий бот-токен, флуд одного клиента иначе бьёт по доставке всем). Security: этот процесс (app/main.py) теперь тоже зовёт Telegram Bot API напрямую (раньше — только изолированный tgbot_main.py) — реплицированы обе защиты токена: httpx-INFO подавлен, include_local_variables=False + redact_telegram_bot_token в sentry before_send. Бот не сконфигурирован (пустой TELEGRAM_BOT_TOKEN/chat_id) → 503, не 500.
433 lines
17 KiB
Python
433 lines
17 KiB
Python
"""Offline-тесты веб-чата поддержки (#tgsupport-web) —
|
||
POST/GET /api/v1/trade-in/support/{messages,unread,read}.
|
||
|
||
Storage-слой (`app.services.tgbot.web_support_storage`) мокается целиком (как
|
||
mock'ается DB в test_trade_in_lead.py) — эти тесты проверяют РОУТЕР: изоляцию
|
||
тредов (нет параметра, которым можно адресовать чужой тред), валидацию
|
||
входа, поведение при несконфигурированном боте, rate-limit и то, что реплай
|
||
парсится в `bridge.py` в web-ветку (см. tests/services/tgbot/test_bridge.py —
|
||
маршрутизация реплая тестируется ТАМ; здесь — только HTTP-контракт отправки/
|
||
чтения).
|
||
|
||
NEVER touches real DB / real Telegram API.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
from typing import Any, ClassVar
|
||
from unittest.mock import MagicMock
|
||
|
||
import pytest
|
||
from fastapi import FastAPI
|
||
from fastapi.testclient import TestClient
|
||
|
||
from app.api.v1 import support as support_module
|
||
from app.core.db import get_db
|
||
from app.core.ratelimit import SlidingWindowLimiter
|
||
from app.services.tgbot.client import TelegramApiError
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _bot_configured(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""По умолчанию бот считается сконфигурированным — отдельные тесты переопределяют."""
|
||
monkeypatch.setattr(support_module.settings, "telegram_bot_token", "fake-token")
|
||
monkeypatch.setattr(support_module.settings, "telegram_support_chat_id", -100123456789)
|
||
monkeypatch.setattr(support_module.settings, "telegram_support_topic_id", 42)
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _fresh_rate_limiter(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Модульный `_send_limiter` иначе накапливает состояние МЕЖДУ тестами (один
|
||
процесс pytest) — свежий лимитер на каждый тест, щедрый дефолт (rate-limit
|
||
тестируется отдельно на СВОЁМ, явно узком экземпляре)."""
|
||
monkeypatch.setattr(
|
||
support_module, "_send_limiter", SlidingWindowLimiter(limit=1000, window_s=60.0)
|
||
)
|
||
|
||
|
||
class _FakeTelegramClient:
|
||
"""Подменяет `TelegramClient` внутри `support` модуля — никакого httpx/сети."""
|
||
|
||
calls: ClassVar[list[dict[str, Any]]] = []
|
||
_response: ClassVar[dict[str, Any] | Exception] = {"message_id": 555}
|
||
|
||
def __init__(self, _token: str) -> None:
|
||
pass
|
||
|
||
async def send_message(self, **kwargs: Any) -> dict[str, Any]:
|
||
_FakeTelegramClient.calls.append(kwargs)
|
||
if isinstance(_FakeTelegramClient._response, Exception):
|
||
raise _FakeTelegramClient._response
|
||
return _FakeTelegramClient._response
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _fake_telegram_client(monkeypatch: pytest.MonkeyPatch) -> Any:
|
||
_FakeTelegramClient.calls = []
|
||
_FakeTelegramClient._response = {"message_id": 555}
|
||
monkeypatch.setattr(support_module, "TelegramClient", _FakeTelegramClient)
|
||
return _FakeTelegramClient
|
||
|
||
|
||
@pytest.fixture
|
||
def db() -> MagicMock:
|
||
return MagicMock()
|
||
|
||
|
||
@pytest.fixture
|
||
def client(db: MagicMock) -> TestClient:
|
||
app = FastAPI()
|
||
app.include_router(support_module.router, prefix="/api/v1/trade-in")
|
||
|
||
def fake_db() -> Any:
|
||
yield db
|
||
|
||
app.dependency_overrides[get_db] = fake_db
|
||
return TestClient(app)
|
||
|
||
|
||
def _auth(username: str = "alice") -> dict[str, str]:
|
||
return {"x-authenticated-user": username}
|
||
|
||
|
||
# ── auth guard ────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_send_message_without_auth_header_401(client: TestClient) -> None:
|
||
r = client.post("/api/v1/trade-in/support/messages", json={"text": "hi"})
|
||
assert r.status_code == 401
|
||
|
||
|
||
def test_list_messages_without_auth_header_401(client: TestClient) -> None:
|
||
r = client.get("/api/v1/trade-in/support/messages")
|
||
assert r.status_code == 401
|
||
|
||
|
||
# ── validation ────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_send_message_blank_text_422(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
called = []
|
||
monkeypatch.setattr(
|
||
support_module.storage, "get_or_create_thread", lambda *a, **kw: called.append(1) or 1
|
||
)
|
||
r = client.post("/api/v1/trade-in/support/messages", json={"text": " "}, headers=_auth())
|
||
assert r.status_code == 422, r.text
|
||
assert called == [] # ничего не персистится на невалидном вводе
|
||
|
||
|
||
def test_send_message_empty_text_422(client: TestClient) -> None:
|
||
r = client.post("/api/v1/trade-in/support/messages", json={"text": ""}, headers=_auth())
|
||
assert r.status_code == 422
|
||
|
||
|
||
def test_send_message_too_long_422(client: TestClient) -> None:
|
||
too_long = "a" * (support_module.MAX_MESSAGE_LENGTH + 1)
|
||
r = client.post("/api/v1/trade-in/support/messages", json={"text": too_long}, headers=_auth())
|
||
assert r.status_code == 422
|
||
|
||
|
||
def test_send_message_at_max_length_ok(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
monkeypatch.setattr(support_module.storage, "get_or_create_thread", lambda *a, **kw: 1)
|
||
monkeypatch.setattr(
|
||
support_module.storage,
|
||
"record_inbound",
|
||
lambda *a, **kw: {
|
||
"id": 1,
|
||
"direction": "in",
|
||
"text_body": kw["text_body"],
|
||
"operator_tg_id": None,
|
||
"created_at": "2026-07-26T00:00:00+00:00",
|
||
},
|
||
)
|
||
at_limit = "a" * support_module.MAX_MESSAGE_LENGTH
|
||
r = client.post("/api/v1/trade-in/support/messages", json={"text": at_limit}, headers=_auth())
|
||
assert r.status_code == 200, r.text
|
||
|
||
|
||
# ── bot not configured ──────────────────────────────────────────────────────
|
||
|
||
|
||
def test_send_message_bot_token_empty_returns_503_not_500(
|
||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
monkeypatch.setattr(support_module.settings, "telegram_bot_token", "")
|
||
thread_created = []
|
||
monkeypatch.setattr(
|
||
support_module.storage, "get_or_create_thread", lambda *a, **kw: thread_created.append(1)
|
||
)
|
||
|
||
r = client.post("/api/v1/trade-in/support/messages", json={"text": "hi"}, headers=_auth())
|
||
|
||
assert r.status_code == 503
|
||
assert r.status_code != 500
|
||
assert thread_created == [] # ничего не создаём/не пишем, если зеркалировать некуда
|
||
|
||
|
||
def test_send_message_support_chat_id_unset_returns_503(
|
||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
monkeypatch.setattr(support_module.settings, "telegram_support_chat_id", 0)
|
||
r = client.post("/api/v1/trade-in/support/messages", json={"text": "hi"}, headers=_auth())
|
||
assert r.status_code == 503
|
||
|
||
|
||
# ── happy path + mirror content ──────────────────────────────────────────────
|
||
|
||
|
||
def test_send_message_happy_path_mirrors_with_website_marker(
|
||
client: TestClient, db: MagicMock, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
|
||
) -> None:
|
||
monkeypatch.setattr(support_module.storage, "get_or_create_thread", lambda db, username: 7)
|
||
recorded = {}
|
||
|
||
def fake_record_inbound(db, *, thread_id, text_body, topic_message_id):
|
||
recorded.update(thread_id=thread_id, text_body=text_body, topic_message_id=topic_message_id)
|
||
return {
|
||
"id": 100,
|
||
"direction": "in",
|
||
"text_body": text_body,
|
||
"operator_tg_id": None,
|
||
"created_at": "2026-07-26T00:00:00+00:00",
|
||
}
|
||
|
||
monkeypatch.setattr(support_module.storage, "record_inbound", fake_record_inbound)
|
||
|
||
r = client.post(
|
||
"/api/v1/trade-in/support/messages",
|
||
json={"text": "У меня вопрос про trade-in"},
|
||
headers=_auth("kopylov"),
|
||
)
|
||
|
||
assert r.status_code == 200, r.text
|
||
body = r.json()
|
||
assert body["id"] == 100
|
||
assert body["direction"] == "in"
|
||
|
||
# Зеркало помечено "С САЙТА" + username — оператор не путает с TG-клиентом.
|
||
assert len(_fake_telegram_client.calls) == 1
|
||
mirror_call = _fake_telegram_client.calls[0]
|
||
assert "С САЙТА" in mirror_call["text"]
|
||
assert "kopylov" in mirror_call["text"]
|
||
assert "У меня вопрос про trade-in" in mirror_call["text"]
|
||
assert mirror_call["chat_id"] == support_module.settings.telegram_support_chat_id
|
||
assert mirror_call["message_thread_id"] == 42
|
||
|
||
assert recorded["thread_id"] == 7
|
||
assert recorded["topic_message_id"] == 555 # из FakeTelegramClient.send_message result
|
||
assert db.commit.called
|
||
|
||
|
||
def test_send_message_telegram_failure_returns_502_and_does_not_persist(
|
||
client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
|
||
) -> None:
|
||
_fake_telegram_client._response = TelegramApiError("sendMessage", 400, "chat not found")
|
||
monkeypatch.setattr(support_module.storage, "get_or_create_thread", lambda db, username: 7)
|
||
record_called = []
|
||
monkeypatch.setattr(
|
||
support_module.storage,
|
||
"record_inbound",
|
||
lambda *a, **kw: record_called.append(1),
|
||
)
|
||
|
||
r = client.post("/api/v1/trade-in/support/messages", json={"text": "hi"}, headers=_auth())
|
||
|
||
assert r.status_code == 502
|
||
assert record_called == [] # неотправленное сообщение не персистится
|
||
|
||
|
||
# ── rate limit ────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_send_message_rate_limited_429(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
monkeypatch.setattr(
|
||
support_module, "_send_limiter", SlidingWindowLimiter(limit=1, window_s=60.0)
|
||
)
|
||
monkeypatch.setattr(support_module.storage, "get_or_create_thread", lambda db, username: 1)
|
||
monkeypatch.setattr(
|
||
support_module.storage,
|
||
"record_inbound",
|
||
lambda *a, **kw: {
|
||
"id": 1,
|
||
"direction": "in",
|
||
"text_body": kw["text_body"],
|
||
"operator_tg_id": None,
|
||
"created_at": "2026-07-26T00:00:00+00:00",
|
||
},
|
||
)
|
||
|
||
first = client.post("/api/v1/trade-in/support/messages", json={"text": "one"}, headers=_auth())
|
||
assert first.status_code == 200, first.text
|
||
|
||
second = client.post("/api/v1/trade-in/support/messages", json={"text": "two"}, headers=_auth())
|
||
assert second.status_code == 429
|
||
assert "Retry-After" in second.headers
|
||
|
||
|
||
def test_send_message_rate_limit_is_per_user(
|
||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
monkeypatch.setattr(
|
||
support_module, "_send_limiter", SlidingWindowLimiter(limit=1, window_s=60.0)
|
||
)
|
||
monkeypatch.setattr(support_module.storage, "get_or_create_thread", lambda db, username: 1)
|
||
monkeypatch.setattr(
|
||
support_module.storage,
|
||
"record_inbound",
|
||
lambda *a, **kw: {
|
||
"id": 1,
|
||
"direction": "in",
|
||
"text_body": kw["text_body"],
|
||
"operator_tg_id": None,
|
||
"created_at": "2026-07-26T00:00:00+00:00",
|
||
},
|
||
)
|
||
|
||
alice = client.post(
|
||
"/api/v1/trade-in/support/messages", json={"text": "one"}, headers=_auth("alice")
|
||
)
|
||
assert alice.status_code == 200
|
||
|
||
bob = client.post(
|
||
"/api/v1/trade-in/support/messages", json={"text": "one"}, headers=_auth("bob")
|
||
)
|
||
assert bob.status_code == 200 # свой ключ лимита — не затронут alice-лимитом
|
||
|
||
|
||
# ── thread isolation (own thread only, no thread_id param exists) ───────────
|
||
|
||
|
||
def test_list_messages_resolves_by_own_username_ignores_foreign_params(
|
||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""Никакого способа адресовать чужой тред: даже если запрос несёт посторонний
|
||
`thread_id`/`username` в query — эндпоинт их не читает, резолвит ИСКЛЮЧИТЕЛЬНО
|
||
по X-Authenticated-User."""
|
||
seen_usernames = []
|
||
|
||
def fake_find_thread_id(db, username):
|
||
seen_usernames.append(username)
|
||
return None
|
||
|
||
monkeypatch.setattr(support_module.storage, "find_thread_id", fake_find_thread_id)
|
||
|
||
r = client.get(
|
||
"/api/v1/trade-in/support/messages",
|
||
params={"thread_id": 999, "username": "someone-else", "since": 0},
|
||
headers=_auth("alice"),
|
||
)
|
||
assert r.status_code == 200
|
||
assert r.json() == []
|
||
assert seen_usernames == ["alice"] # username из заголовка — параметры query проигнорированы
|
||
|
||
|
||
def test_list_messages_returns_thread_scoped_rows(
|
||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
monkeypatch.setattr(support_module.storage, "find_thread_id", lambda db, username: 7)
|
||
monkeypatch.setattr(
|
||
support_module.storage,
|
||
"list_messages",
|
||
lambda db, *, thread_id, since_id: [
|
||
{
|
||
"id": 1,
|
||
"direction": "in",
|
||
"text_body": "hi",
|
||
"operator_tg_id": None,
|
||
"created_at": "2026-07-26T00:00:00+00:00",
|
||
}
|
||
]
|
||
if thread_id == 7
|
||
else [],
|
||
)
|
||
r = client.get("/api/v1/trade-in/support/messages", params={"since": 0}, headers=_auth("alice"))
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert len(body) == 1
|
||
assert body[0]["text_body"] == "hi"
|
||
|
||
|
||
def test_two_users_get_independent_threads(
|
||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
thread_by_user = {"alice": 1, "bob": 2}
|
||
monkeypatch.setattr(
|
||
support_module.storage, "find_thread_id", lambda db, username: thread_by_user.get(username)
|
||
)
|
||
monkeypatch.setattr(
|
||
support_module.storage,
|
||
"list_messages",
|
||
lambda db, *, thread_id, since_id: [
|
||
{
|
||
"id": 1,
|
||
"direction": "in",
|
||
"text_body": f"secret-of-thread-{thread_id}",
|
||
"operator_tg_id": None,
|
||
"created_at": "2026-07-26T00:00:00+00:00",
|
||
}
|
||
],
|
||
)
|
||
|
||
alice_resp = client.get("/api/v1/trade-in/support/messages", headers=_auth("alice")).json()
|
||
bob_resp = client.get("/api/v1/trade-in/support/messages", headers=_auth("bob")).json()
|
||
|
||
assert alice_resp[0]["text_body"] == "secret-of-thread-1"
|
||
assert bob_resp[0]["text_body"] == "secret-of-thread-2"
|
||
assert alice_resp != bob_resp
|
||
|
||
|
||
# ── unread / read ─────────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_unread_no_thread_returns_zero_without_querying_count(
|
||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
monkeypatch.setattr(support_module.storage, "find_thread_id", lambda db, username: None)
|
||
count_called = []
|
||
monkeypatch.setattr(
|
||
support_module.storage, "count_unread", lambda *a, **kw: count_called.append(1)
|
||
)
|
||
|
||
r = client.get("/api/v1/trade-in/support/unread", headers=_auth())
|
||
assert r.status_code == 200
|
||
assert r.json() == {"unread": 0}
|
||
assert count_called == []
|
||
|
||
|
||
def test_unread_delegates_to_storage(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
monkeypatch.setattr(support_module.storage, "find_thread_id", lambda db, username: 7)
|
||
monkeypatch.setattr(support_module.storage, "count_unread", lambda db, *, thread_id: 3)
|
||
|
||
r = client.get("/api/v1/trade-in/support/unread", headers=_auth())
|
||
assert r.status_code == 200
|
||
assert r.json() == {"unread": 3}
|
||
|
||
|
||
def test_mark_read_noop_when_no_thread(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
monkeypatch.setattr(support_module.storage, "find_thread_id", lambda db, username: None)
|
||
mark_called = []
|
||
monkeypatch.setattr(support_module.storage, "mark_read", lambda *a, **kw: mark_called.append(1))
|
||
|
||
r = client.post("/api/v1/trade-in/support/read", headers=_auth())
|
||
assert r.status_code == 200
|
||
assert r.json() == {"status": "ok"}
|
||
assert mark_called == []
|
||
|
||
|
||
def test_mark_read_calls_storage_when_thread_exists(
|
||
client: TestClient, db: MagicMock, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
monkeypatch.setattr(support_module.storage, "find_thread_id", lambda db, username: 7)
|
||
mark_called = []
|
||
monkeypatch.setattr(
|
||
support_module.storage, "mark_read", lambda db, *, thread_id: mark_called.append(thread_id)
|
||
)
|
||
|
||
r = client.post("/api/v1/trade-in/support/read", headers=_auth())
|
||
assert r.status_code == 200
|
||
assert mark_called == [7]
|
||
assert db.commit.called
|