From 40fc94ee910d65d01e76a840c079799f241a26c9 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 31 Jul 2026 16:04:51 +0300 Subject: [PATCH] =?UTF-8?q?feat(tradein/support):=20=D1=87=D0=B0=D1=82=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B4=D0=B4=D0=B5=D1=80=D0=B6=D0=BA=D0=B8=20=D0=B1?= =?UTF-8?q?=D0=B5=D0=B7=20=D0=B2=D1=85=D0=BE=D0=B4=D0=B0=20=E2=80=94=20?= =?UTF-8?q?=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=20=D0=BB=D0=BE=D0=B3=D0=B8=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=B8=20=C2=AB=D0=B4=D0=BE=D1=81=D1=82=D1=83=D0=BF?= =?UTF-8?q?=D0=B0=20=D0=BD=D0=B5=D1=82=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit После cutover'а на свою авторизацию (#2558) единственным каналом в поддержку остался чат ЗА логином, а самая частая причина писать в поддержку — как раз «не могу войти». 2026-07-31 это выстрелило: «Практика» весь день билась в форму входа (5 неудачных попыток с трёх разных IP, ни одной успешной) и сообщить об этом из продукта не могла ничем — на /login не было ни чата, ни контакта. Backend — 4 ручки /api/v1/trade-in/support/anon/* (public в rbac_guard): - Идентичность анонима — opaque-токен в httpOnly+Secure куке; тред живёт в тех же web_support_threads под ключом `anon:`. Двоеточие делает коллизию с реальным логином структурно невозможной (CHECK миграции 193 разрешает только `^[A-Za-z0-9._-]{3,64}$`) — аноним не может попасть в чужой тред. - Изоляция та же, что у авторизованной ветки: thread_id снаружи не принимается ни в каком виде, тред резолвится ИСКЛЮЧИТЕЛЬНО из куки. - Форма куки валидируется — мусор из браузера не становится ключом треда. - В Telegram-топик уходит не токен (это bearer треда), а `anon-<6 hex sha256>`; зеркало помечено «[С САЙТА · БЕЗ ВХОДА]» — оператору важно, что аккаунта нет. - Анти-абуз: два бюджета — per-token (12/мин) и per-IP (10/10мин). Второй ловит обход ротацией куки, без него публичная ручка записи в общий топик беззащитна. - Кука и запись в БД — только после успешного sendMessage (порядок операций H1), неудачная отправка не закрепляет за посетителем пустой тред. Frontend: - `SupportScope = "auth" | "anon"` в useSupportChat: scope выбирает базовый путь и входит в ключ кэша (иначе после логина в панели висела бы переписка анонима). Дефолт "auth" — существующие места монтирования не меняются. - `AnonSupportWidget` монтируется на /login и в NoAccessScreen — обе точки тупики, из которых пользователю больше некуда идти. На /login добавлена подсказка. Ответы оператора маршрутизируются без изменений в bridge.py: реплай резолвится по topic_message_id → thread_id, кто автор треда — там неважно. Тесты: 13 новых на анонимную ветку + 2 на границу public/authed в rbac_guard. 62 passed (test_support + test_rbac). --- tradein-mvp/backend/app/api/v1/support.py | 233 +++++++++++++++++- tradein-mvp/backend/app/core/rbac.py | 15 ++ tradein-mvp/backend/tests/test_rbac.py | 28 +++ tradein-mvp/backend/tests/test_support.py | 221 ++++++++++++++++- tradein-mvp/frontend/src/app/login/page.tsx | 17 ++ .../src/components/auth/AnonSupportWidget.tsx | 32 +++ .../src/components/auth/NoAccessScreen.tsx | 12 + .../components/trade-in/v2/SupportButton.tsx | 16 +- .../trade-in/v2/SupportChatPanel.tsx | 16 +- .../frontend/src/lib/useSupportChat.ts | 50 ++-- 10 files changed, 604 insertions(+), 36 deletions(-) create mode 100644 tradein-mvp/frontend/src/components/auth/AnonSupportWidget.tsx diff --git a/tradein-mvp/backend/app/api/v1/support.py b/tradein-mvp/backend/app/api/v1/support.py index 0a8c1914..bc8af578 100644 --- a/tradein-mvp/backend/app/api/v1/support.py +++ b/tradein-mvp/backend/app/api/v1/support.py @@ -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:`. Двоеточие +делает коллизию с реальным логином структурно невозможной: `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() diff --git a/tradein-mvp/backend/app/core/rbac.py b/tradein-mvp/backend/app/core/rbac.py index ffaf1a04..eb9ec090 100644 --- a/tradein-mvp/backend/app/core/rbac.py +++ b/tradein-mvp/backend/app/core/rbac.py @@ -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) перед diff --git a/tradein-mvp/backend/tests/test_rbac.py b/tradein-mvp/backend/tests/test_rbac.py index c0648e32..6fec305c 100644 --- a/tradein-mvp/backend/tests/test_rbac.py +++ b/tradein-mvp/backend/tests/test_rbac.py @@ -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", diff --git a/tradein-mvp/backend/tests/test_support.py b/tradein-mvp/backend/tests/test_support.py index 4b87d446..f89370ad 100644 --- a/tradein-mvp/backend/tests/test_support.py +++ b/tradein-mvp/backend/tests/test_support.py @@ -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:`, а не логином).""" + 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 diff --git a/tradein-mvp/frontend/src/app/login/page.tsx b/tradein-mvp/frontend/src/app/login/page.tsx index b3529e83..8bc61556 100644 --- a/tradein-mvp/frontend/src/app/login/page.tsx +++ b/tradein-mvp/frontend/src/app/login/page.tsx @@ -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() { "Войти" )} + +

+ Не получается войти? Напишите нам — кнопка «Поддержка» в правом нижнем + углу. Отвечаем без входа в систему. +

+ + {/* Инцидент 2026-07-31: без этого тот, кто не может залогиниться, не + может и сообщить об этом — единственный чат был за логином. */} + ); } diff --git a/tradein-mvp/frontend/src/components/auth/AnonSupportWidget.tsx b/tradein-mvp/frontend/src/components/auth/AnonSupportWidget.tsx new file mode 100644 index 00000000..aa4e7423 --- /dev/null +++ b/tradein-mvp/frontend/src/components/auth/AnonSupportWidget.tsx @@ -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 ( + + + + ); +} diff --git a/tradein-mvp/frontend/src/components/auth/NoAccessScreen.tsx b/tradein-mvp/frontend/src/components/auth/NoAccessScreen.tsx index 7d13b55a..c8ab4e1f 100644 --- a/tradein-mvp/frontend/src/components/auth/NoAccessScreen.tsx +++ b/tradein-mvp/frontend/src/components/auth/NoAccessScreen.tsx @@ -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) { )} + + {/* Второй тупик, из которого пользователю некуда идти (первый — /login). + Анонимный scope, а не авторизованный: на variant="session"/"error" + идентити уже нет, а разное поведение чата на соседних вариантах одного + экрана — лишняя развилка на ровном месте. Инцидент 2026-07-31. */} + ); } diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/SupportButton.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/SupportButton.tsx index e3cd0b8b..9b7f7b65 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/SupportButton.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/SupportButton.tsx @@ -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() { )} - + , document.body, ); diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/SupportChatPanel.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/SupportChatPanel.tsx index dc00586a..e652e594 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/SupportChatPanel.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/SupportChatPanel.tsx @@ -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(null); const inputRef = useRef(null); const listEndRef = useRef(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 diff --git a/tradein-mvp/frontend/src/lib/useSupportChat.ts b/tradein-mvp/frontend/src/lib/useSupportChat.ts index 478f10ea..537a6370 100644 --- a/tradein-mvp/frontend/src/lib/useSupportChat.ts +++ b/tradein-mvp/frontend/src/lib/useSupportChat.ts @@ -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({ - queryKey: SUPPORT_MESSAGES_KEY, - queryFn: () => apiFetch(`${BASE}/messages?since=0`), + queryKey: messagesKey(scope), + queryFn: () => + apiFetch(`${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({ - queryKey: SUPPORT_UNREAD_KEY, - queryFn: () => apiFetch(`${BASE}/unread`), + queryKey: unreadKey(scope), + queryFn: () => apiFetch(`${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({ mutationFn: (text) => - apiFetch(`${BASE}/messages`, { + apiFetch(`${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({ mutationFn: async () => { - await apiFetch<{ status: string }>(`${BASE}/read`, { method: "POST" }); + await apiFetch<{ status: string }>(`${scopeBase(scope)}/read`, { + method: "POST", + }); }, onSuccess: () => { - queryClient.setQueryData(SUPPORT_UNREAD_KEY, { unread: 0 }); + queryClient.setQueryData(unreadKey(scope), { unread: 0 }); }, }); }