#!/usr/bin/env bash # Регресс-тест публичного B2C-периметра МЕРА (ЭТАП 1 плана B2C-запуска). # # Проверяет инварианты периметра (см. корневой Caddyfile): # 1. meraocenka.ru отдаёт 200 анонимно (публичный лэндинг). # 1b. Длинный адрес /trade-in/mera-public/privacy отдаёт 301 на короткий # (у страницы один канонический адрес, старые ссылки не ломаются). # 1c. Короткие адреса /oferta, /refund, /privacy отдают 200 — эти URL # напечатаны внутри самих юридических документов и уходят эквайеру. # 1d. /estimate отдаёт 200 — экран проверки, куда ведут все кнопки лэндинга. # 1e. Длинные адреса поддерева отдают 301 на короткие (включая ГОЛЫЙ # /trade-in/mera-public — прежний матчер его не ловил, «Главная» в подвале # вела в 404). # 2d. Публичный API /api/public/mera/* доступен анонимно, а /api/v1/* на # публичном домене по-прежнему 404. # 2. meraocenka.ru/v2 и /trade-in/v2, /trade-in/api/* (B2B-пути) отдают 404 — # allowlist-by-default, НЕ были случайно проброшены на B2B-дерево # tradein-frontend. Проверяются обе формы — с basePath-префиксом и без. # 3. trade-in API (/me, /history, /admin/*) отдаёт 401 анониму — данные B2B # закрыты. Именно API, а не страница: см. комментарий у проверки ниже. # 4. gendsgn.ru/api/v1/admin/* отдаёт 401 анониму (gate Site Finder). # 5. merahome.ru и meraotsenka.ru отдают 301 на канонический meraocenka.ru. # 5b. www-формы всех трёх доменов МЕРА — тоже 301 (без своих site-блоков # Caddy не выпускает сертификат, и клиент видит обрыв TLS, а не редирект). # # ВАЖНО: проверки 1 и 2 требуют, чтобы DNS A-record meraocenka.ru → IP VPS # уже существовал И деплой прошёл (сертификат Let's Encrypt выпущен). Пока # записи нет — они ожидаемо падают (DNS resolution failure / TLS handshake # failure), это НЕ регресс периметра gendsgn.ru. Проверки 3 и 4 не зависят от # DNS нового домена и обязаны быть зелёными всегда. # # Запуск вручную: # bash scripts/smoke-mera-perimeter.sh # Запуск в CI: .forgejo/workflows/perimeter-smoke.yml (workflow_dispatch + daily cron). set -uo pipefail BASE_MERA="${SMOKE_MERA_BASE:-https://meraocenka.ru}" BASE_MAIN="${SMOKE_MAIN_BASE:-https://gendsgn.ru}" fail=0 noresp=0 # --- ТЕМП ЗАПРОСОВ И ПОВТОРЫ (11.09.2026) -------------------------------- # # ЧТО СЛУЧИЛОСЬ. Прогон на голове main (a659b187) покраснел на двух последних # проверках списка с кодом `000`. `000` у curl — это НЕ «пришёл неверный код», # а «ответа не было вовсе» (таймаут/обрыв). Периметр при этом был цел: те же # пути, запрошенные вручную, отдавали 503 и 405 от приложения. # # ЗАМЕР (внешний IP, https://gendsgn.ru/trade-in/api/v1/me): # серия без пауз — 5 обрывов из 12 # серия с паузой 0.5 c — 6 обрывов из 12 # серия с паузой 2 c — 0 обрывов из 8 # с самого хоста прода — 0 обрывов из 10 (все 401) # То есть приложение отвечает, а частую серию запросов с одного внешнего # адреса отбивает ВХОД (на хосте активен fail2ban). Смоук шлёт ~43 запроса # подряд без пауз и попадает ровно под этот эффект — и падают именно # ПОСЛЕДНИЕ проверки, потому что к концу серии счётчик уже набран. # # ОТСЮДА ДВА МЕХАНИЗМА, И НИ ОДИН НЕ ТРОГАЕТ САМИ ОЖИДАНИЯ: # 1. Повтор ТОЛЬКО там, где ответа не было вовсе. Ответ с «не тем» кодом — # это результат проверки, он не повторяется никогда: иначе ретрай # маскировал бы настоящий регресс периметра, ради которого всё написано. # 2. Пауза между проверками, чтобы серия не выглядела флудом. # # ПОЧЕМУ ПАУЗА 2 c, А НЕ МЕНЬШЕ. Это наименьшая величина, у которой есть # замер: 0.5 c измеренно НЕ помогает (6 обрывов из 12 — не лучше, чем без # пауз), 2 c даёт ноль обрывов, промежуточные значения никто не мерил, и # взять их означало бы выдумать число. Цена ИЗМЕРЕНА, а не оценена: прогон # целиком занимал 89 c и стал занимать 160 c (оба замера 11.09 с локальной # машины, 43 проверки, все зелёные). Под это в воркфлоу периметра поднят # timeout-minutes: неотвечающая проверка сетевого класса стоит 3×15 c таймаута # + 2 c и 4 c пауз ретрая + 2 c паузы между проверками = 53 c, а худший случай # (прод не отвечает вовсе — мертвы все 43) = 43 × 53 ≈ 2279 c ≈ 38 мин. # Ни 5, ни 10 минут на него не хватало: job убивали ДО печати FAIL-строк и # итога, то есть ровно там, где лог нужнее всего. # Обе величины переопределяются из окружения — для отладки локально # (`SMOKE_PAUSE=0 bash scripts/smoke-mera-perimeter.sh` даёт прежний темп). SMOKE_PAUSE="${SMOKE_PAUSE:-2}" SMOKE_ATTEMPTS="${SMOKE_ATTEMPTS:-3}" # curl_try: запрос с повтором, если ответа не пришло ВООБЩЕ, и с паузой после. # # Признак «ответа не было» берём у самого curl — код возврата из СЕТЕВОГО # класса (перечислен в константе NETWORK_RC ниже). Он строго эквивалентен # `%{http_code}` = 000, но доступен ВСЕМ проверкам, включая те, которые # http_code вообще не запрашивают: до этой правки обрыв в # check_redirect_location читался как «Location не тот», а обрыв при загрузке # лэндинга — как «сам лэндинг сломан». Один сторож в общей обёртке чинит все # места сразу, а не только те две проверки, что покраснели. # # СЕТЕВОЙ КЛАСС — И ТОЛЬКО ОН. Коды живут в константе, а не в тексте # комментария, чтобы описание и поведение не разъехались: # 6 — DNS не разрешился # 7 — соединение отвергнуто (вход лежит; REJECT у fail2ban выглядит так же) # 28 — таймаут # 35 — обрыв на TLS-хендшейке # 52 — сервер закрыл соединение, не ответив # 56 — обрыв при приёме ответа # Общее у них ровно одно: ответа не получено, и повтор имеет шанс помочь — # тот самый эффект входа, ради которого повтор и заведён. # # ОСТАЛЬНЫЕ КОДЫ НЕ ПОВТОРЯЕМ И НЕ ЗОВЁМ «ответа нет». Протухший, чужой или # самоподписанный сертификат даёт rc=60 (замер 12.09: expired.badssl.com, # self-signed.badssl.com, wrong.host.badssl.com — все три). Это ИЗМЕРЕННЫЙ # отказ периметра, а не потерянный запрос: см. проверку 5b в шапке — без # site-блока Caddy не выпускает сертификат, и клиент видит обрыв TLS вместо # редиректа, ради этого проверка и написана. Отказ детерминирован: три попытки # дадут тот же rc, потратив 6 c пауз, а результат уехал бы в колонку «не # измеряли» — то есть регресс спрятался бы ровно там, где его надо показать. NETWORK_RC=" 6 7 28 35 52 56 " # Ответ, пришедший с «неправильным» кодом, для curl — успех (rc=0), повтора не # будет; проверка отработает ровно так же, как до правки. curl_try() { local attempt=1 rc out while :; do out=$(curl "$@" 2>/dev/null) rc=$? { [ "$rc" -eq 0 ] || [ "$attempt" -ge "$SMOKE_ATTEMPTS" ]; } && break # Код вне сетевого класса — повторять нечего, отдаём rc наверх (см. NETWORK_RC). case "$NETWORK_RC" in *" $rc "*) ;; *) break ;; esac # Пауза растёт: 2 c, затем 4 c — ниже 2 c смысла нет (см. замер выше). echo " RETRY: ответа нет (curl rc=$rc), попытка $((attempt + 1)) из $SMOKE_ATTEMPTS через $((attempt * 2)) c: $*" >&2 sleep "$((attempt * 2))" attempt=$((attempt + 1)) done [ "$SMOKE_PAUSE" = "0" ] || sleep "$SMOKE_PAUSE" printf '%s' "$out" return "$rc" } # curl_failed: красная строка, когда ответа не удалось получить у самого curl. # # За одним «rc != 0» прячутся ДВА разных диагноза, и путать их нельзя: # - сетевой класс → «ответа нет»: цел ли периметр, мы не знаем, проверка НЕ # измерилась (плюс счётчик noresp и отдельная строка в итоге); # - всё остальное, прежде всего cert-класс (rc=60) → обычный FAIL: отказ # ИЗМЕРЕН, это регресс периметра, искать надо конфиг, а не флап входа. # # rc печатается в ОБЕИХ ветках: строка RETRY при SMOKE_ATTEMPTS=1 не выводится # вовсе, и без rc читатель красного лога не отличит «домена нет» (6) от # «сертификат протух» (60) — а это диагнозы из разных отделов. curl_failed() { local desc="$1" url="$2" rc="$3" reason fail=1 case "$NETWORK_RC" in *" $rc "*) echo "FAIL (ответа нет): $desc ($url — вход не отдал ответ после $SMOKE_ATTEMPTS попыток, curl rc=$rc;" \ "это НЕ измеренный код ответа, периметр этой проверкой НЕ проверен — повторите URL вручную)" noresp=$((noresp + 1)) return ;; esac case "$rc" in 60|51|83) reason="TLS-сертификат отвергнут" ;; 58|77) reason="проблема с клиентским сертификатом/CA" ;; *) reason="curl не выполнил запрос" ;; esac echo "FAIL: $desc ($url -> $reason (curl rc=$rc); ответ измерен как отказ, это не потерянный" \ "запрос — повтора не было, код вне сетевого класса «ответа нет»)" } check() { local desc="$1" url="$2" expected="$3" local code rc code=$(curl_try -s -o /dev/null -w '%{http_code}' --max-time 15 "$url") rc=$? if [ "$rc" -ne 0 ]; then curl_failed "$desc" "$url" "$rc" elif [ "$code" = "$expected" ]; then echo "PASS: $desc ($url -> $code)" else echo "FAIL: $desc ($url -> got '${code:-}', expected $expected)" fail=1 fi } # check_any: PASS, если код ответа — ЛЮБОЙ из перечисленных. # # Нужен там, где проверяемое свойство — «этого адреса наружу нет», а каким # именно отказом это выражено, зависит от того, какой рубеж ответил первым. # На gendsgn.ru таких рубежа два: пилотный basic_auth (401) стоит выше # allowlist'а site-блока (404), и какой сработает — вопрос порядка директив, а # не безопасности. Фиксировать один конкретный код значило бы ронять смоук при # снятии пилотного гейта, то есть при изменении, которое к предмету проверки # отношения не имеет. check_any() { local desc="$1" url="$2" shift 2 local code rc expected="$*" code=$(curl_try -s -o /dev/null -w '%{http_code}' --max-time 15 "$url") rc=$? if [ "$rc" -ne 0 ]; then curl_failed "$desc" "$url" "$rc" return fi for want in "$@"; do if [ "$code" = "$want" ]; then echo "PASS: $desc ($url -> $code)" return fi done echo "FAIL: $desc ($url -> got '${code:-}', expected one of: $expected)" fail=1 } check_post() { local desc="$1" url="$2" body="$3" expected="$4" reject="${5:-}" local out code head_and_body rc # -i: заголовки попадают в вывод вместе с телом — по ним отличаем ответ # приложения от заглушки Caddy (см. $reject у вызова payments/notify). out=$(curl_try -s -i -w '\n%{http_code}' --max-time 15 \ -X POST -H 'Content-Type: application/json' -d "$body" "$url") rc=$? if [ "$rc" -ne 0 ]; then curl_failed "$desc" "$url" "$rc" return fi code=${out##*$'\n'} head_and_body=${out%$'\n'*} if [ "$code" != "$expected" ]; then echo "FAIL: $desc ($url -> got '${code:-}', expected $expected)" fail=1 elif [ -n "$reject" ] && printf '%s' "$head_and_body" | grep -qEi "$reject"; then echo "FAIL: $desc ($url -> $code, но ответ от заглушки окна деплоя, а не от приложения)" fail=1 else echo "PASS: $desc ($url -> $code)" fi } # check_redirect_location: сверяет буквальный заголовок Location редиректа, # а не только код ответа — нужен там, где регресс не меняет код (301 # остаётся 301), а меняет ТОЛЬКО цель (баг 10.09.2026 у @meraShortSlash в # apps.caddy: query терялась молча, код ответа был зелёным всю дорогу). # curl без -L (редирект не проходим), -D - дампит заголовки в stdout. # # ФОРМА LOCATION НЕ ФИКСИРУЕТСЯ. Caddy отдаёт цель редиректа так, как её # собрала директива: для `redir * {uri}` это относительный путь # (`/articles?utm_source=vc`), а для веток с явным хостом — абсолютный URL # (`https://meraocenka.ru/articles?utm_source=vc`). Обе формы валидны по # RFC 7231, браузер разрешает относительную сам, и переписывание одной ветки # конфига на другую — не регресс, ради которого стоит ронять смоук. Поэтому # сверяется ХВОСТ: путь с query, с какого бы префикса Location ни начинался. # Ровно это и есть предмет проверки — что query дожила до цели. check_redirect_location() { local desc="$1" url="$2" expected_suffix="$3" local location headers rc # Заголовки сначала забираем целиком, и только потом разбираем: при обрыве # соединения grep по пустому выводу дал бы «Location: » — красную # строку про подмену цели редиректа там, где ответа не было вовсе. headers=$(curl_try -s -o /dev/null -D - --max-time 15 "$url") rc=$? if [ "$rc" -ne 0 ]; then curl_failed "$desc" "$url" "$rc" return fi location=$(printf '%s' "$headers" \ | grep -i '^location:' | tr -d '\r' | sed 's/^[Ll]ocation: *//') case "$location" in "$expected_suffix"|*"$expected_suffix") echo "PASS: $desc ($url -> Location: $location)" ;; *) echo "FAIL: $desc ($url -> got Location '${location:-}', expected ending with '$expected_suffix')" fail=1 ;; esac } # check_content_type: 200 И заявленный Content-Type начинается с ожидаемого. # # Заведён под og:image. Для картинки превью «200» само по себе ничего не # доказывает: перепутанный rewrite приводит к 200 с HTML-страницей фронта, и # обходчик мессенджера её молча отбросит — ссылка развернётся без картинки, а # смоук останется зелёным. Проверять надо ровно то, ради чего адрес открыт. check_content_type() { local desc="$1" url="$2" expected_prefix="$3" local out code ctype rc out=$(curl_try -s -o /dev/null -D - -w '%{http_code}' --max-time 15 "$url") rc=$? if [ "$rc" -ne 0 ]; then curl_failed "$desc" "$url" "$rc" return fi code=${out##*$' '} ctype=$(printf '%s' "$out" | grep -i '^content-type:' | tr -d ' ' | sed 's/^[Cc]ontent-[Tt]ype: *//' | head -1) if [ "$code" = "200" ] && [ "${ctype#"$expected_prefix"}" != "$ctype" ]; then echo "PASS: $desc ($url -> $code, $ctype)" else echo "FAIL: $desc ($url -> got '${code:-}' / '${ctype:-}', expected 200 + $expected_prefix)" fail=1 fi } echo "== МЕРА B2C perimeter smoke (ЭТАП 1) ==" # 1. Публичный домен отдаёт 200 анонимно. check "meraocenka.ru root — public 200" "$BASE_MERA/" 200 # 1b. Подстраница лэндинга по ДЛИННОМУ адресу теперь отдаёт 301 на короткий, а # не 200: с 15.08.2026 у публичной страницы один канонический адрес. # Проверка осталась именно здесь, потому что раньше она сторожила # доступность обязательного по 152-ФЗ документа — теперь сторожит, что при # переходе на короткие адреса длинные не превратились в 404 (тогда бы # сломались уже разосланные ссылки). check "meraocenka.ru длинная privacy — 301 на короткую" "$BASE_MERA/trade-in/mera-public/privacy" 301 # 1c. Короткие адреса юридических документов. Это НЕ дубль проверки 1b: именно # эти три URL напечатаны внутри самих документов и уходят в заявку # эквайеру — если rewrite выпадет из Caddyfile, оферта будет ссылаться на # 404, и заявку завернут. Проверяем все три поимённо, потому что и в # Caddyfile они перечислены поимённо (allowlist, не шаблон). check "meraocenka.ru/oferta — public 200" "$BASE_MERA/oferta" 200 check "meraocenka.ru/refund — public 200" "$BASE_MERA/refund" 200 check "meraocenka.ru/privacy — public 200" "$BASE_MERA/privacy" 200 # 1d. Экран проверки квартиры — короткий адрес, на который ведут все кнопки # лэндинга. Отвалится handle — кнопки «Проверить» станут ссылками в 404. check "meraocenka.ru/estimate — public 200" "$BASE_MERA/estimate" 200 # 1d2. Страница «МЕРА для бизнеса» — короткий адрес, на который ведут пункт # шапки и подвала. До 31.08.2026 они вели в закрытый контур и приводили # человека на форму входа; отвалится handle — вернётся 404 вместо неё. check "meraocenka.ru/business — public 200" "$BASE_MERA/business" 200 # 1d3. robots.txt / sitemap.xml — первое, что запрашивает поисковый краулер # при индексации. robots.txt отдаётся ОТДЕЛЬНЫМ handle (Next-конвенция: # файл в корне app/, а не под mera-public/), sitemap.xml — той же строкой # что и остальные @meraPages (см. комментарии в apps.caddy). Отвалится # любой из handle — сайт не проиндексируется вовсе. check "meraocenka.ru/robots.txt — public 200" "$BASE_MERA/robots.txt" 200 check "meraocenka.ru/sitemap.xml — public 200" "$BASE_MERA/sitemap.xml" 200 # 1d4. og:image — картинка превью ссылок. Её просят обходчики мессенджеров и # соцсетей, анонимно и без Referer; отвалится handle — ссылки на сайт # начнут разворачиваться без картинки, и заметить это по логам # приложения нельзя (запрос туда просто не доходит). Content-Type # проверяется вместе с кодом: 200 с HTML вместо PNG выглядел бы так же. check_content_type "meraocenka.ru/og-mera.png — public 200 + image/png" "$BASE_MERA/og-mera.png" "image/png" check_content_type "meraocenka.ru/logo-mera.png — public 200 + image/png" "$BASE_MERA/logo-mera.png" "image/png" # 1e. Длинные адреса поддерева отдают 301 на короткие: у страницы один # канонический адрес, а старые ссылки и закладки продолжают работать. # ГОЛЫЙ /trade-in/mera-public — регресс на баг 15.08.2026: прежний матчер # `/trade-in/mera-public/*` эту форму не ловил, и ссылка «Главная» в # подвале v3 вела в 404. check "meraocenka.ru длинный корень — 301 на /" "$BASE_MERA/trade-in/mera-public" 301 check "meraocenka.ru длинная оферта — 301 на /oferta" "$BASE_MERA/trade-in/mera-public/oferta" 301 # 1f. Регресс-тест на баг 10.09.2026 (замер на живом проде): редирект со # слэшем терял query-строку — статьи публикуются с UTM-метками, а # мессенджеры и автолинкификаторы дописывают слэш к скопированной ссылке, # то есть именно этот трафик терял атрибуцию молча (код ответа # оставался 301, поэтому предыдущая версия смоука проблему не ловила). # Проверяем сам факт 301 (уже покрыт проверками check выше по коду) И # буквальный Location — только вторая половина ловит регресс. check_redirect_location "meraocenka.ru/articles/?utm_source=vc — query сохраняется на редиректе" \ "$BASE_MERA/articles/?utm_source=vc" "/articles?utm_source=vc" # 2. B2B-путь на публичном домене — 404 (allowlist-by-default), не 200/401. check "meraocenka.ru/v2 — B2B path must 404" "$BASE_MERA/v2" 404 # 2b. Те же B2B-пути в basePath-форме — 404. Это регресс-тест именно на # matcher `handle /trade-in/mera-public/*`: расширь его случайно до # `/trade-in/*` — и B2B-дерево уедет наружу через публичный домен, а # проверка 2 (/v2 без префикса) этого НЕ заметит. check "meraocenka.ru/trade-in/v2 — B2B path must 404" "$BASE_MERA/trade-in/v2" 404 check "meraocenka.ru/trade-in/api/* — must 404 (не проксируем API)" "$BASE_MERA/trade-in/api/v1/me" 404 # 2c. Статика проксируется ТОЛЬКО из _next/static/*. Оптимизатор картинок # /_next/image на лэндинге не нужен (next/image там не импортируется) и # наружу не открыт — иначе аноним получил бы CPU-нагрузку по запросу. # Ловит расширение матчера обратно до `/trade-in/_next/*`. check "meraocenka.ru/_next/image — must 404 (не открываем оптимизатор)" "$BASE_MERA/trade-in/_next/image?url=%2Ftest.png&w=64&q=75" 404 # 2c-bis. Внутри разрешённой статики закрыто поддерево ЧУЖИХ маршрутов (#3324): # App Router кладёт код постранично в chunks/app/<маршрут>/, и до этой # правки аноним скачивал с публичного домена бандлы /admin, /team, # /scrapers — с именами внутренних ручек внутри. # # КОД 404 ЗДЕСЬ НЕДОСТАТОЧЕН: несуществующий чанк Next тоже отдаёт 404, # поэтому проверка не отличила бы «Caddy отсёк» от «Caddy проксировал, а # файла нет» — и осталась бы зелёной после отката матчера. Отличаем по # ТЕЛУ: `respond 404` Caddy пустой (0 байт), 404 от Next — непустой # (замер на проде 02.09.2026: 9 байт). check_caddy_404() { local desc="$1" url="$2" local out code size rc out=$(curl_try -s -o /dev/null -w '%{http_code} %{size_download}' --max-time 15 "$url") rc=$? if [ "$rc" -ne 0 ]; then curl_failed "$desc" "$url" "$rc" return fi code=${out%% *} size=${out##* } if [ "$code" = "404" ] && [ "$size" = "0" ]; then echo "PASS: $desc ($url -> 404, пустое тело = отсёк Caddy)" else echo "FAIL: $desc ($url -> got '${out:-}', expected '404 0')" fail=1 fi } check_caddy_404 "meraocenka.ru — чанки /admin не раздаются" \ "$BASE_MERA/trade-in/_next/static/chunks/app/admin/page-smoke.js" check_caddy_404 "meraocenka.ru — чанки /admin/analytics не раздаются" \ "$BASE_MERA/trade-in/_next/static/chunks/app/admin/analytics/page-smoke.js" check_caddy_404 "meraocenka.ru — чанки /team не раздаются" \ "$BASE_MERA/trade-in/_next/static/chunks/app/team/page-smoke.js" # Обратная сторона того же матчера: статика САМОГО лэндинга обязана остаться # живой. Без этой строки «починка» вида «404 на весь chunks/app/» выглядела бы # успешной, а публичный сайт молча остался бы без JS. layout_html=$(curl_try -s --max-time 15 "$BASE_MERA/") layout_rc=$? if [ "$layout_rc" -ne 0 ]; then # Обрыв на загрузке лэндинга раньше попадал в ветку «не нашёл чанк» и читался # как «сам лэндинг сломан» — диагноз, которого никто не измерял. curl_failed "HTML лэндинга (ищем в нём layout-чанк)" "$BASE_MERA/" "$layout_rc" else layout_chunk=$(printf '%s' "$layout_html" \ | grep -o '/trade-in/_next/static/chunks/app/layout-[^"]*\.js' | head -1) if [ -z "$layout_chunk" ]; then # Пустая строка вместо пути дала бы запрос к корню и зелёную проверку ни о # чём — поэтому это FAIL, а не «пропустим». echo "FAIL: не нашёл layout-чанк в HTML лэндинга (сам лэндинг сломан?)" fail=1 else check "meraocenka.ru — корневой layout-чанк лэндинга жив (200)" "$BASE_MERA$layout_chunk" 200 fi fi # 2d. Публичный API МЕРЫ (#2911). Ровно две ручки под /api/public/mera/* # доступны анонимно на обоих доменах; ВЕСЬ /api/v1/* на публичном домене # по-прежнему 404. # # Пара проверок ниже неразделима: первая доказывает, что форма вообще # работает, вторая — что новый handle не расширил периметр до # `/trade-in/api/*`. Зелёная только первая = API открыт целиком и тест это # пропустил (ровно та ошибка, ради которой в Caddyfile выбран отдельный # префикс, а не поимённый проброс v1-путей). # ПРО ВНЕШНЮЮ ЗАВИСИМОСТЬ (#2917). Опасение «упадёт DaData — покраснеет # смоук без всякого регресса» проверено по коду и оказалось у́же, чем # звучит: `geocoder.suggest` — это цепочка «кадастровый тир → DaData → # Nominatim → []», и КАЖДЫЙ внешний тир обёрнут в `except Exception` # (services/geocoder.py). Отказ, квота и 5xx провайдера дают пустой список # и HTTP 200 — проверка остаётся зелёной. Покраснеть она может только если # провайдер ВИСНЕТ дольше 15 с (--max-time у curl), то есть на зависании, # а не на отказе. Ослаблять ожидание не стали: 200 здесь проверяет # открытость пути анониму, ради которой проверка и написана. check_post "meraocenka.ru public suggest — 200 anonymous" "$BASE_MERA/trade-in/api/public/mera/suggest" '{"q":"Малышева"}' 200 check_post "meraocenka.ru public coverage — 200 anonymous" "$BASE_MERA/trade-in/api/public/mera/coverage" '{"lat":56.838,"lon":60.597,"rooms":2,"area_m2":54}' 200 check "meraocenka.ru v1 geocode — must stay 404" "$BASE_MERA/trade-in/api/v1/geocode/suggest?q=test" 404 check "meraocenka.ru v1 coverage — must stay 404" "$BASE_MERA/trade-in/api/v1/trade-in/coverage" 404 # Тот же публичный путь на gendsgn.ru: страницу лэндинга открывают и оттуда # (QA за basic_auth), поэтому URL у формы один на оба домена. Здесь он # проходит через уже существующий `handle /trade-in/api/*` — проверка ловит # регресс в rbac._PUBLIC_PATHS (стало бы 401), а не в Caddyfile. check_post "gendsgn.ru public suggest — 200 anonymous" "$BASE_MAIN/trade-in/api/public/mera/suggest" '{"q":"Малышева"}' 200 # 3. B2B-данные trade-in по-прежнему закрыты анониму. # # ВНИМАНИЕ: проверять СТРАНИЦУ (/trade-in/v2) больше нельзя — она отдаёт 200. # После #2555/#2558 trade-in ушёл с Caddy basic_auth на собственный логин: # страница рендерится анониму, а RouteGuard уже на клиенте уводит на /login. # Гейт данных переехал на API — там и проверяем, иначе тест зелёный при # открытом наружу бэкенде. check "trade-in /api/v1/me — 401 anonymous" "$BASE_MAIN/trade-in/api/v1/me" 401 # Путь именно `/api/v1/trade-in/history`: роутер trade_in подключён в main.py с # префиксом `/api/v1/trade-in`, а сама ручка объявлена как `@router.get("/history")`. # До 05.09.2026 здесь стоял несуществующий `/api/v1/history` — проверка была # зелёной только потому, что guard отвечал 401 на ЛЮБОЙ путь; после #3352 # несуществующий путь даёт 404, и проверка честно покраснела. check "trade-in /api/v1/trade-in/history — 401 anonymous (чужие оценки)" "$BASE_MAIN/trade-in/api/v1/trade-in/history" 401 # #3360: admin-префикс анониму отвечает 404, а НЕ 401. Периметр здесь не срезан # (Caddy-блок /trade-in/api/* стоит выше basic_auth-снипета, и срезать нельзя — # admin-UI зовёт эти же пути из браузера), поэтому существование ручки прячет сам # guard. Пара путей взята намеренно разная: `/proxies` — РЕАЛЬНЫЙ роут # (app/api/v1/admin.py), `/users` — несуществующий. Одинаковый код на обоих и # означает, что перебором имён admin-API снаружи ничего не узнать; 401 на первом # = регресс маскировки (app/core/rbac.py::_unauthenticated). check "trade-in /api/v1/admin/proxies — 404 anonymous (существующая ручка скрыта)" \ "$BASE_MAIN/trade-in/api/v1/admin/proxies" 404 check "trade-in /api/v1/admin/users — 404 anonymous (несуществующая — тот же ответ)" \ "$BASE_MAIN/trade-in/api/v1/admin/users" 404 # 3b. sitemap.xml публичного лэндинга МЕРЫ не должен просачиваться через # закрытый B2B-контур gendsgn.ru — корень gendsgn.ru принадлежит Site # Finder, у него нет своего route на /sitemap.xml под МЕРУ, allowlist-by- # default этого site-блока (как и у meraocenka.ru) отдаёт 404 на всё # незаявленное. # # ДВА ДОПУСТИМЫХ КОДА, А НЕ ОДИН. Первая редакция этой проверки ждала 404 и # упала на первом же прогоне против прода: корень gendsgn.ru закрыт # пилотным basic_auth, и гейт отвечает 401 РАНЬШЕ, чем запрос доходит до # allowlist'а. Оба ответа означают ровно проверяемое — карты сайта МЕРЫ на # B2B-домене нет; какой именно рубеж сработал первым, к предмету проверки # отношения не имеет, а вот снятие пилотного гейта (оно запланировано) # переключит 401 на 404 и уронило бы жёсткое ожидание на пустом месте. # Красная строка здесь — только 200: он означал бы, что sitemap реально # отдаётся из закрытого контура. # # Если у Site Finder когда-нибудь появится СВОЙ /sitemap.xml, ожидание # придётся пересмотреть на «200 с другим содержимым». check_any "gendsgn.ru/sitemap.xml — must NOT serve MERA sitemap (закрытый контур)" "$BASE_MAIN/sitemap.xml" 401 404 # 4. gendsgn.ru/api/v1/admin/* отдаёт 401 анониму (auth gate стоит ДО роутинга # в FastAPI — конкретный путь неважен, любой /api/v1/admin/* перехватывается # на уровне Caddy до бэкенда). check "gendsgn.ru/api/v1/admin/* — 401 anonymous" "$BASE_MAIN/api/v1/admin/users" 401 # 5. Домены-спутники ведут на канонический (301, без следования редиректу — # curl без -L, поэтому ждём именно код редиректа, а не 200 конечной страницы). # Как и проверки 1-2, требуют DNS + выпущенного сертификата. check "merahome.ru — 301 to canonical" "https://merahome.ru/" 301 check "meraotsenka.ru — 301 to canonical" "https://meraotsenka.ru/" 301 # 5b. www-формы — та же проверка, отдельным блоком: их отсутствие проявляется # НЕ как 404, а как обрыв TLS (curl вернёт пустой код). До 27.08 site-блоков # под www.mera* не было, A-записи при этом существовали, и «www.» в адресной # строке давал «сайт недоступен». Держим проверку, чтобы регресс был виден # здесь, а не в жалобе клиента. check "www.meraocenka.ru — 301 to canonical" "https://www.meraocenka.ru/" 301 check "www.merahome.ru — 301 to canonical" "https://www.merahome.ru/" 301 check "www.meraotsenka.ru — 301 to canonical" "https://www.meraotsenka.ru/" 301 # 6. Платёжный периметр. Заведён в PR-D2 как канарейка «ожидания обновляют # вместе с тем PR, который путь открывает». Канарейка отработала: PR-D3 # (#3231, роутер) смержен 29.08 и внёс notify в `_PUBLIC_PATHS`, ожидание # здесь обновлено тем же днём — но уже ПОСЛЕ красного деплоя, а не вместе с # ним. Кто будет делать PR-D4 — правьте ожидания в одном PR с ним. # # Состояние на 29.08, чего ждём сейчас: # - meraocenka.ru вообще не проксирует /trade-in/api/* (allowlist-by-default, # см. проверку 2) — 404 от Caddy, до бэкенда не доходит. Это ВСЁ ЕЩЁ # канарейка: PR-D4 не смержен, 404 должен держаться; # - gendsgn.ru: `checkout` по-прежнему требует X-Authenticated-User → 401 # анониму (он не публичный и не станет им); # - gendsgn.ru: `notify` — публичный по построению (вебхук банка), см. # комментарий у самой проверки ниже. check "meraocenka.ru payments/notify — must 404 (Caddy не проксирует, PR-D4)" \ "$BASE_MERA/trade-in/api/v1/trade-in/payments/notify" 404 check "meraocenka.ru payments/checkout — must 404 (Caddy не проксирует, PR-D4)" \ "$BASE_MERA/trade-in/api/v1/trade-in/payments/checkout" 404 # notify — ЕДИНСТВЕННЫЙ платёжный путь, который PR-D3 (#3231) открыл анониму # осознанно: это вебхук банка, он обязан быть достижим без наших заголовков, и # потому внесён в `_PUBLIC_PATHS`. Канарейка выше отработала как задумано — 401 # здесь стал бы теперь признаком ПОЛОМКИ (банк получил бы отказ), а не защиты. # # Проверяем POST и ждём 503: это строже прежнего 401 — утверждает сразу, что # маршрут существует (не 404 — старый образ) И что приём платежей выключен # (`payments_enabled=False`). 200 здесь означал бы, что флаг включили, не тронув # этот смоук. GET → 405 закрепляет, что путь принимает только POST. # # С #3274 голый код 503 больше не доказывает ничего: Caddy сам отдаёт 503 на # окне деплоя (`handle_errors` -> caddy/deploy-window.caddy.snippet), и тогда # запрос до приложения не дошёл вовсе. Отличаем по признакам заглушки — # заголовок `Retry-After: 30` и `"error":"service_unavailable"` в теле; у # приложения тело `{"detail":"payments are disabled"}` и никакого Retry-After. # Совпал код, но пришла заглушка → FAIL, а не молчаливый PASS. check_post "trade-in payments/notify — 503 anonymous (маршрут есть, приём выключен)" \ "$BASE_MAIN/trade-in/api/v1/trade-in/payments/notify" '{}' 503 \ 'service_unavailable|^retry-after: *30' check "trade-in payments/notify — 405 на GET (только POST)" \ "$BASE_MAIN/trade-in/api/v1/trade-in/payments/notify" 405 check "trade-in payments/checkout — 401 anonymous (не публичный по построению)" \ "$BASE_MAIN/trade-in/api/v1/trade-in/payments/checkout" 401 echo "========================================" if [ "$noresp" -gt 0 ]; then # Отдельная строка в итоге, а не только у самой проверки: читатель красного # лога должен сразу видеть, что часть проверок НЕ ИЗМЕРИЛАСЬ, и не искать # регресс периметра там, где ответа просто не было. echo "БЕЗ ОТВЕТА: $noresp проверок не получили ответа даже после $SMOKE_ATTEMPTS попыток." echo " Это не измеренный код ответа. Повторите эти URL вручную (и учтите, что" echo " вход отбивает частые серии запросов с одного адреса — см. шапку скрипта)." fi if [ "$fail" -eq 0 ]; then echo "ALL CHECKS PASSED" else echo "SOME CHECKS FAILED — see FAIL lines above" fi exit "$fail"