#!/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. # # ВАЖНО: проверки 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:-}', 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:-}', 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 # 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 check "trade-in payments/notify — 401 anonymous (rbac закрыт до PR-D3)" \ "$BASE_MAIN/trade-in/api/v1/trade-in/payments/notify" 401 check "trade-in payments/checkout — 401 anonymous (rbac закрыт до PR-D3)" \ "$BASE_MAIN/trade-in/api/v1/trade-in/payments/checkout" 401 echo "========================================" if [ "$fail" -eq 0 ]; then echo "ALL CHECKS PASSED" else echo "SOME CHECKS FAILED — see FAIL lines above" fi exit "$fail"