feat(tradein/support): чат поддержки без входа — экран логина и «доступа нет» #2577
10 changed files with 604 additions and 36 deletions
|
|
@ -29,20 +29,48 @@ support-моста (`app.services.tgbot.bridge`, data/sql/186_tg_support.sql).
|
|||
`username` — thread_id для отправки не нужен вообще, поэтому эту БД-операцию
|
||||
можно безопасно отложить до после успешного sendMessage. Бонус: неудачная
|
||||
отправка больше не создаёт тред.
|
||||
|
||||
Анонимная ветка (`/support/anon/*`, инцидент 2026-07-31)
|
||||
-------------------------------------------------------
|
||||
Ровно те же 4 действия, но БЕЗ авторизации — доступны с экрана входа. Причина:
|
||||
после cutover'а на свою авторизацию (#2558) единственным каналом в поддержку был
|
||||
чат ЗА логином, а самая частая причина писать в поддержку — как раз «не могу
|
||||
войти». 2026-07-31 «Практика» весь день билась в форму (5 login_failed, 0
|
||||
успешных) и достучаться из продукта не могла ничем.
|
||||
|
||||
Идентичность анонима — opaque-токен в httpOnly-куке (`_ANON_COOKIE_NAME`),
|
||||
тред живёт в тех же `web_support_threads` под ключом `anon:<token>`. Двоеточие
|
||||
делает коллизию с реальным логином структурно невозможной: `tradein_users`
|
||||
допускает только `^[A-Za-z0-9._-]{3,64}$` (CHECK из миграции 193 + Pydantic),
|
||||
двоеточия там быть не может — аноним НИКОГДА не попадёт в чужой тред и не
|
||||
«станет» существующим юзером.
|
||||
|
||||
Изоляция тредов та же, что у авторизованной ветки, и по той же причине:
|
||||
thread_id не принимается снаружи ни в каком виде, тред резолвится
|
||||
ИСКЛЮЧИТЕЛЬНО из куки. Кука здесь — bearer-токен своего треда, поэтому
|
||||
httpOnly+Secure+SameSite=Lax (как session-cookie) и `token_urlsafe(18)`
|
||||
(144 бита) вместо чего-то угадываемого.
|
||||
|
||||
В Telegram-топик уходит НЕ сам токен, а `anon-<6 hex от sha256(токен)>`
|
||||
(`_anon_display_id`): оператору нужен стабильный ярлык треда, а не bearer —
|
||||
зеркало топика читают люди и пересылают дальше.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.db import get_db
|
||||
from app.core.ratelimit import SlidingWindowLimiter
|
||||
from app.core.ratelimit import SlidingWindowLimiter, _client_ip
|
||||
from app.services.tgbot import web_support_storage as storage
|
||||
from app.services.tgbot.bridge import SERVICE_UNAVAILABLE_TEXT
|
||||
from app.services.tgbot.client import TelegramApiError, TelegramClient
|
||||
|
|
@ -262,3 +290,204 @@ def mark_support_read(
|
|||
storage.mark_read(db, thread_id=thread_id)
|
||||
db.commit()
|
||||
return StatusOut()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Анонимная ветка — поддержка без входа (см. блок в докстринге модуля)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_ANON_COOKIE_NAME = "tradein_support_anon"
|
||||
# 30 дней: тред должен пережить «напишу вечером — отвечут утром», но не жить вечно.
|
||||
_ANON_COOKIE_MAX_AGE_S = 30 * 24 * 3600
|
||||
# Двоеточие → структурная невозможность коллизии с реальным логином (докстринг).
|
||||
_ANON_THREAD_PREFIX = "anon:"
|
||||
# Форма того, что МЫ выдаём (`token_urlsafe(18)` → 24 символа из [A-Za-z0-9_-]).
|
||||
# Кука клиент-контролируема: без этой проверки в ключ треда (а значит в SQL-параметр
|
||||
# и в лог) уехала бы произвольная строка из браузера. Не матчится — считаем куку
|
||||
# отсутствующей и выдаём новую, а не пытаемся «починить» присланное.
|
||||
_ANON_TOKEN_RE = re.compile(r"^[A-Za-z0-9_-]{16,64}\Z")
|
||||
|
||||
# Публичная ручка записи в общий Telegram-топик — поверхность для спама, которой у
|
||||
# авторизованной ветки нет. Два независимых бюджета:
|
||||
# 1) per-token (`_send_limiter`, 12/мин — тот же объект, ключи не пересекаются:
|
||||
# анонимные начинаются с "anon:", что невозможно для username);
|
||||
# 2) per-IP — именно он ловит обход ротацией куки (сбросил куку → новый токен →
|
||||
# бюджет (1) снова пуст). Окно широкое и щедрое для живого диалога: реальный
|
||||
# сценарий — «не могу войти, помогите», несколько сообщений подряд.
|
||||
_ANON_IP_RATE_LIMIT = 10
|
||||
_ANON_IP_RATE_WINDOW_S = 600.0
|
||||
_anon_ip_limiter = SlidingWindowLimiter(limit=_ANON_IP_RATE_LIMIT, window_s=_ANON_IP_RATE_WINDOW_S)
|
||||
|
||||
|
||||
def _anon_display_id(token: str) -> str:
|
||||
"""Стабильный НЕсекретный ярлык треда для оператора — см. докстринг модуля.
|
||||
|
||||
sha256, а не префикс токена: префикс — это часть bearer'а, а зеркало уходит
|
||||
в Telegram-топик, который читают люди и пересылают дальше.
|
||||
"""
|
||||
return f"anon-{hashlib.sha256(token.encode('utf-8')).hexdigest()[:6]}"
|
||||
|
||||
|
||||
def _read_anon_token(request: Request) -> str | None:
|
||||
"""Токен из куки, если он валидной формы; иначе None (кука считается отсутствующей)."""
|
||||
raw = request.cookies.get(_ANON_COOKIE_NAME)
|
||||
if raw is None or not _ANON_TOKEN_RE.match(raw):
|
||||
return None
|
||||
return raw
|
||||
|
||||
|
||||
def _anon_thread_key(token: str) -> str:
|
||||
return f"{_ANON_THREAD_PREFIX}{token}"
|
||||
|
||||
|
||||
def _set_anon_cookie(response: Response, token: str) -> None:
|
||||
response.set_cookie(
|
||||
key=_ANON_COOKIE_NAME,
|
||||
value=token,
|
||||
max_age=_ANON_COOKIE_MAX_AGE_S,
|
||||
httponly=True,
|
||||
secure=True,
|
||||
samesite="lax",
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/support/anon/messages", response_model=SupportMessageOut)
|
||||
async def send_anon_support_message(
|
||||
payload: SupportMessageInput,
|
||||
request: Request,
|
||||
response: Response,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> SupportMessageOut:
|
||||
"""Сообщение в поддержку БЕЗ входа. Порядок операций — как в авторизованной
|
||||
ветке (H1 в докстринге модуля): БД трогаем только после успешного sendMessage.
|
||||
|
||||
Кука выставляется тоже только на успехе — иначе первая же неудачная попытка
|
||||
(бот не настроен / Telegram лёг) закрепляла бы за посетителем пустой тред.
|
||||
"""
|
||||
if not _bot_configured():
|
||||
raise HTTPException(status_code=503, detail=SERVICE_UNAVAILABLE_TEXT)
|
||||
|
||||
token = _read_anon_token(request)
|
||||
is_new_token = token is None
|
||||
if token is None:
|
||||
token = secrets.token_urlsafe(18)
|
||||
thread_key = _anon_thread_key(token)
|
||||
ip = _client_ip(request)
|
||||
|
||||
# Оба бюджета — non-destructive peek (review L3): неудачная отправка не
|
||||
# должна стоить посетителю попытки. `.record()` только на успех, ниже.
|
||||
for retry_after in (_send_limiter.retry_after(thread_key), _anon_ip_limiter.retry_after(ip)):
|
||||
if retry_after is not None:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Слишком много сообщений. Попробуйте позже.",
|
||||
headers={"Retry-After": str(int(retry_after) + 1)},
|
||||
)
|
||||
|
||||
display_id = _anon_display_id(token)
|
||||
client = TelegramClient(settings.telegram_bot_token)
|
||||
try:
|
||||
mirrored = await client.send_message(
|
||||
chat_id=settings.telegram_support_chat_id,
|
||||
text=_format_anon_mirror_text(display_id, payload.text),
|
||||
message_thread_id=settings.telegram_support_topic_id or None,
|
||||
timeout=_INTERACTIVE_SEND_TIMEOUT_S,
|
||||
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
|
||||
)
|
||||
except TelegramApiError:
|
||||
# Ни текст сообщения (ПДн), ни токен (bearer треда) в лог не попадают.
|
||||
logger.exception(
|
||||
"web support (anon): не удалось отправить зеркало в топик (%s)", display_id
|
||||
)
|
||||
raise HTTPException(status_code=502, detail=SERVICE_UNAVAILABLE_TEXT) from None
|
||||
|
||||
_send_limiter.record(thread_key)
|
||||
_anon_ip_limiter.record(ip)
|
||||
|
||||
topic_message_id = mirrored.get("message_id") if isinstance(mirrored, dict) else None
|
||||
if topic_message_id is None:
|
||||
logger.warning(
|
||||
"web support (anon): Telegram sendMessage не вернул message_id (%s) — "
|
||||
"ответ оператора на это сообщение не будет смаршрутизирован",
|
||||
display_id,
|
||||
)
|
||||
|
||||
thread_id = storage.get_or_create_thread(db, thread_key)
|
||||
row = storage.record_inbound(
|
||||
db,
|
||||
thread_id=thread_id,
|
||||
text_body=payload.text,
|
||||
topic_message_id=topic_message_id,
|
||||
support_chat_id=settings.telegram_support_chat_id,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
if is_new_token:
|
||||
_set_anon_cookie(response, token)
|
||||
logger.info("web support (anon): message sent %s thread_id=%d", display_id, thread_id)
|
||||
return SupportMessageOut(**row)
|
||||
|
||||
|
||||
def _format_anon_mirror_text(display_id: str, message_text: str) -> str:
|
||||
"""Помечает зеркало как пришедшее с сайта ОТ НЕЗАЛОГИНЕННОГО посетителя.
|
||||
|
||||
Оператору это ключевой контекст: у такого обращения нет аккаунта, по которому
|
||||
можно посмотреть историю, и самая вероятная причина написать — как раз
|
||||
невозможность войти (инцидент 2026-07-31).
|
||||
"""
|
||||
return f"[С САЙТА · БЕЗ ВХОДА] {display_id}:\n{message_text}"
|
||||
|
||||
|
||||
@router.get("/support/anon/messages", response_model=list[SupportMessageOut])
|
||||
def list_anon_support_messages(
|
||||
request: Request,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
since: Annotated[int, Query(ge=0)] = 0,
|
||||
) -> list[SupportMessageOut]:
|
||||
"""Свой тред по куке. Нет куки / нет треда → пустой список, НЕ 401: виджет
|
||||
поллит эту ручку и до первого сообщения, 401 там был бы ложной ошибкой.
|
||||
|
||||
Sync `def` (review M3) — см. `list_support_messages`.
|
||||
"""
|
||||
token = _read_anon_token(request)
|
||||
if token is None:
|
||||
return []
|
||||
thread_id = storage.find_thread_id(db, _anon_thread_key(token))
|
||||
if thread_id is None:
|
||||
return []
|
||||
rows = storage.list_messages(
|
||||
db, thread_id=thread_id, since_id=since, limit=_LIST_MESSAGES_LIMIT
|
||||
)
|
||||
return [SupportMessageOut(**r) for r in rows]
|
||||
|
||||
|
||||
@router.get("/support/anon/unread", response_model=UnreadOut)
|
||||
def get_anon_support_unread(
|
||||
request: Request,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> UnreadOut:
|
||||
"""Sync `def` (review M3) — см. `list_support_messages`."""
|
||||
token = _read_anon_token(request)
|
||||
if token is None:
|
||||
return UnreadOut(unread=0)
|
||||
thread_id = storage.find_thread_id(db, _anon_thread_key(token))
|
||||
if thread_id is None:
|
||||
return UnreadOut(unread=0)
|
||||
return UnreadOut(unread=storage.count_unread(db, thread_id=thread_id))
|
||||
|
||||
|
||||
@router.post("/support/anon/read", response_model=StatusOut)
|
||||
def mark_anon_support_read(
|
||||
request: Request,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> StatusOut:
|
||||
"""Sync `def` (review M3) — см. `list_support_messages`."""
|
||||
token = _read_anon_token(request)
|
||||
if token is None:
|
||||
return StatusOut()
|
||||
thread_id = storage.find_thread_id(db, _anon_thread_key(token))
|
||||
if thread_id is not None:
|
||||
storage.mark_read(db, thread_id=thread_id)
|
||||
db.commit()
|
||||
return StatusOut()
|
||||
|
|
|
|||
|
|
@ -53,6 +53,15 @@ _ADMIN_API_RE = re.compile(r"^/api/v1/admin/")
|
|||
# rate-limit у /login отдельный (app.api.v1.auth._LOGIN_LIMITER), RateLimitMiddleware
|
||||
# на /api/* всё равно применяется — это ослабляет ТОЛЬКО rbac_guard'овский
|
||||
# auth-required gate, не остальные защиты.
|
||||
#
|
||||
# Инцидент 2026-07-31: /api/v1/trade-in/support/anon/* — по той же логике. Единственным
|
||||
# каналом в поддержку был чат ЗА логином, а типовая причина писать в поддержку —
|
||||
# «не могу войти» (в тот день так и вышло: «Практика» билась в форму весь день и
|
||||
# достучаться из продукта не могла). Ветка НЕ трогает авторизованные
|
||||
# /api/v1/trade-in/support/* — те по-прежнему требуют identity; у анонимной свой,
|
||||
# заведомо более узкий бюджет (per-token + per-IP, см. app.api.v1.support) и своя
|
||||
# идентичность из httpOnly-куки, которая структурно не может совпасть с чьим-то
|
||||
# логином.
|
||||
_PUBLIC_PATHS = frozenset(
|
||||
{
|
||||
"/health",
|
||||
|
|
@ -61,6 +70,12 @@ _PUBLIC_PATHS = frozenset(
|
|||
"/openapi.json",
|
||||
"/api/v1/auth/login",
|
||||
"/api/v1/auth/logout",
|
||||
# NB: префикс — /api/v1/trade-in (app/main.py include_router), а Caddy
|
||||
# срезает ВНЕШНИЙ /trade-in ещё раньше. Т.е. снаружи это
|
||||
# /trade-in/api/v1/trade-in/support/anon/*, сюда приходит вот такое.
|
||||
"/api/v1/trade-in/support/anon/messages",
|
||||
"/api/v1/trade-in/support/anon/unread",
|
||||
"/api/v1/trade-in/support/anon/read",
|
||||
}
|
||||
)
|
||||
# #R2-H3: Caddy срезает внешний префикс /trade-in (uri strip_prefix) перед
|
||||
|
|
|
|||
|
|
@ -73,6 +73,16 @@ def _build_test_app() -> FastAPI:
|
|||
async def health() -> dict:
|
||||
return {"status": "ok"}
|
||||
|
||||
# Анонимная поддержка (инцидент 2026-07-31) — публичная ветка рядом с
|
||||
# авторизованной, чтобы тесты ниже проверяли ИМЕННО границу между ними.
|
||||
@app.get("/api/v1/trade-in/support/anon/unread")
|
||||
async def anon_support_unread() -> dict:
|
||||
return {"unread": 0}
|
||||
|
||||
@app.get("/api/v1/trade-in/support/unread")
|
||||
async def support_unread() -> dict:
|
||||
return {"unread": 0}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
|
|
@ -253,6 +263,24 @@ def test_rbac_guard_skips_health(client: TestClient) -> None:
|
|||
assert resp.json()["status"] == "ok"
|
||||
|
||||
|
||||
def test_rbac_guard_lets_anon_support_through_without_identity(client: TestClient) -> None:
|
||||
"""Инцидент 2026-07-31: поддержка должна работать БЕЗ входа — иначе тот, кто
|
||||
не может залогиниться, не может и пожаловаться на это."""
|
||||
resp = client.get("/api/v1/trade-in/support/anon/unread")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"unread": 0}
|
||||
|
||||
|
||||
def test_rbac_guard_still_gates_authenticated_support(client: TestClient) -> None:
|
||||
"""Обратная сторона той же границы: анонимная ветка НЕ распахнула соседний
|
||||
авторизованный support (тред залогиненного юзера по-прежнему за identity)."""
|
||||
assert client.get("/api/v1/trade-in/support/unread").status_code == 401
|
||||
resp = client.get(
|
||||
"/api/v1/trade-in/support/unread", headers={"X-Authenticated-User": "nosuchuser"}
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_rbac_guard_pilot_can_hit_non_admin_api(client: TestClient) -> None:
|
||||
resp = client.get(
|
||||
"/api/v1/me",
|
||||
|
|
|
|||
|
|
@ -87,7 +87,11 @@ def client(db: MagicMock) -> TestClient:
|
|||
yield db
|
||||
|
||||
app.dependency_overrides[get_db] = fake_db
|
||||
return TestClient(app)
|
||||
# https, а не дефолтный http: анонимная ветка ставит идентити-куку с
|
||||
# `secure=True` (как session-cookie), и по http httpx её не вернул бы в
|
||||
# следующем запросе — тесты «тот же тред / тот же бюджет лимита» тихо
|
||||
# проверяли бы каждый раз НОВОГО анонима. Прод и так только https.
|
||||
return TestClient(app, base_url="https://testserver")
|
||||
|
||||
|
||||
def _auth(username: str = "alice") -> dict[str, str]:
|
||||
|
|
@ -417,17 +421,19 @@ def test_list_messages_returns_thread_scoped_rows(
|
|||
monkeypatch.setattr(
|
||||
support_module.storage,
|
||||
"list_messages",
|
||||
lambda db, *, thread_id, since_id, limit: [
|
||||
{
|
||||
"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 [],
|
||||
lambda db, *, thread_id, since_id, limit: (
|
||||
[
|
||||
{
|
||||
"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
|
||||
|
|
@ -534,3 +540,194 @@ def test_mark_read_calls_storage_when_thread_exists(
|
|||
assert r.status_code == 200
|
||||
assert mark_called == [7]
|
||||
assert db.commit.called
|
||||
|
||||
|
||||
# ── анонимная ветка: поддержка без входа (инцидент 2026-07-31) ────────────────
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fresh_anon_ip_limiter(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Как `_fresh_rate_limiter`, но для per-IP бюджета анонимной ветки — иначе
|
||||
состояние течёт между тестами в одном процессе pytest."""
|
||||
monkeypatch.setattr(
|
||||
support_module, "_anon_ip_limiter", SlidingWindowLimiter(limit=1000, window_s=60.0)
|
||||
)
|
||||
|
||||
|
||||
def _patch_anon_storage(monkeypatch: pytest.MonkeyPatch) -> list[str]:
|
||||
"""Мокает storage для send-пути и возвращает список ключей тредов, с которыми
|
||||
его позвали (проверяем, что аноним адресуется `anon:<token>`, а не логином)."""
|
||||
seen_keys: list[str] = []
|
||||
|
||||
def fake_get_or_create(db: Any, username: str) -> int:
|
||||
seen_keys.append(username)
|
||||
return 1
|
||||
|
||||
monkeypatch.setattr(support_module.storage, "get_or_create_thread", fake_get_or_create)
|
||||
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-31T00:00:00+00:00",
|
||||
},
|
||||
)
|
||||
return seen_keys
|
||||
|
||||
|
||||
def test_anon_send_without_any_auth_succeeds_and_sets_cookie(
|
||||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Суть фичи: залогиниться нельзя, а написать в поддержку — можно."""
|
||||
seen_keys = _patch_anon_storage(monkeypatch)
|
||||
|
||||
r = client.post("/api/v1/trade-in/support/anon/messages", json={"text": "не могу войти"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["text_body"] == "не могу войти"
|
||||
|
||||
token = client.cookies.get(support_module._ANON_COOKIE_NAME)
|
||||
assert token is not None
|
||||
assert support_module._ANON_TOKEN_RE.match(token)
|
||||
# Тред адресован анонимным ключом, не голым токеном и не чьим-то логином.
|
||||
assert seen_keys == [f"anon:{token}"]
|
||||
|
||||
|
||||
def test_anon_cookie_reused_across_messages_same_thread(
|
||||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
seen_keys = _patch_anon_storage(monkeypatch)
|
||||
|
||||
client.post("/api/v1/trade-in/support/anon/messages", json={"text": "первое"})
|
||||
token_after_first = client.cookies.get(support_module._ANON_COOKIE_NAME)
|
||||
client.post("/api/v1/trade-in/support/anon/messages", json={"text": "второе"})
|
||||
|
||||
assert client.cookies.get(support_module._ANON_COOKIE_NAME) == token_after_first
|
||||
assert seen_keys == [f"anon:{token_after_first}"] * 2
|
||||
|
||||
|
||||
def test_anon_mirror_is_labelled_and_never_leaks_token(
|
||||
client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
|
||||
) -> None:
|
||||
"""Оператор видит, что это НЕзалогиненный посетитель, но bearer треда в
|
||||
Telegram-топик не уходит (топик читают люди и пересылают дальше)."""
|
||||
_patch_anon_storage(monkeypatch)
|
||||
|
||||
client.post("/api/v1/trade-in/support/anon/messages", json={"text": "помогите"})
|
||||
token = client.cookies.get(support_module._ANON_COOKIE_NAME)
|
||||
sent_text = _fake_telegram_client.calls[-1]["text"]
|
||||
|
||||
assert sent_text.startswith("[С САЙТА · БЕЗ ВХОДА] anon-")
|
||||
assert "помогите" in sent_text
|
||||
assert token not in sent_text
|
||||
assert support_module._anon_display_id(token) in sent_text
|
||||
|
||||
|
||||
def test_anon_read_paths_without_cookie_are_empty_not_401(client: TestClient) -> None:
|
||||
"""Виджет поллит эти ручки ДО первого сообщения — 401 там был бы ложной ошибкой."""
|
||||
assert client.get("/api/v1/trade-in/support/anon/messages").status_code == 200
|
||||
assert client.get("/api/v1/trade-in/support/anon/messages").json() == []
|
||||
assert client.get("/api/v1/trade-in/support/anon/unread").json() == {"unread": 0}
|
||||
assert client.post("/api/v1/trade-in/support/anon/read").json() == {"status": "ok"}
|
||||
|
||||
|
||||
def test_anon_malformed_cookie_ignored_and_never_reaches_storage(
|
||||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Кука клиент-контролируема: мусор из браузера не должен становиться ключом
|
||||
треда. Считаем куку отсутствующей и выдаём новую."""
|
||||
seen_keys = _patch_anon_storage(monkeypatch)
|
||||
bogus = "not-a-valid-token!@#$%^"
|
||||
client.cookies.set(support_module._ANON_COOKIE_NAME, bogus)
|
||||
|
||||
r = client.post("/api/v1/trade-in/support/anon/messages", json={"text": "hi"})
|
||||
assert r.status_code == 200
|
||||
|
||||
assert len(seen_keys) == 1
|
||||
assert bogus not in seen_keys[0]
|
||||
assert seen_keys[0].startswith("anon:")
|
||||
assert support_module._ANON_TOKEN_RE.match(seen_keys[0].removeprefix("anon:"))
|
||||
|
||||
|
||||
def test_anon_read_path_with_malformed_cookie_returns_empty(
|
||||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
find_calls = []
|
||||
monkeypatch.setattr(
|
||||
support_module.storage,
|
||||
"find_thread_id",
|
||||
lambda db, username: find_calls.append(username),
|
||||
)
|
||||
client.cookies.set(support_module._ANON_COOKIE_NAME, "!!not-a-token!!")
|
||||
|
||||
assert client.get("/api/v1/trade-in/support/anon/messages").json() == []
|
||||
assert find_calls == [] # до storage мусор не доехал вообще
|
||||
|
||||
|
||||
def test_anon_per_ip_rate_limit_429(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Главный анти-абуз: per-token бюджет обходится сбросом куки, per-IP — нет."""
|
||||
_patch_anon_storage(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
support_module, "_anon_ip_limiter", SlidingWindowLimiter(limit=1, window_s=60.0)
|
||||
)
|
||||
|
||||
assert (
|
||||
client.post("/api/v1/trade-in/support/anon/messages", json={"text": "1"}).status_code == 200
|
||||
)
|
||||
# Ротация куки НЕ спасает — бюджет привязан к IP.
|
||||
client.cookies.delete(support_module._ANON_COOKIE_NAME)
|
||||
r = client.post("/api/v1/trade-in/support/anon/messages", json={"text": "2"})
|
||||
assert r.status_code == 429
|
||||
assert "Retry-After" in r.headers
|
||||
|
||||
|
||||
def test_anon_per_token_rate_limit_429(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_patch_anon_storage(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
support_module, "_send_limiter", SlidingWindowLimiter(limit=1, window_s=60.0)
|
||||
)
|
||||
|
||||
assert (
|
||||
client.post("/api/v1/trade-in/support/anon/messages", json={"text": "1"}).status_code == 200
|
||||
)
|
||||
assert (
|
||||
client.post("/api/v1/trade-in/support/anon/messages", json={"text": "2"}).status_code == 429
|
||||
)
|
||||
|
||||
|
||||
def test_anon_failed_send_sets_no_cookie_and_writes_nothing(
|
||||
client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
|
||||
) -> None:
|
||||
"""Неудачная отправка не должна закреплять за посетителем пустой тред."""
|
||||
seen_keys = _patch_anon_storage(monkeypatch)
|
||||
_fake_telegram_client._response = TelegramApiError("sendMessage", 500, "boom")
|
||||
|
||||
r = client.post("/api/v1/trade-in/support/anon/messages", json={"text": "hi"})
|
||||
assert r.status_code == 502
|
||||
assert seen_keys == []
|
||||
assert client.cookies.get(support_module._ANON_COOKIE_NAME) is None
|
||||
|
||||
|
||||
def test_anon_bot_not_configured_503(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(support_module.settings, "telegram_bot_token", "")
|
||||
r = client.post("/api/v1/trade-in/support/anon/messages", json={"text": "hi"})
|
||||
assert r.status_code == 503
|
||||
assert client.cookies.get(support_module._ANON_COOKIE_NAME) is None
|
||||
|
||||
|
||||
def test_anon_blank_text_422(client: TestClient) -> None:
|
||||
r = client.post("/api/v1/trade-in/support/anon/messages", json={"text": " "})
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
def test_anon_thread_key_cannot_collide_with_real_username() -> None:
|
||||
"""Инвариант изоляции: `anon:` невозможен в реальном логине (CHECK миграции
|
||||
193 + Pydantic `^[A-Za-z0-9._-]{3,64}$`), значит аноним структурно не может
|
||||
попасть в тред существующего пользователя."""
|
||||
from app.schemas.team import _USERNAME_RE
|
||||
|
||||
key = support_module._anon_thread_key(support_module.secrets.token_urlsafe(18))
|
||||
assert key.startswith("anon:")
|
||||
assert _USERNAME_RE.match(key) is None
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|||
|
||||
import { apiFetch, HTTPError } from "@/lib/api";
|
||||
import { ME_QUERY_KEY } from "@/lib/useMe";
|
||||
import { AnonSupportWidget } from "@/components/auth/AnonSupportWidget";
|
||||
|
||||
interface LoginInput {
|
||||
username: string;
|
||||
|
|
@ -282,7 +283,23 @@ export default function LoginPage() {
|
|||
"Войти"
|
||||
)}
|
||||
</button>
|
||||
|
||||
<p
|
||||
style={{
|
||||
margin: "16px 0 0",
|
||||
fontSize: 12,
|
||||
color: "var(--fg-tertiary)",
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
Не получается войти? Напишите нам — кнопка «Поддержка» в правом нижнем
|
||||
углу. Отвечаем без входа в систему.
|
||||
</p>
|
||||
</form>
|
||||
|
||||
{/* Инцидент 2026-07-31: без этого тот, кто не может залогиниться, не
|
||||
может и сообщить об этом — единственный чат был за логином. */}
|
||||
<AnonSupportWidget />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* Чат поддержки для экранов БЕЗ входа — экран логина и «доступа нет».
|
||||
*
|
||||
* WHY (инцидент 2026-07-31): после cutover'а на свою авторизацию (#2558)
|
||||
* единственным каналом в поддержку был чат ЗА логином, а самая частая причина
|
||||
* писать в поддержку — как раз «не могу войти». В тот день «Практика» весь день
|
||||
* билась в форму входа (5 неудачных попыток с трёх разных IP, ни одной успешной)
|
||||
* и достучаться до нас из продукта не могла ничем: на `/login` не было ни чата,
|
||||
* ни контакта.
|
||||
*
|
||||
* Технически это тот же `SupportButton` + `SupportChatPanel`, что и в `/v2`,
|
||||
* только в анонимном scope (`SupportScope = "anon"`, ручки `/support/anon/*`) —
|
||||
* тред резолвится из httpOnly-куки, а не из идентити. Провайдер здесь свой:
|
||||
* `SupportChatProvider` живёт в `app/v2/layout.tsx`, куда эти экраны не входят.
|
||||
*
|
||||
* Отдельный компонент, а не копипаста в двух местах: точек монтирования две
|
||||
* (login-страница и `NoAccessScreen`), и обе — тупики, из которых пользователю
|
||||
* больше некуда идти.
|
||||
*/
|
||||
|
||||
import { SupportButton } from "@/components/trade-in/v2/SupportButton";
|
||||
import { SupportChatProvider } from "@/components/trade-in/v2/SupportChatContext";
|
||||
|
||||
export function AnonSupportWidget() {
|
||||
return (
|
||||
<SupportChatProvider>
|
||||
<SupportButton scope="anon" />
|
||||
</SupportChatProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -7,9 +7,15 @@
|
|||
*
|
||||
* Fullscreen «доступа нет» — для 403 от /me или для denied path.
|
||||
* Token-based styling per `.claude/rules/ui-tokens.md` (см. globals.css).
|
||||
*
|
||||
* СОЗНАТЕЛЬНОЕ расхождение с зеркалом (2026-07-31): здесь внизу монтируется
|
||||
* `AnonSupportWidget`, в копии Site Finder'а его нет и быть не может — виджет
|
||||
* ходит в trade-in'овый support-бридж (`/api/v1/trade-in/support/anon/*`),
|
||||
* которого в том бэкенде не существует. Всё остальное держим в синхроне.
|
||||
*/
|
||||
|
||||
import { logout } from "@/lib/logout";
|
||||
import { AnonSupportWidget } from "@/components/auth/AnonSupportWidget";
|
||||
|
||||
interface NoAccessScreenProps {
|
||||
variant: "user" | "path" | "session" | "trial" | "error";
|
||||
|
|
@ -168,6 +174,12 @@ export function NoAccessScreen({ variant, path }: NoAccessScreenProps) {
|
|||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Второй тупик, из которого пользователю некуда идти (первый — /login).
|
||||
Анонимный scope, а не авторизованный: на variant="session"/"error"
|
||||
идентити уже нет, а разное поведение чата на соседних вариантах одного
|
||||
экрана — лишняя развилка на ровном месте. Инцидент 2026-07-31. */}
|
||||
<AnonSupportWidget />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import { tokens } from "./tokens";
|
|||
import { useSupportChat } from "./SupportChatContext";
|
||||
import { SupportChatPanel } from "./SupportChatPanel";
|
||||
import { useSupportUnread } from "@/lib/useSupportChat";
|
||||
import type { SupportScope } from "@/lib/useSupportChat";
|
||||
|
||||
const { accent, accentDeep, onAccent, surface, font, danger } = tokens;
|
||||
|
||||
|
|
@ -45,7 +46,16 @@ const styles = `
|
|||
}
|
||||
`;
|
||||
|
||||
export function SupportButton() {
|
||||
interface SupportButtonProps {
|
||||
/**
|
||||
* "anon" — экран входа / «доступа нет», где идентити нет и быть не может.
|
||||
* Дефолт "auth" — все существующие места монтирования (v2 layout) не меняются.
|
||||
* См. `SupportScope` в `@/lib/useSupportChat`.
|
||||
*/
|
||||
scope?: SupportScope;
|
||||
}
|
||||
|
||||
export function SupportButton({ scope = "auth" }: SupportButtonProps = {}) {
|
||||
// Portal-mount guard (SSR-safe): `document` only exists after mount
|
||||
// (mirrors MapPicker.tsx:107-108 / BuildingListingsDrawer.tsx:29-30).
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
|
@ -54,7 +64,7 @@ export function SupportButton() {
|
|||
const { open, toggleChat, closeChat } = useSupportChat();
|
||||
// Unread badge only matters while the panel is closed — see
|
||||
// useSupportUnread's docstring for why polling stops entirely once open.
|
||||
const unreadQuery = useSupportUnread(!open);
|
||||
const unreadQuery = useSupportUnread(!open, scope);
|
||||
const unread = unreadQuery.data?.unread ?? 0;
|
||||
|
||||
if (!mounted) return null;
|
||||
|
|
@ -139,7 +149,7 @@ export function SupportButton() {
|
|||
</span>
|
||||
)}
|
||||
</button>
|
||||
<SupportChatPanel open={open} onClose={closeChat} />
|
||||
<SupportChatPanel open={open} onClose={closeChat} scope={scope} />
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -24,11 +24,13 @@ import {
|
|||
useSendSupportMessage,
|
||||
useSupportMessages,
|
||||
} from "@/lib/useSupportChat";
|
||||
import type { SupportMessage } from "@/lib/useSupportChat";
|
||||
import type { SupportMessage, SupportScope } from "@/lib/useSupportChat";
|
||||
|
||||
interface SupportChatPanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** См. `SupportScope` — "anon" для экрана входа / «доступа нет». */
|
||||
scope?: SupportScope;
|
||||
}
|
||||
|
||||
const TIME_FORMAT = new Intl.DateTimeFormat("ru-RU", {
|
||||
|
|
@ -54,7 +56,11 @@ const PANEL_STYLES = `
|
|||
.support-chat-close:hover { border-color: ${tokens.accent}; color: ${tokens.accent}; }
|
||||
`;
|
||||
|
||||
export function SupportChatPanel({ open, onClose }: SupportChatPanelProps) {
|
||||
export function SupportChatPanel({
|
||||
open,
|
||||
onClose,
|
||||
scope = "auth",
|
||||
}: SupportChatPanelProps) {
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const listEndRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -64,9 +70,9 @@ export function SupportChatPanel({ open, onClose }: SupportChatPanelProps) {
|
|||
|
||||
const [draft, setDraft] = useState("");
|
||||
|
||||
const messagesQuery = useSupportMessages(open);
|
||||
const sendMessage = useSendSupportMessage();
|
||||
const markRead = useMarkSupportRead();
|
||||
const messagesQuery = useSupportMessages(open, scope);
|
||||
const sendMessage = useSendSupportMessage(scope);
|
||||
const markRead = useMarkSupportRead(scope);
|
||||
|
||||
// Mark the thread read on BOTH the closed->open and open->closed edges (the
|
||||
// effect fires on mount-while-open and its cleanup fires on the reverse
|
||||
|
|
|
|||
|
|
@ -39,6 +39,21 @@ import { apiFetch, HTTPError } from "@/lib/api";
|
|||
|
||||
const BASE = "/api/v1/trade-in/support";
|
||||
|
||||
/**
|
||||
* Какой поддержкой пользуемся:
|
||||
* "auth" — тред залогиненного юзера, резолвится сервером из сессии/идентити;
|
||||
* "anon" — тред посетителя БЕЗ входа (экран логина, «доступа нет»), резолвится
|
||||
* из httpOnly-куки, которую ставит бэкенд (`/support/anon/*`).
|
||||
*
|
||||
* Появилось после инцидента 2026-07-31: единственный канал в поддержку был ЗА
|
||||
* логином, а типовая причина писать — «не могу войти».
|
||||
*/
|
||||
export type SupportScope = "auth" | "anon";
|
||||
|
||||
function scopeBase(scope: SupportScope): string {
|
||||
return scope === "anon" ? `${BASE}/anon` : BASE;
|
||||
}
|
||||
|
||||
// Mirrors backend `MAX_MESSAGE_LENGTH` (app/api/v1/support.py) — enforced
|
||||
// client-side too so the send button disables before the round-trip 422/400.
|
||||
export const MAX_SUPPORT_MESSAGE_LENGTH = 4000;
|
||||
|
|
@ -57,8 +72,12 @@ export interface SupportUnread {
|
|||
unread: number;
|
||||
}
|
||||
|
||||
const SUPPORT_MESSAGES_KEY = ["trade-in", "support", "messages"] as const;
|
||||
const SUPPORT_UNREAD_KEY = ["trade-in", "support", "unread"] as const;
|
||||
// Scope — часть ключа: анонимный и авторизованный треды физически разные, общий
|
||||
// ключ склеил бы их в кэше (после логина в панели висела бы переписка анонима).
|
||||
const messagesKey = (scope: SupportScope) =>
|
||||
["trade-in", "support", scope, "messages"] as const;
|
||||
const unreadKey = (scope: SupportScope) =>
|
||||
["trade-in", "support", scope, "unread"] as const;
|
||||
|
||||
const MESSAGES_POLL_MS = 6_000;
|
||||
const UNREAD_POLL_MS = 20_000;
|
||||
|
|
@ -67,10 +86,11 @@ const UNREAD_POLL_MS = 20_000;
|
|||
* Polls the caller's own thread. `enabled` should be the chat-panel `open`
|
||||
* flag — see module docstring.
|
||||
*/
|
||||
export function useSupportMessages(enabled: boolean) {
|
||||
export function useSupportMessages(enabled: boolean, scope: SupportScope = "auth") {
|
||||
return useQuery<SupportMessage[]>({
|
||||
queryKey: SUPPORT_MESSAGES_KEY,
|
||||
queryFn: () => apiFetch<SupportMessage[]>(`${BASE}/messages?since=0`),
|
||||
queryKey: messagesKey(scope),
|
||||
queryFn: () =>
|
||||
apiFetch<SupportMessage[]>(`${scopeBase(scope)}/messages?since=0`),
|
||||
enabled,
|
||||
staleTime: 0,
|
||||
refetchInterval: enabled ? MESSAGES_POLL_MS : false,
|
||||
|
|
@ -81,26 +101,26 @@ export function useSupportMessages(enabled: boolean) {
|
|||
* Feeds the unread badge on the closed floating button. `enabled` should be
|
||||
* `!open` — see module docstring.
|
||||
*/
|
||||
export function useSupportUnread(enabled: boolean) {
|
||||
export function useSupportUnread(enabled: boolean, scope: SupportScope = "auth") {
|
||||
return useQuery<SupportUnread>({
|
||||
queryKey: SUPPORT_UNREAD_KEY,
|
||||
queryFn: () => apiFetch<SupportUnread>(`${BASE}/unread`),
|
||||
queryKey: unreadKey(scope),
|
||||
queryFn: () => apiFetch<SupportUnread>(`${scopeBase(scope)}/unread`),
|
||||
enabled,
|
||||
staleTime: 0,
|
||||
refetchInterval: enabled ? UNREAD_POLL_MS : false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSendSupportMessage() {
|
||||
export function useSendSupportMessage(scope: SupportScope = "auth") {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<SupportMessage, Error, string>({
|
||||
mutationFn: (text) =>
|
||||
apiFetch<SupportMessage>(`${BASE}/messages`, {
|
||||
apiFetch<SupportMessage>(`${scopeBase(scope)}/messages`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ text }),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: SUPPORT_MESSAGES_KEY });
|
||||
queryClient.invalidateQueries({ queryKey: messagesKey(scope) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -111,14 +131,16 @@ export function useSendSupportMessage() {
|
|||
* unread badge never counts messages the visitor demonstrably already saw
|
||||
* while the panel was open.
|
||||
*/
|
||||
export function useMarkSupportRead() {
|
||||
export function useMarkSupportRead(scope: SupportScope = "auth") {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<void, Error, void>({
|
||||
mutationFn: async () => {
|
||||
await apiFetch<{ status: string }>(`${BASE}/read`, { method: "POST" });
|
||||
await apiFetch<{ status: string }>(`${scopeBase(scope)}/read`, {
|
||||
method: "POST",
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.setQueryData<SupportUnread>(SUPPORT_UNREAD_KEY, { unread: 0 });
|
||||
queryClient.setQueryData<SupportUnread>(unreadKey(scope), { unread: 0 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue