gendesign/scripts/smoke-mera-perimeter.sh
bot-backend d046eaada2
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m49s
feat(mera/b2c): публичный API МЕРЫ — /api/public/mera/{suggest,coverage}
Первый шаг к отдельному B2C-интерфейсу оценки на meraocenka.ru: домен получает
собственную поверхность бэкенда вместо того, чтобы тянуть куски закрытого
контура.

## Отдельный префикс, а не проброс кусков /api/v1/*

На meraocenka.ru действует allowlist-by-default. Открыть там API можно было
двумя способами: перечислить нужные v1-пути поимённо — или завести префикс, под
которым по определению не лежит ничего закрытого. Выбран второй: при первом
одна опечатка в матчере (`/trade-in/api/*` вместо точного пути) открывает
наружу весь v1 — ~20 ручек, включая PDF расчётов, фотографии и админку. Цена
ошибки, а не удобство.

Добавить сюда приватную ручку теперь нужно СПЕЦИАЛЬНО — положив файл в
app/api/public/. Случайно нельзя.

## Ноль записей в БД

Обе ручки только читают: /coverage — один SELECT, /suggest — прокси
автокомплита. Это условие, при котором публичная форма работает ДО контура
согласия 152-ФЗ (#2895: сегодня адрес физлица попадает в trade_in_estimates
раньше согласия, а пути удаления в бэкенде нет). Платный расчёт, который писать
будет, открывается только вместе с ним.

## Делегирование, а не копии

Обе ручки вызывают те же функции, что обслуживают закрытый контур
(v1.geocode.suggest_addresses, v1.trade_in.coverage_probe). Разбор #2894
показал, чем кончается вторая копия когорты: проба отвечает «данные есть» там,
где платный расчёт видит ноль. Публичный ответ переиспользует
CoverageProbeResponse — на нём уже стоит гейт «ни одного price-подобного поля».

## Бюджеты

Общего 300/60с мало: /suggest через DaData-тир — платный внешний вызов, абуз
стоит денег. Свои per-IP окна: 40/мин на подсказки (человек с debounce'ом
тратит единицы на адрес), 15/мин на пробу.

## Проверено

11 тестов, из них структурные: набор ручек под /api/public проверяется на
РАВЕНСТВО (третья, добавленная без правки теста, роняет сборку) и сверяется с
rbac._PUBLIC_PATHS в обе стороны — чтобы не осталось открытого пути-призрака.
Рядом висит закрытый маршрут-двойник: без него «аноним получает 200» одинаково
зелёный и когда исключение точечное, и когда auth-гейт снят целиком.

Мутации:
  убрать пути из rbac._PUBLIC_PATHS → 7 failed / 4 passed
  снять бюджет с /coverage         → 2 failed / 9 passed
  откат                            → 11 passed

Плюс 72 passed на связке rbac + coverage + version, `caddy validate` = Valid
configuration, ruff чист.

Смоук периметра дополнен парой, которую нельзя разделять: публичные ручки
отвечают 200 анонимно И /trade-in/api/v1/* на этом домене по-прежнему 404.
Зелёная только первая проверка = API открыт целиком, а тест этого не заметил.

Refs #2894, #2895
2026-08-15 22:32:53 +03:00

144 lines
9.7 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
# Регресс-тест публичного B2C-периметра МЕРА (ЭТАП 1 плана B2C-запуска).
#
# Проверяет инварианты периметра (см. корневой Caddyfile):
# 1. meraocenka.ru отдаёт 200 анонимно (публичный лэндинг).
# 1b. Подстраница лэндинга /trade-in/mera-public/privacy отдаёт 200 —
# политика ПДн, на которую ссылается футер.
# 1c. Короткие адреса /oferta, /refund, /privacy отдают 200 — эти URL
# напечатаны внутри самих юридических документов и уходят эквайеру.
# 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:-<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. Подстраница лэндинга (политика ПДн) доступна — на неё ссылается футер.
# Путь приезжает с basePath: next/link + basePath=/trade-in эмитит именно
# /trade-in/mera-public/privacy. Если этот handle выпадет из Caddyfile,
# обязательный по 152-ФЗ документ станет недоступен с публичной страницы.
check "meraocenka.ru privacy — public 200" "$BASE_MERA/trade-in/mera-public/privacy" 200
# 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
# 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-путей).
check "meraocenka.ru public suggest — 200 anonymous" "$BASE_MERA/trade-in/api/public/mera/suggest?q=%D0%9C%D0%B0%D0%BB%D1%8B%D1%88%D0%B5%D0%B2%D0%B0" 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=%D0%9C%D0%B0%D0%BB%D1%8B%D1%88%D0%B5%D0%B2%D0%B0" 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 "gendsgn.ru public suggest — 200 anonymous" "$BASE_MAIN/trade-in/api/public/mera/suggest?q=%D0%9C%D0%B0%D0%BB%D1%8B%D1%88%D0%B5%D0%B2%D0%B0" 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
echo "========================================"
if [ "$fail" -eq 0 ]; then
echo "ALL CHECKS PASSED"
else
echo "SOME CHECKS FAILED — see FAIL lines above"
fi
exit "$fail"