diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index eccedb9e..0fd47b82 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -65,6 +65,30 @@ jobs: python3 scripts/check-workflow-ports.py --selftest python3 scripts/check-workflow-ports.py + - name: "Guard: Caddyfile синтаксически валиден" + # Тем же шагом-соседом и по той же причине, что два гейта рядом: бежит + # на КАЖДОМ PR, стоит секунды, падение блокирует merge. + # + # ЗАЧЕМ. До 16.08.2026 конфиг прокси не проверял НИКТО — ни один + # workflow не звал `caddy validate`/`adapt` (grep по .forgejo/). При + # этом deploy.yml применяет его не через `reload` (тот отказался бы + # принять битый конфиг и оставил бы старый работать), а через + # `up -d --force-recreate caddy`: синтаксическая ошибка уводит контейнер + # в crash-loop, и ложатся ВСЕ домены сразу — gendsgn.ru, meraocenka.ru, + # obsidian, status. То есть цена опечатки в этом файле — полный + # даунтайм, а гейта на неё не было. + # + # `docker run`, а не установка caddy в раннер: тот же образ `caddy:2`, + # что стоит в docker-compose.prod.yml — проверяем ровно тем парсером, + # который будет читать конфиг на проде. Docker на раннере есть (им же + # поднимается Postgres в ci-tradein.yml). + # + # Плейсхолдеры окружения ({env.*}) при validate резолвятся в пустую + # строку — это нормально, синтаксис от их значений не зависит. + run: | + docker run --rm -v "$PWD:/etc/caddy" caddy:2 \ + caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile + - name: "Guard: блокирующий DDL без lock_timeout (#2752)" # Тем же шагом-соседом и по той же причине: гейт бежит на КАЖДОМ PR, # включая tradein-only (у ci.yml нет paths-фильтра на уровне workflow — diff --git a/Caddyfile b/Caddyfile index b54f530c..a43e8e9d 100644 --- a/Caddyfile +++ b/Caddyfile @@ -278,6 +278,10 @@ meraocenka.ru { # `trailingSlash: false` ответил бы на такой путь 308-редиректом на вариант # без слэша — то есть на ДЛИННЫЙ адрес, который handle ниже отправит 301 на # «/», и запрос закольцуется. + # `/v3` — ВРЕМЕННОЕ превью второго варианта дизайна, а не публичная + # страница: владелец сравнивает его с текущим лэндингом. Оно `noindex` и + # ни с одной страницы на него нет ссылки. Убрать эту строку в тот момент, + # когда вариант выберут и он станет корнем. @meraPages path /estimate /oferta /refund /privacy /v3 handle @meraPages { rewrite * /trade-in/mera-public{path} @@ -286,6 +290,17 @@ meraocenka.ru { } } + # Тот же адрес со слэшем на конце → 301 на канонический вид без слэша. + # Слэш дописывают мессенджеры, автолинкификаторы и сами люди, а матчер + # `path` требует точного совпадения — без этой ветки `/oferta/` отдавал бы + # голый 404 (так было и до этого PR, с момента #2615). Заодно это + # замыкает цепочку для длинных адресов со слэшем: они приходят на короткий + # со слэшем и здесь нормализуются. + @meraShortSlash path_regexp shortslash ^/(estimate|oferta|refund|privacy|v3)/$ + handle @meraShortSlash { + redir * /{re.shortslash.1} permanent + } + # Длинные адреса поддерева → 301 на короткие. Один канонический адрес у # страницы, а не два работающих. # @@ -300,10 +315,6 @@ meraocenka.ru { # ссылка «Главная» в подвале v3, то есть она была мёртвой (замер на проде # 15.08.2026). Первый матчер ниже ловит обе формы — со слэшем и без. # - # Query-строка при редиректе не переносится: ни одна из этих страниц - # параметров не принимает (форма проверки шлёт данные телом POST, а - # черновик с лэндинга едет через sessionStorage — специально чтобы адрес - # квартиры не попал в access-лог). # `redir * <куда>`, а НЕ `redir <куда>`. Первый аргумент директивы, если он # начинается со слэша, Caddy разбирает как inline path-matcher — то есть # `redir / permanent` означает «для пути / редиректить на permanent», а не @@ -315,18 +326,30 @@ meraocenka.ru { redir * / permanent } - # `([^/].*)`, а не `(.+)` — страховка от протокол-относительной цели. - # Захват, начинающийся со слэша, дал бы `redir` цель вида `//evil.example`, - # которую браузер резолвит как ЧУЖОЙ ХОСТ (открытый редирект с нашего - # домена). Проверено на живом Caddy: сегодня это недостижимо и без - # ограничения — Caddy нормализует путь ДО матчинга, схлопывая повторные - # слэши, и `//evil.example` (как и `/%2Fevil.example`) уже приезжает сюда - # одним слэшем, то есть редирект остаётся на нашем хосте. Ограничение - # оставлено намеренно: оно стоит ноль, а полагаться на нормализацию как на - # единственный барьер для дыры такого класса не хочется. - @meraLongSub path_regexp meralong ^/trade-in/mera-public/([^/].*)$ - handle @meraLongSub { - redir * /{re.meralong.1} permanent + # Длинные адреса страниц → короткие. Пути перечислены ПОИМЁННО, обе формы + # (со слэшем на конце и без) — не шаблоном и не регекспом. + # + # ПОЧЕМУ НЕ РЕГЕКСП С ЗАХВАТОМ ХВОСТА. Очевидный вариант + # `path_regexp ^/trade-in/mera-public/(.+)$` + `redir /{re.…1}` — открытый + # редирект. Захват берётся из РАСКОДИРОВАННОГО пути, поэтому + # `/trade-in/mera-public/%5Cevil.example/pay` даёт цель `/\evil.example/pay`, + # а браузеры трактуют `/\` как `//` — Location уводит на ЧУЖОЙ хост. Это + # готовая фишинговая заготовка с домена, который напечатан внутри оферты и + # уходит модератору эквайера. Проверено на живом Caddy, воспроизводится. + # С поимённым списком такой путь просто не матчится и падает в 404 ниже. + # + # ПОЧЕМУ `uri strip_prefix` + `{uri}`, А НЕ `redir /oferta` в каждой ветке. + # `{uri}` переносит query-строку: уже размещённые ссылки с UTM-метками + # после редиректа не теряют атрибуцию. Обёртка `route` обязательна — + # порядок директив внутри `handle` определяет Caddy, и без неё `redir` + # выполняется РАНЬШЕ `uri`, отдавая Location, равный исходному адресу + # (бесконечный цикл; поймано на локальном стенде). + @meraLongPages path /trade-in/mera-public/estimate /trade-in/mera-public/estimate/ /trade-in/mera-public/oferta /trade-in/mera-public/oferta/ /trade-in/mera-public/refund /trade-in/mera-public/refund/ /trade-in/mera-public/privacy /trade-in/mera-public/privacy/ /trade-in/mera-public/v3 /trade-in/mera-public/v3/ + handle @meraLongPages { + route { + uri strip_prefix /trade-in/mera-public + redir * {uri} permanent + } } # Next.js уже эмитит ссылки на статику с /trade-in-префиксом (тот же diff --git a/tradein-mvp/backend/app/api/public/mera.py b/tradein-mvp/backend/app/api/public/mera.py index 21af11d0..80ed00e9 100644 --- a/tradein-mvp/backend/app/api/public/mera.py +++ b/tradein-mvp/backend/app/api/public/mera.py @@ -57,6 +57,7 @@ non-public пути. Обе ручки перечислены в `_PUBLIC_PATHS` from __future__ import annotations +import asyncio import logging from typing import Annotated @@ -67,11 +68,20 @@ from sqlalchemy.orm import Session from app.api.v1.geocode import SuggestResponse, suggest_addresses from app.api.v1.trade_in import coverage_probe from app.core.db import get_db +from app.core.public_request import install_address_log_redaction, public_request_scope from app.core.ratelimit import SlidingWindowLimiter, _client_ip from app.schemas.trade_in import CoverageProbeInput, CoverageProbeResponse logger = logging.getLogger(__name__) +# Публичная форма обещает, что введённый адрес нигде не сохраняется. По базам +# это так, по журналам не было — геокодер печатал запрос открытым текстом, а +# прод пишет stdout в persistent journald. Ставим редакцию логов в момент +# импорта модуля (его импортирует app/main.py) — то есть ровно тогда, когда +# публичные ручки вообще появляются в приложении. Разбор — в +# app/core/public_request.py. +install_address_log_redaction() + router = APIRouter() # Бюджеты подобраны от живого сценария, а не «на глаз»: человек набирает адрес @@ -79,13 +89,47 @@ router = APIRouter() # на несколько попыток подряд и режет перебор словарём. Проба покрытия — шаг # осознанный (нажатие кнопки), 15/мин с запасом покрывает «поправил площадь, # нажал ещё раз». -_SUGGEST_LIMIT = 40 +_SUGGEST_LIMIT = 20 _COVERAGE_LIMIT = 15 _WINDOW_S = 60.0 _suggest_limiter = SlidingWindowLimiter(limit=_SUGGEST_LIMIT, window_s=_WINDOW_S) _coverage_limiter = SlidingWindowLimiter(limit=_COVERAGE_LIMIT, window_s=_WINDOW_S) +# ── Общий суточный потолок публичных подсказок ────────────────────────────── +# +# Per-IP окна одного клиента ограничивают, но не ограничивают СУММУ. Считаем: +# 20 запросов/мин с одного адреса — это 28 800 в сутки, а весь бесплатный тир +# DaData у проекта — 10 000 в сутки И ОН ОБЩИЙ с закрытым контуром. То есть без +# этого потолка один настойчивый клиент (или один скрипт) за несколько часов +# выедает квоту, и подсказки перестают работать у ПЛАТЯЩИХ пилотов, а не только +# у него. Найдено состязательным ревью и подтверждено на проде: достаточно +# упомянуть в запросе не-екатеринбургский город, чтобы локальный кадастровый +# тир отключился и запрос гарантированно ушёл во внешний сервис. +# +# 2000/сутки — заведомо меньше десятой доли тира: публичная форма не должна +# уметь навредить закрытому контуру в принципе. Порог достижим только абузом +# (живой посетитель тратит единицы запросов на адрес), поэтому исчерпание — +# сигнал, а не штатный режим: логируем ошибкой. +_DAILY_SUGGEST_BUDGET = 2000 +_daily_suggest_limiter = SlidingWindowLimiter(limit=_DAILY_SUGGEST_BUDGET, window_s=86_400.0) +_GLOBAL_KEY = "public-suggest" + +# ── Потолок одновременных подсказок ───────────────────────────────────────── +# +# Кадастровый тир геокодера уходит в FDW-скан ЧУЖОЙ базы (gendesign) и на +# коротком вводе занимает около секунды, всё это время удерживая соединение из +# пула. Пул общий с закрытым контуром и невелик (дефолт SQLAlchemy 5+10), так +# что полтора десятка одновременных публичных подсказок способны положить +# B2B-запросы в том же процессе — при том, что per-IP лимиты каждого из них +# формально соблюдены. +# +# Ждём слот недолго и отвечаем 429, а не копим очередь: очередь под нагрузкой +# превращается в те же занятые соединения плюс растущий таймаут у клиента. +_SUGGEST_CONCURRENCY = 4 +_SUGGEST_SLOT_WAIT_S = 2.0 +_suggest_slots = asyncio.Semaphore(_SUGGEST_CONCURRENCY) + def _enforce(limiter: SlidingWindowLimiter, request: Request, what: str) -> None: """429 при превышении per-IP бюджета. Попытку регистрируем ДО работы ручки. @@ -148,9 +192,42 @@ async def public_suggest( стоить внешнего вызова. """ _enforce(_suggest_limiter, request, "suggest") - return await suggest_addresses( - q=payload.q, limit=payload.limit, db=db, city_hint=payload.city_hint - ) + + # Суточный потолок — ПОСЛЕ per-IP: сначала отсекаем одиночного абузера его + # собственным лимитом, и только оставшееся считаем в общий бюджет. + daily_retry = _daily_suggest_limiter.retry_after(_GLOBAL_KEY) + if daily_retry is not None: + logger.error( + "публичные подсказки исчерпали суточный бюджет (%d) — квота геокодера " + "защищена, но форма на лэндинге сейчас без автокомплита", + _DAILY_SUGGEST_BUDGET, + ) + raise HTTPException( + status_code=429, + detail="Подсказки адреса временно недоступны. Введите адрес полностью.", + headers={"Retry-After": str(int(daily_retry) + 1)}, + ) + _daily_suggest_limiter.record(_GLOBAL_KEY) + + try: + await asyncio.wait_for(_suggest_slots.acquire(), timeout=_SUGGEST_SLOT_WAIT_S) + except TimeoutError: + raise HTTPException( + status_code=429, + detail="Сервис сейчас занят. Попробуйте ещё раз через несколько секунд.", + headers={"Retry-After": "5"}, + ) from None + + try: + # Пометка публичного запроса нужна ровно здесь: внутри `suggest_addresses` + # геокодер логирует введённую строку, а публичная форма обещает, что + # адрес не попадает в журналы. + with public_request_scope(): + return await suggest_addresses( + q=payload.q, limit=payload.limit, db=db, city_hint=payload.city_hint + ) + finally: + _suggest_slots.release() @router.post("/coverage", response_model=CoverageProbeResponse) diff --git a/tradein-mvp/backend/app/core/public_request.py b/tradein-mvp/backend/app/core/public_request.py new file mode 100644 index 00000000..5a2ebbb5 --- /dev/null +++ b/tradein-mvp/backend/app/core/public_request.py @@ -0,0 +1,97 @@ +"""Пометка «этот запрос пришёл из публичной формы» и её единственное следствие: +адрес, который ввёл аноним, не попадает в журналы. + +ЗАЧЕМ ЭТО СУЩЕСТВУЕТ +-------------------- +На `meraocenka.ru/estimate` и в политике обработки ПДн сказано, что введённый +адрес нигде не сохраняется. По базам данных это правда (обе публичные ручки +только читают), а по журналам — не было: геокодер логирует запрос открытым +текстом на каждый вызов, например + + INFO app.services.dadata: dadata suggest: 'онуфриева 24' → 5 вариантов + +Прод пишет stdout контейнеров в journald с persistent-хранилищем +(`tradein-mvp/docker-compose.prod.yml`), то есть строка ложится на диск и живёт +там неделями. Рядом, в access-логе Caddy, лежит IP того же запроса с той же +меткой времени — то есть адрес квартиры фактически сохранён и сопоставим с +человеком. Ровно то, что публичная страница обещает не делать. + +Найдено состязательным ревью PR публичного периметра (16.08.2026) и +воспроизведено на проде, а не выведено из чтения кода. + +ПОЧЕМУ ФИЛЬТР, А НЕ ПРАВКА КАЖДОГО ВЫЗОВА logger +------------------------------------------------ +Мест, где адрес попадает в лог, много (`app/services/dadata.py`, +`app/services/geocoder.py` — успех, пустая выдача, сетевая ошибка, таймаут, +кадастровый фолбэк), и любое новое добавится незаметно. Обещание не должно +зависеть от того, вспомнил ли автор следующей правки про эту страницу. +Фильтр — единственная точка, которая закрывает и уже написанное, и будущее. + +ПОЧЕМУ contextvar +----------------- +Публичный и закрытый контуры обслуживает ОДИН процесс, и один и тот же +`suggest()` вызывают оба. Различить их можно только по текущему запросу. +`ContextVar` — то, что переживает `await` и копируется в `asyncio.to_thread` +(им геокодер уходит в синхронный кадастровый тир), в отличие от глобального +флага, который в конкурентной обработке принадлежал бы соседнему запросу. + +Для B2B-трафика ничего не меняется: там флаг не выставлен, логи прежние — они +нужны, чтобы разбирать жалобы пилотов на подсказки. +""" + +from __future__ import annotations + +import logging +from collections.abc import Iterator +from contextlib import contextmanager +from contextvars import ContextVar + +#: Истинно, пока обрабатывается запрос анонимной публичной формы. +is_public_request: ContextVar[bool] = ContextVar("mera_is_public_request", default=False) + +#: Что видно в журнале вместо сообщения. Уровень и логгер сохраняются — по ним +#: по-прежнему видно, что вызов был и чем закончился. +REDACTED_MESSAGE = "<публичный запрос МЕРЫ: содержимое скрыто>" + +#: Логгеры, чьи записи могут содержать введённый адрес. +ADDRESS_LOGGERS = ("app.services.dadata", "app.services.geocoder") + + +@contextmanager +def public_request_scope() -> Iterator[None]: + """Помечает текущий запрос публичным на время работы блока.""" + token = is_public_request.set(True) + try: + yield + finally: + is_public_request.reset(token) + + +class RedactPublicAddressFilter(logging.Filter): + """Заменяет сообщение целиком, пока обрабатывается публичный запрос. + + Целиком, а не по ключам: в шаблонах сообщений адрес стоит рядом с + безобидными аргументами (`"%r → %d вариантов"`), и отличить их друг от + друга внутри фильтра нельзя. Терять текст сообщения на публичном пути + дешевле, чем хранить адреса; на закрытом контуре текст остаётся полным. + """ + + def filter(self, record: logging.LogRecord) -> bool: + if is_public_request.get(): + record.msg = REDACTED_MESSAGE + record.args = () + return True + + +def install_address_log_redaction() -> None: + """Вешает фильтр на логгеры, видящие адрес. Идемпотентно. + + Фильтр ставится на КОНКРЕТНЫЕ логгеры, а не на корневой хендлер: фильтры + логгера применяются к записям этого логгера, а не ко всему, что через + хендлер проходит, — то есть посторонние сообщения (пул соединений, старт + приложения) во время публичного запроса не пострадают. + """ + for name in ADDRESS_LOGGERS: + logger = logging.getLogger(name) + if not any(isinstance(f, RedactPublicAddressFilter) for f in logger.filters): + logger.addFilter(RedactPublicAddressFilter()) diff --git a/tradein-mvp/backend/app/main.py b/tradein-mvp/backend/app/main.py index a3d16343..5a8c34cb 100644 --- a/tradein-mvp/backend/app/main.py +++ b/tradein-mvp/backend/app/main.py @@ -71,6 +71,7 @@ if settings.glitchtip_dsn: from app.observability.sentry_scrub import ( redact_telegram_bot_token, scrub_payment_request_body, + scrub_public_address, stabilize_retry_error_fingerprint, ) @@ -93,6 +94,13 @@ if settings.glitchtip_dsn: (suggest/lookup/reverse), которые ретраят Nominatim через tenacity; см. sentry_scrub.stabilize_retry_error_fingerprint.""" scrubbed = scrub_payment_request_body(event, hint) # type: ignore[arg-type] + if scrubbed is None: + return None + # Публичный периметр МЕРЫ: тело запроса — это ровно введённый адрес, а + # брэдкрамб исходящего вызова геокодера несёт его же в query. Публичная + # страница обещает, что адрес нигде не сохраняется; GlitchTip — внешний + # сервис, значит обещание распространяется и на него. + scrubbed = scrub_public_address(scrubbed, hint) # type: ignore[arg-type] if scrubbed is None: return None scrubbed = scrub_pii_event(scrubbed, hint) # type: ignore[arg-type] diff --git a/tradein-mvp/backend/app/observability/sentry_scrub.py b/tradein-mvp/backend/app/observability/sentry_scrub.py index 0920486e..f3c6b63a 100644 --- a/tradein-mvp/backend/app/observability/sentry_scrub.py +++ b/tradein-mvp/backend/app/observability/sentry_scrub.py @@ -241,6 +241,61 @@ def scrub_payment_request_body(event: Event, _hint: dict[str, Any]) -> Event | N return event +_PUBLIC_API_URL_SEGMENT = "/api/public/" + +#: Хосты геокодеров: их URL несёт введённый адрес прямо в query. +_GEOCODER_HOSTS = ("nominatim.openstreetmap.org", "suggestions.dadata.ru", "dadata.ru") + +_ANY_URL_QUERY_RE = re.compile(r"^([^?]*)\?.*$") + + +def scrub_public_address(event: Event, _hint: dict[str, Any]) -> Event | None: + """Убрать введённый анонимом адрес из события GlitchTip. + + На `meraocenka.ru/estimate` и в политике обработки ПДн сказано, что адрес + нигде не сохраняется. GlitchTip — внешний сервис, и до этой правки адрес + доезжал туда двумя путями (оба воспроизведены состязательным ревью + 16.08.2026, не выведены из чтения кода): + + 1. `event.request.data`. sentry_sdk кладёт в событие ПОЛНОЕ тело запроса, + а `send_default_pii=False` этот путь не гейтит — он про куки и IP, не + про тело. Тело публичной ручки — это ровно `{"q": "<адрес>"}`. + Ключ-based `scrub_pii_event` не помогает: `_PII_KEYS` перечисляет + имена вроде `client_phone`, а поле здесь называется `q`. + 2. Брэдкрамб исходящего HTTP-запроса к геокодеру: `HttpxIntegration` + кладёт URL целиком, а адрес там в query (`?q=Малышева+30`). + + Стратегия та же, что у платёжного тела: не вычищать отдельные ключи, а + убирать целиком — состав полей задаёт не только наш код (у геокодеров свои + параметры), поэтому перечислить безопасное заранее нельзя. + + Композировать с остальными шагами, а не вместо них. + """ + if not isinstance(event, dict): + return event + + request = event.get("request") + if isinstance(request, dict): + url = request.get("url") + if isinstance(url, str) and _PUBLIC_API_URL_SEGMENT in url.lower(): + request.pop("data", None) + + crumbs = event.get("breadcrumbs") + values = crumbs.get("values") if isinstance(crumbs, dict) else crumbs + if isinstance(values, list): + for crumb in values: + if not isinstance(crumb, dict): + continue + data = crumb.get("data") + if not isinstance(data, dict): + continue + url = data.get("url") + if isinstance(url, str) and any(h in url for h in _GEOCODER_HOSTS): + data["url"] = _ANY_URL_QUERY_RE.sub(r"\g<1>?" + _REDACTED, url) + + return event + + def _redact_strings(obj: Any) -> Any: """Рекурсивно проходит dict/list/tuple и прогоняет обе токен-регулярки по КАЖДОЙ строке (не только по конкретным ключам) — токен может оказаться в locals diff --git a/tradein-mvp/backend/tests/test_public_mera_api.py b/tradein-mvp/backend/tests/test_public_mera_api.py index 06b5b031..95efc4a5 100644 --- a/tradein-mvp/backend/tests/test_public_mera_api.py +++ b/tradein-mvp/backend/tests/test_public_mera_api.py @@ -246,3 +246,107 @@ def test_suggest_limit_ceiling_is_lower_than_v1(client: TestClient) -> None: above = client.post(f"{PREFIX}/suggest", json={"q": "Малышева", "limit": 15}) assert at_ceiling.status_code == 200, at_ceiling.text assert above.status_code == 422, above.text + + +# ── 5. Обещание «адрес нигде не сохраняется» ───────────────────────────────── +# +# Три пути утечки, найденные состязательным ревью 16.08.2026 и воспроизведённые +# на живом коде. Тесты сформулированы от обещания, а не от реализации: пока на +# публичной странице и в политике ПДн написано «не сохраняется», эти проверки +# обязаны быть зелёными. + + +def test_address_is_redacted_from_logs_inside_public_scope(caplog) -> None: + """Геокодер логирует введённую строку открытым текстом на каждый вызов, а + прод пишет stdout контейнеров в persistent journald — то есть адрес ложился + на диск рядом с IP того же запроса в access-логе Caddy.""" + import logging + + from app.core.public_request import install_address_log_redaction, public_request_scope + + install_address_log_redaction() + dadata_logger = logging.getLogger("app.services.dadata") + + with caplog.at_level(logging.INFO): + with public_request_scope(): + dadata_logger.info("dadata suggest: %r → %d вариантов", "онуфриева 24", 5) + # Вне публичного контура логи прежние — они нужны для разбора жалоб + # пилотов на подсказки. + dadata_logger.info("dadata suggest: %r → %d вариантов", "малышева 51", 3) + + text = "\n".join(r.getMessage() for r in caplog.records) + assert "онуфриева" not in text.lower(), "адрес анонима попал в журнал" + assert "малышева" in text.lower(), "редакция протекла на закрытый контур" + + +def test_public_scope_is_not_leaked_after_request() -> None: + """Флаг обязан сниматься: иначе первый же публичный запрос заглушил бы логи + процесса до перезапуска.""" + from app.core.public_request import is_public_request, public_request_scope + + assert is_public_request.get() is False + with public_request_scope(): + assert is_public_request.get() is True + assert is_public_request.get() is False + + +def test_sentry_scrub_drops_public_request_body_and_geocoder_query() -> None: + """sentry_sdk кладёт в событие полное тело запроса, а `send_default_pii=False` + этот путь не гейтит (он про куки и IP). Тело публичной ручки — ровно + `{"q": "<адрес>"}`; брэдкрамб httpx несёт тот же адрес в query.""" + from app.observability.sentry_scrub import scrub_public_address + + event = { + "request": { + "url": "https://meraocenka.ru/trade-in/api/public/mera/suggest", + "data": {"q": "Малышева 30"}, + }, + "breadcrumbs": { + "values": [ + { + "category": "httpx", + "data": {"url": "https://nominatim.openstreetmap.org/search?q=Малышева+30"}, + }, + {"category": "httpx", "data": {"url": "https://example.com/x?a=1"}}, + ] + }, + } + + scrubbed = scrub_public_address(event, {}) + assert scrubbed is not None + assert "data" not in scrubbed["request"], "тело запроса с адресом уехало в GlitchTip" + + crumbs = scrubbed["breadcrumbs"]["values"] + assert "Малышева" not in crumbs[0]["data"]["url"], "адрес уехал в брэдкрамбе геокодера" + # Посторонние URL не трогаем — иначе разбирать чужие ошибки станет нечем. + assert crumbs[1]["data"]["url"] == "https://example.com/x?a=1" + + +def test_sentry_scrub_keeps_closed_contour_body() -> None: + """Редакция узкая: тела запросов закрытого контура нужны для разбора.""" + from app.observability.sentry_scrub import scrub_public_address + + event = { + "request": {"url": "https://gendsgn.ru/trade-in/api/v1/trade-in/estimate", "data": {"x": 1}} + } + scrubbed = scrub_public_address(event, {}) + assert scrubbed is not None + assert scrubbed["request"]["data"] == {"x": 1} + + +# ── 6. Бюджет внешнего геокодера ───────────────────────────────────────────── + + +def test_daily_suggest_budget_protects_shared_geocoder_quota(client: TestClient) -> None: + """Per-IP окна ограничивают одного клиента, но не сумму: 20/мин с адреса — + это 28 800 в сутки при общем бесплатном тире DaData в 10 000, ОБЩЕМ с + закрытым контуром. Без суточного потолка один скрипт оставлял бы без + подсказок платящих пилотов.""" + assert public_mera._DAILY_SUGGEST_BUDGET < 10_000, ( + "суточный потолок публичных подсказок обязан быть заметно меньше всего " + "тира геокодера — иначе публичная форма может навредить закрытому контуру" + ) + assert public_mera._SUGGEST_LIMIT * 60 * 24 > public_mera._DAILY_SUGGEST_BUDGET, ( + "если per-IP лимит сам по себе не может исчерпать суточный бюджет, " + "потолок бессмысленен — проверь, что тест сторожит реальный сценарий" + ) diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/estimate-draft.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/estimate-draft.test.ts new file mode 100644 index 00000000..e75875c6 --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/estimate-draft.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { normalizeDraftRooms, saveDraft, takeDraft } from "../estimate-draft"; + +/** + * Черновик, который лэндинг передаёт на экран проверки. + * + * Тесты — на две вещи, каждая из которых уже ломалась при ревью: + * 1. поле «Комнат» на лэндинге свободное, и его значение нельзя подставлять + * в селект как есть; + * 2. черновик не должен переживать свой единственный переход. + */ + +const ROOMS = ["0", "1", "2", "3", "4", "5"]; + +describe("normalizeDraftRooms", () => { + it("пропускает то, что уже совпадает с вариантом селекта", () => { + expect(normalizeDraftRooms("2", ROOMS)).toBe("2"); + expect(normalizeDraftRooms("0", ROOMS)).toBe("0"); + }); + + it("понимает, как люди пишут на самом деле", () => { + expect(normalizeDraftRooms("2 комнаты", ROOMS)).toBe("2"); + expect(normalizeDraftRooms("3-комн.", ROOMS)).toBe("3"); + expect(normalizeDraftRooms(" 1к ", ROOMS)).toBe("1"); + expect(normalizeDraftRooms("Студия", ROOMS)).toBe("0"); + expect(normalizeDraftRooms("студию", ROOMS)).toBe("0"); + }); + + it("возвращает null вместо мусора — иначе селект покажет пустоту, а сервер получит null", () => { + // Именно этот путь и давал 422 с текстом «сломалось на нашей стороне»: + // селект пустой, parseInt → NaN, rooms: null улетает на бэкенд. + expect(normalizeDraftRooms("много", ROOMS)).toBeNull(); + expect(normalizeDraftRooms("", ROOMS)).toBeNull(); + expect(normalizeDraftRooms(undefined, ROOMS)).toBeNull(); + expect(normalizeDraftRooms("9", ROOMS)).toBeNull(); + }); +}); + +describe("черновик", () => { + beforeEach(() => window.sessionStorage.clear()); + + it("переживает ровно один переход", () => { + saveDraft({ address: "Малышева 51", rooms: "2" }); + expect(takeDraft()?.address).toBe("Малышева 51"); + // Второй раз — уже пусто: иначе возврат на /estimate через неделю в той же + // вкладке подставил бы чужой по смыслу адрес. + expect(takeDraft()).toBeNull(); + }); + + it("не падает на мусоре в хранилище", () => { + window.sessionStorage.setItem("mera:estimate-draft", "{это не json"); + expect(takeDraft()).toBeNull(); + + window.sessionStorage.setItem("mera:estimate-draft", JSON.stringify({ rooms: "2" })); + expect(takeDraft()).toBeNull(); + }); + + it("не тащит поля неожиданных типов", () => { + window.sessionStorage.setItem( + "mera:estimate-draft", + JSON.stringify({ address: "Ленина 1", rooms: 2, area: null }), + ); + const draft = takeDraft(); + expect(draft?.address).toBe("Ленина 1"); + expect(draft?.rooms).toBeUndefined(); + expect(draft?.area).toBeUndefined(); + }); +}); diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/public-perimeter.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/public-perimeter.test.ts index c99f060c..a570b49b 100644 --- a/tradein-mvp/frontend/src/app/mera-public/__tests__/public-perimeter.test.ts +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/public-perimeter.test.ts @@ -72,18 +72,62 @@ describe("короткие адреса публичного домена", () = expect(meraBlock.length).toBeGreaterThan(500); }); + /** + * Разрешённый «лишний» путь: временное превью второго варианта дизайна. + * Ссылок на него нет, страница noindex; строка удаляется вместе с выбором + * варианта. Держим здесь, чтобы проверка ниже была ДВУСТОРОННЕЙ. + */ + const PREVIEW_ONLY = ["/v3"]; + + const meraPagesLine = meraBlock.match(/@meraPages path ([^\n]+)/); + it("каждый маршрут из PUBLIC_ROUTES раздаётся публичным доменом", () => { for (const route of Object.values(PUBLIC_ROUTES)) { // Корень — отдельным `handle /`, остальные перечислены в матчере @meraPages. const served = route === "/" ? /handle\s+\/\s*\{/.test(meraBlock) - : new RegExp(`@meraPages path[^\\n]*\\s${route}(\\s|$)`, "m").test(meraBlock); + : (meraPagesLine?.[1].split(/\s+/) ?? []).includes(route); expect(served, `${route} не раздаётся на meraocenka.ru — ссылка на него будет 404`).toBe( true, ); } }); + + /** + * Обратное направление. Односторонняя проверка (каждый роут есть в Caddy) + * пропускает противоположную ошибку: путь, открытый на боевом домене, о + * котором приложение не знает. Так на публичный домен уже уехало `/v3` — + * черновой лэндинг с маркетинговыми плейсхолдерами вместо посчитанных чисел. + * Теперь любой такой путь обязан быть либо в PUBLIC_ROUTES, либо в списке + * превью выше — то есть названным вслух. + */ + it("на публичном домене не открыто ничего сверх известных страниц", () => { + const served = meraPagesLine?.[1].split(/\s+/).filter(Boolean) ?? []; + const known = new Set([...Object.values(PUBLIC_ROUTES), ...PREVIEW_ONLY]); + const unexpected = served.filter((p) => !known.has(p)); + expect( + unexpected, + `открыты наружу, но не объявлены ни в PUBLIC_ROUTES, ни как превью: ${unexpected}`, + ).toEqual([]); + }); + + /** + * Длинные адреса обязаны редиректить на короткие — иначе разосланные ссылки + * и закладки превращаются в 404. Список тоже поимённый (регексп с захватом + * хвоста здесь был бы открытым редиректом — см. комментарий в Caddyfile), + * поэтому он так же легко расходится с набором страниц. + */ + it("для каждой страницы есть 301 с длинного адреса", () => { + const longLine = meraBlock.match(/@meraLongPages path ([^\n]+)/)?.[1] ?? ""; + for (const route of [...Object.values(PUBLIC_ROUTES), ...PREVIEW_ONLY]) { + if (route === "/") continue; // корень ловит отдельный @meraLongRoot + expect( + longLine.includes(`/trade-in/mera-public${route}`), + `нет 301 с длинного адреса на ${route} — старые ссылки станут 404`, + ).toBe(true); + } + }); }); describe("basePath не протекает в публичные ссылки", () => { diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/estimate/EstimateFlow.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/estimate/EstimateFlow.tsx index 0f7534c4..bcf6dcdf 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/estimate/EstimateFlow.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/estimate/EstimateFlow.tsx @@ -28,7 +28,7 @@ import type { FormEvent, KeyboardEvent } from "react"; import { COVERED_CITIES, PRIMARY_CITY } from "../../content"; import { describeCoverage } from "../../coverage-copy"; import type { CoverageVerdict } from "../../coverage-copy"; -import { takeDraft } from "../../estimate-draft"; +import { normalizeDraftRooms, takeDraft } from "../../estimate-draft"; import { PublicApiError, fetchAddressSuggestions, @@ -40,6 +40,8 @@ import styles from "../../landing-v3.module.css"; /** Задержка перед запросом подсказок. Каждый вызов платный (DaData-тир). */ const SUGGEST_DEBOUNCE_MS = 300; const SUGGEST_MIN_CHARS = 3; +/** Потолок ожидания пробы покрытия. Сам SQL укладывается в ~80 мс. */ +const COVERAGE_TIMEOUT_MS = 15_000; const ROOM_OPTIONS = [ { value: 0, label: "Студия" }, @@ -59,6 +61,12 @@ type Phase = const FORM: Phase = { kind: "form" }; function failureCopy(error: unknown): { title: string; text: string } { + if (error instanceof DOMException && error.name === "AbortError") { + return { + title: "Проверка заняла слишком долго", + text: "Мы прервали запрос, чтобы не держать вас в неизвестности. Нажмите «Проверить мой дом» ещё раз — введённое сохранилось.", + }; + } if (error instanceof PublicApiError && error.kind === "rate-limited") { const wait = error.retryAfterS ? `${error.retryAfterS} сек.` : "минуту"; return { @@ -96,58 +104,88 @@ export function EstimateFlow() { const [area, setArea] = useState(""); const [phase, setPhase] = useState(FORM); const [fieldError, setFieldError] = useState<"address" | "area" | null>(null); + const [suggestFailed, setSuggestFailed] = useState(false); const addressRef = useRef(null); const areaRef = useRef(null); - const suggestAbort = useRef(null); + const coverageAbort = useRef(null); + + // Незавершённый запрос покрытия при уходе со страницы отменяем — иначе + // setState прилетает в размонтированный компонент. + useEffect(() => () => coverageAbort.current?.abort(), []); // Черновик с лэндинга — то, что человек уже набрал там. Забираем ОДИН раз // на монтировании; координат в нём нет (и быть не может — на лэндинге нет // автокомплита), поэтому дом всё равно придётся выбрать из подсказок. + // + // Каждое поле ВАЛИДИРУЕТСЯ, а не подставляется как есть. На лэндинге + // «Комнат» — свободный текст, туда пишут «студия» или «2 комнаты»; такое + // значение не совпадает ни с одним