После мержа платёжного роутера (#3231) perimeter-smoke стал красным на проде: FAIL: trade-in payments/notify — 401 anonymous (rbac закрыт до PR-D3) gendsgn.ru/.../payments/notify -> got '405', expected 401 Это НЕ поломка, а срабатывание канарейки, которую сам скрипт и обещал: «осознанно станет красной, когда PR-D3 явно откроет эти пути — тогда ожидания здесь надо обновить вместе с ними». Я смержил PR-D3 и ожидание не обновил. Что изменилось по существу: /payments/notify теперь в rbac._PUBLIC_PATHS — вебхук Т-Банка не умеет присылать X-Authenticated-User, аутентификация там подписью тела. /payments/checkout НЕ открывался и остаётся под 401. Проверка переведена с GET на POST: GET по POST-only маршруту даёт 405 и не говорит ничего ни про rbac, ни про флаг. POST с пустым телом проходит rbac (иначе 401) и упирается в выключенный контур — 503. Заодно закрыты три дыры в покрытии смоука, все замерены на проде: · /stats и /showcase — ручки, из которых лэндинг берёт числа. При их отказе страница по построению рендерится БЕЗ блоков (честная деградация), то есть тихо. Ничем другим это не ловится. · /estimate и /estimate/read — обязаны быть неотличимы от несуществующего пути при выключенном флаге. До #3233 отвечали 422 с перечнем своих полей. Путь длинный (/trade-in/api/public/...) намеренно: короткого на публичном домене нет, а по нему ходит фронт — проба обязана идти рабочим трактом. Следующая канарейка отмечена в комментарии: при включении PAYMENTS_ENABLED 503 станет отказом по подписи, и ожидание надо будет обновить вместе с флагом. Прогон на проде: ALL CHECKS PASSED.
245 lines
19 KiB
Bash
245 lines
19 KiB
Bash
#!/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
|
||
|
||
check() {
|
||
local desc="$1" url="$2" expected="$3"
|
||
local code
|
||
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$url" 2>/dev/null)
|
||
if [ "$code" = "$expected" ]; then
|
||
echo "PASS: $desc ($url -> $code)"
|
||
else
|
||
echo "FAIL: $desc ($url -> got '${code:-<no response>}', expected $expected)"
|
||
fail=1
|
||
fi
|
||
}
|
||
|
||
check_post() {
|
||
local desc="$1" url="$2" body="$3" expected="$4"
|
||
local code
|
||
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 \
|
||
-X POST -H 'Content-Type: application/json' -d "$body" "$url" 2>/dev/null)
|
||
if [ "$code" = "$expected" ]; then
|
||
echo "PASS: $desc ($url -> $code)"
|
||
else
|
||
echo "FAIL: $desc ($url -> got '${code:-<no response>}', expected $expected)"
|
||
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
|
||
|
||
# 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
|
||
|
||
# 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
|
||
|
||
# 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
|
||
check "trade-in /api/v1/history — 401 anonymous (чужие оценки)" "$BASE_MAIN/trade-in/api/v1/history" 401
|
||
check "trade-in /api/v1/admin/* — 401 anonymous" "$BASE_MAIN/trade-in/api/v1/admin/users" 401
|
||
|
||
# 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-D3 (роутер) и PR-D4
|
||
# (Caddy), но САМ НИЧЕГО НЕ ОТКРЫВАЕТ. Ожидаем закрытое состояние С ОБЕИХ
|
||
# СТОРОН прямо сейчас:
|
||
# - meraocenka.ru вообще не проксирует /trade-in/api/* (allowlist-by-default,
|
||
# см. проверку 2) — 404 от Caddy, до бэкенда не доходит;
|
||
# - gendsgn.ru проксирует /trade-in/api/* в tradein-backend, но rbac_guard
|
||
# (`_PUBLIC_PATHS` в app/core/rbac.py — ЭТОТ PR её не трогает) не знает
|
||
# платёжные пути и требует X-Authenticated-User → 401 анониму.
|
||
# Если один из этих чек-ов вдруг перестанет быть 404/401 РАНЬШЕ мержа
|
||
# PR-D3/PR-D4 — это и есть преждевременная утечка периметра, которую ловит
|
||
# этот смоук (канарейка: осознанно станет красной, когда PR-D3/PR-D4 явно
|
||
# откроют эти пути — тогда ожидания здесь надо обновить вместе с ними).
|
||
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
|
||
# PR-D3 СМЕРЖЕН 29.08.2026 — канарейка сработала ровно как обещано выше, и её
|
||
# ожидания обновлены вместе с ней (иначе смоук остался бы красным навсегда и
|
||
# перестал бы что-либо значить).
|
||
#
|
||
# Что изменилось: `/payments/notify` теперь В `rbac._PUBLIC_PATHS` — иначе
|
||
# вебхук Т-Банка получал бы 401, а банк не умеет присылать X-Authenticated-User.
|
||
# Аутентификация там — подпись `Token` тела, её проверяет сам хендлер.
|
||
# `/payments/checkout` открыт НЕ был и остаётся под 401: его зовёт наш же фронт.
|
||
#
|
||
# Проверяем POST'ом, а не GET'ом: GET по POST-only маршруту даёт 405, и это
|
||
# ничего не говорит ни про rbac, ни про флаг. POST с пустым телом проходит
|
||
# rbac (иначе 401) и упирается в выключенный контур — 503.
|
||
#
|
||
# ⚠️ СЛЕДУЮЩАЯ КАНАРЕЙКА ЗДЕСЬ ЖЕ: когда владелец включит PAYMENTS_ENABLED,
|
||
# этот 503 станет отказом по подписи (400/403). Тогда ожидание надо обновить
|
||
# ВМЕСТЕ с включением флага — не подгонять задним числом.
|
||
check_post "trade-in payments/notify — 503 anonymous (rbac пропускает, контур выключен)" \
|
||
"$BASE_MAIN/trade-in/api/v1/trade-in/payments/notify" '{}' 503
|
||
check "trade-in payments/checkout — 401 anonymous (закрыт, зовёт только наш фронт)" \
|
||
"$BASE_MAIN/trade-in/api/v1/trade-in/payments/checkout" 401
|
||
|
||
# ── Публичные ручки витрины лэндинга (29.08.2026) ────────────────────────────
|
||
#
|
||
# Числа на лэндинге приходят из этих двух ручек. Если они отвалятся, страница
|
||
# по построению отрендерится БЕЗ блоков (честная деградация) — то есть тихо и
|
||
# незаметно. Поэтому проверяем их здесь: молчаливое исчезновение витрины
|
||
# ничем другим не ловится.
|
||
#
|
||
# Путь длинный (`/trade-in/api/public/...`) НАМЕРЕННО: короткого
|
||
# `/api/public/...` на публичном домене нет, и один и тот же адрес обязан
|
||
# работать с обоих хостов — см. шапку frontend/src/app/mera-public/public-api.ts.
|
||
check "meraocenka.ru /stats — public 200" \
|
||
"$BASE_MERA/trade-in/api/public/mera/stats" 200
|
||
check "meraocenka.ru /showcase — public 200" \
|
||
"$BASE_MERA/trade-in/api/public/mera/showcase" 200
|
||
|
||
# Анонимный расчёт закрыт флагом public_estimate_enabled и обязан быть
|
||
# НЕОТЛИЧИМ от несуществующего пути. До 29.08 он отвечал 422 с перечнем своих
|
||
# полей: проверка флага стояла в теле хендлера, а FastAPI валидирует тело
|
||
# раньше. Гейт переехал в зависимости маршрута; здесь — прод-подтверждение.
|
||
check_post "meraocenka.ru /estimate — 404 при выключенном флаге (схема не течёт)" \
|
||
"$BASE_MERA/trade-in/api/public/mera/estimate" '{}' 404
|
||
check_post "meraocenka.ru /estimate/read — 404 при выключенном флаге" \
|
||
"$BASE_MERA/trade-in/api/public/mera/estimate/read" '{}' 404
|
||
|
||
echo "========================================"
|
||
if [ "$fail" -eq 0 ]; then
|
||
echo "ALL CHECKS PASSED"
|
||
else
|
||
echo "SOME CHECKS FAILED — see FAIL lines above"
|
||
fi
|
||
|
||
exit "$fail"
|