Merge remote-tracking branch 'refs/remotes/forgejo/main' into feat/mera-b2c-perimeter
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (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 / frontend-checks (pull_request) Successful in 1m16s

# Conflicts:
#	tradein-mvp/frontend/src/components/auth/RouteGuard.tsx
This commit is contained in:
bot-backend 2026-07-31 23:57:10 +03:00
commit 536640a252
94 changed files with 10888 additions and 1022 deletions

View file

@ -43,6 +43,13 @@ jobs:
# [tool.uv.workspace] меняют реальные зависимости → гейт обязан бежать.
- 'tradein-mvp/uv.lock'
- 'tradein-mvp/pyproject.toml'
# auth/roles.yaml — общий RBAC-конфиг обоих стеков, лежит В КОРНЕ
# репы и монтируется в tradein-backend (/app/auth/roles.yaml).
# tests/test_rbac.py читает именно его, поэтому правка ролей обязана
# гонять и этот гейт. Без строки правка roles.yaml не запускала НИ
# ОДИН сьют (та же дыра закрыта симметрично в ci.yml) — так на main
# уехал красный test_get_role_known_users (2026-07-30 → PR #2587).
- 'auth/**'
- '.forgejo/workflows/ci-tradein.yml'
frontend:
- 'tradein-mvp/frontend/**'

View file

@ -52,6 +52,14 @@ jobs:
backend:
- 'backend/**'
- 'data/sql/**'
# auth/roles.yaml — общий RBAC-конфиг ОБОИХ стеков (bind-mount в
# backend и в tradein-backend). Правка ролей/пользователей меняет
# поведение backend/tests/test_rbac.py, но сам файл лежит вне
# 'backend/**' → без этой строки сьют no-op'ился, и правка уезжала
# в main без единого прогона. Так и случилось 2026-07-30: user2
# переведён в expired, test_get_role_known_users стал красным и
# доехал до main незамеченным (починен в PR #2587).
- 'auth/**'
- '.forgejo/workflows/ci.yml'
frontend:
- 'frontend/**'

View file

@ -16,6 +16,10 @@ on:
- ".forgejo/workflows/deploy.yml"
- "data/sql/**"
- "ops/glitchtip-auth-forwarder/**"
# Bootstrap-SQL (создание БД auth, ALTER ROLE паролем из env) исполняется шагом
# деплоя ниже — без этого триггера правка bootstrap-файла молча не доезжала бы
# до прода до следующего чужого коммита в backend/.
- "ops/db-bootstrap/**"
workflow_dispatch:
concurrency:
@ -320,6 +324,70 @@ jobs:
echo "⚠️ GENDESIGN_FDW_PASSWORD not set in backend/.env.runtime — skipping ALTER ROLE for tradein_fdw_reader"
fi
# ── БД `auth` — единое хранилище доступов «Меры» и «Птицы» ──────────────
# Расположение файлов: схема лежит в data/sql/auth/ (ПОДКАТАЛОГ, не плоский
# data/sql/) — цикл миграций выше использует `ls -1 data/sql/*.sql`, который в
# подкаталоги не рекурсирует. Значит эти файлы физически не могут примениться
# в БД gendesign, даже если кто-то забудет про разделение; при этом триггер
# `data/sql/**` (paths выше) подкаталог покрывает, деплой запускается сам.
# Свой _schema_migrations живёт ВНУТРИ БД auth: отдельная база — отдельный
# трекинг, имена файлов двух каталогов не конфликтуют между собой.
# Порядок: сразу после bootstrap'а FDW-пароля и ДО `compose up -d` — падение
# здесь останавливает деплой (exit 1) до подъёма нового кода.
# `source backend/.env.runtime` уже выполнен выше (строка с FDW-паролем), из него
# берётся AUTH_DB_PASSWORD.
echo "→ Bootstrapping auth database (idempotent)"
docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \
psql -U "$POSTGRES_USER" -d postgres -v ON_ERROR_STOP=on \
< ops/db-bootstrap/create_auth_db.sql \
|| { echo "FAILED to create auth database"; exit 1; }
docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \
psql -U "$POSTGRES_USER" -d auth -v ON_ERROR_STOP=on -c "
CREATE TABLE IF NOT EXISTS _schema_migrations (
filename TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
"
for sql_file in $(ls -1 data/sql/auth/*.sql 2>/dev/null | sort); do
fname=$(basename "$sql_file")
# `| tr -d '[:space:]'` — как в deploy-tradein.yml: без него psql-вывод с
# лишним пробелом/CR ломает сравнение с "0" и миграция молча считается
# применённой.
applied=$(docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \
psql -U "$POSTGRES_USER" -d auth -tAc \
"SELECT COUNT(*) FROM _schema_migrations WHERE filename='$fname'" \
| tr -d '[:space:]')
if [ "$applied" = "0" ]; then
echo "→ Applying auth migration: $fname"
docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \
psql -U "$POSTGRES_USER" -d auth -v ON_ERROR_STOP=on \
< "$sql_file" \
|| { echo "FAILED on auth migration: $fname"; exit 1; }
docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \
psql -U "$POSTGRES_USER" -d auth -c \
"INSERT INTO _schema_migrations (filename) VALUES ('$fname') ON CONFLICT DO NOTHING;"
else
echo "✓ Already applied (auth): $fname"
fi
done
echo "All auth migrations applied."
# Пароль роли auth_app из env (post-migration bootstrap): миграция
# data/sql/auth/002_auth_app_role.sql создаёт роль БЕЗ пароля, пароль живёт
# только в /opt/gendesign/backend/.env.runtime. Пустая переменная — не ошибка:
# PR-1 ещё никого не подключает к этой БД, роль просто остаётся без пароля.
if [ -n "${AUTH_DB_PASSWORD:-}" ]; then
echo "→ Applying auth_app password from env"
docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \
psql -U "$POSTGRES_USER" -d auth -v ON_ERROR_STOP=on \
-v "pw=$AUTH_DB_PASSWORD" \
< ops/db-bootstrap/set_auth_app_password.sql
else
echo "⚠️ AUTH_DB_PASSWORD not set in backend/.env.runtime — skipping ALTER ROLE for auth_app"
fi
# Build local-only sidecar images (glitchtip-auth-forwarder).
# Эти services не в GHCR — сборка происходит на VPS на каждом deploy.
# Cache-friendly: первый build ~30s, последующие 1-3s если файлы не менялись.

View file

@ -1,16 +1,16 @@
# Регресс-тест публичного B2C-периметра МЕРА (ЭТАП 1 плана B2C-запуска).
#
# НЕ pre-merge гейт — эти 4 проверки требуют реального DNS + выпущенного TLS-
# сертификата для mera.gendsgn.ru, т.е. осмысленны ТОЛЬКО против прода после
# сертификата для meraocenka.ru, т.е. осмысленны ТОЛЬКО против прода после
# деплоя. Запускается вручную (workflow_dispatch) или раз в сутки (cron) —
# страхует от случайной регрессии периметра (например, будущий PR по ошибке
# открывает B2B-путь на публичном поддомене, или basic_auth gate на gendsgn.ru
# открывает B2B-путь на публичном домене, или basic_auth gate на gendsgn.ru
# случайно снимают).
#
# ДО того как появится DNS A-record mera.gendsgn.ru → IP VPS, проверки 1 и 2
# ДО того как появится DNS A-record meraocenka.ru → IP VPS, проверки 1 и 2
# (см. scripts/smoke-mera-perimeter.sh) ожидаемо КРАСНЫЕ — это не регресс,
# просто поддомен ещё не резолвится. Проверки 3 и 4 не зависят от DNS нового
# поддомена и обязаны быть зелёными всегда.
# просто домен ещё не резолвится. Проверки 3 и 4 не зависят от DNS нового
# домена и обязаны быть зелёными всегда.
name: perimeter-smoke-mera
on:

117
Caddyfile
View file

@ -11,6 +11,13 @@
# Users managed via caddy/users.caddy.snippet (git history = audit trail).
# Public exclusions: /health (liveness probe), /preview/* (static mockups).
#
# #2558: с 2026-07 basic_auth гейтит ТОЛЬКО Site Finder (`/`, `/api/*`,
# `/analytics` и т.д.). `/trade-in/*` (+ `/sale-share` redirect) вынесены ВЫШЕ
# import'ау trade-in своя авторизация (форма входа + opaque session-cookie,
# см. #2552) поверх RBAC (`tradein-mvp/backend/app/core/rbac.py`). Site Finder
# всё ещё легаси-пилотный basic_auth (roles.yaml dual-mode остаётся живым для
# него — НЕ трогать caddy/users.caddy.snippet).
#
# IMPORTANT: route { } block is required to preserve directive order.
# Without route { }, Caddy executes directives in hard-coded default order
# (basic_auth runs before handle), making /health and /preview/* exclusions
@ -70,26 +77,56 @@ gendsgn.ru {
# Оба ДО auth-import, иначе ассеты страницы уходят в @tradein (под auth) → 401 → без CSS.
@uipreview path /trade-in/ui-preview/* /trade-in/_next/static/*
handle @uipreview {
reverse_proxy tradein-frontend:3000
reverse_proxy tradein-frontend:3000 {
# #2558 review: тот же периметр-scrub, что и у /trade-in/api/* и
# @tradein ниже — этот блок тоже теперь ДО basic_auth, клиент
# мог бы прислать свой X-Authenticated-User. Сейчас инертно
# (страница статична, у tradein-frontend нет секрета для
# X-Internal-Auth-Secret), но убираем ради единообразия периметра,
# а не полагаясь на то, что downstream ничего не делает с заголовком.
header_up -X-Authenticated-User
}
}
# Auth gate (applies to all routes below within this route block).
import caddy/users.caddy.snippet
# Trade-In MVP subproject (tradein-mvp/) — gendesign-tradein docker stack,
# подключен через gendesign_shared network. Routes ДО универсального handle
# потому что Caddy матчит handle-блоки сверху вниз.
# #2558: Trade-In MVP subproject (tradein-mvp/) — gendesign-tradein docker
# stack, подключен через gendesign_shared network. Секция ЦЕЛИКОМ ДО
# `import caddy/users.caddy.snippet` ниже — /trade-in имеет собственную
# авторизацию (форма входа + opaque session-cookie, #2552; RBAC-проверка
# роли внутри tradein-backend, `app/core/rbac.py`), Site Finder basic_auth
# ей больше не нужен и не должен применяться (short-circuit сверху вниз,
# как /health и /preview/* выше).
#
# X-Authenticated-User — ЯВНОЕ УДАЛЕНИЕ (`header_up -X-Authenticated-User`),
# НЕ `header_up X-Authenticated-User {http.auth.user.id}`. Причина: этот
# блок больше не идёт ПОСЛЕ basic_auth, поэтому `{http.auth.user.id}`
# никогда не резолвится авторизованным юзером на этом пути.
# Проверено эмпирически (echo-стенд на образе caddy:2, `caddy adapt`):
# старая Set-форма (`header_up X-Authenticated-User {http.auth.user.id}`)
# НЕ пропустила бы клиентский заголовок насквозь и НЕ оставила бы поле
# пустым — Caddy подставляет НЕРАЗРЕШЁННЫЙ плейсхолдер как ЛИТЕРАЛЬНУЮ
# строку (`ReplaceKnown`), т.е. upstream получил бы буквально
# `X-Authenticated-User: {http.auth.user.id}`. Для backend (auth_mode=
# "dual", `app/core/config.py`) это НЕ подмена личности — legacy path
# (`rbac.py:186`) сделал бы `get_role("{http.auth.user.id}")`, юзер не
# найден в roles.yaml → 403 для всех. Т.е. старая форма была бы не
# security-дырой, а fail-closed-but-сломанной (все trade-in запросы без
# session-cookie получали бы 403 вместо ожидаемого 401/редиректа на логин).
# `-Field` остаётся правильным выбором не потому что Set был бы дырой, а
# потому что это ЕДИНСТВЕННАЯ форма с явно задокументированной семантикой
# "удалить заголовок" (Caddyfile reverse_proxy directive: `-<field>` =
# delete) — корректное поведение не должно зависеть от того, как именно
# Caddy трактует нерезолвленный/пустой плейсхолдер в Set-операции.
# X-Internal-Auth-Secret НЕ трогаем — #2213-секрет всегда перезаписывается
# из env (Set-операция с непустым значением, никак не связана с auth-гейтом
# basic_auth), это единственное, что теперь отсекает подделку заголовков
# изнутри gendesign_shared network для legacy dual-mode пути.
handle /trade-in/api/* {
# `handle_path /trade-in/api/*` стрипал бы целиком /trade-in/api;
# FastAPI router замаунтен на /api/v1/trade-in/* — нужен strip только
# префикса basePath /trade-in (Next.js basePath leak).
uri strip_prefix /trade-in
reverse_proxy tradein-backend:8000 {
header_up X-Authenticated-User {http.auth.user.id}
# #2213 defense-in-depth: общий секрет Caddy↔tradein-backend. header_up
# с value ПЕРЕЗАПИСЫВАЕТ (стирает) любой клиентский X-Internal-Auth-Secret —
# тот же механизм, что защищает X-Authenticated-User выше. Пусто пока
# TRADEIN_INTERNAL_AUTH_SECRET не задан в .env (fail-open, backend не проверяет).
header_up -X-Authenticated-User
header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET}
}
}
@ -98,6 +135,23 @@ gendsgn.ru {
# Next basePath=/trade-in → редиректим на канонический /trade-in/sale-share
# (тот же tradein-frontend контейнер; query-string сохраняется). True vanity-URL
# в адресной строке требует отдельного Next-app с basePath=/sale-share.
# #2558: перенесён ВЫШЕ auth-import вместе с trade-in — редирект ведёт на
# /trade-in/sale-share, для которого теперь нет Caddy basic_auth (как и
# для остального /trade-in). Это НЕ делает страницу публичной: она всё
# ещё за собственной авторизацией trade-in — `RouteGuard` во фронте
# (`app/layout.tsx`) и сессия для `/api/v1/buildings/sale-share*` на
# бэке; без валидной сессии юзер получит редирект на /login, а не
# контент. Смысл переноса — не открыть страницу всем, а убрать
# несогласованность: короткий URL не должен быть строже (Caddy
# basic_auth) целевого адреса, к которому и так уже нет
# basic_auth-барьера (только собственный login trade-in).
#
# ОБНОВЛЕНО 2026-07-31: доступ к разделу сузился с «pilot + admin» до
# ТОЛЬКО admin — «Поиск домов» признан тестовым продуктом, клиентам не
# показывается (deny в auth/roles.yaml для pilot и analyst + в
# DB_ROLE_PATHS для employee/manager). Сам редирект не трогаем: он ведёт
# на страницу, а гейт стоит на роли — для всех, кроме admin, короткий
# адрес приведёт на NoAccessScreen.
@saleshare path /sale-share /sale-share/
handle @saleshare {
redir /trade-in/sale-share permanent
@ -110,13 +164,17 @@ gendsgn.ru {
handle @tradein {
# Next.js basePath=/trade-in — фронт сам ждёт префикса в URL
reverse_proxy tradein-frontend:3000 {
header_up X-Authenticated-User {http.auth.user.id}
# #2213: симметрично с /trade-in/api/* — перезаписываем секрет из env
# (стирает клиентский), на случай SSR-forwardʼa фронтом в backend.
# См. комментарий над /trade-in/api/* выше — та же логика (явное
# удаление вместо Set с пустым {http.auth.user.id}).
header_up -X-Authenticated-User
header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET}
}
}
# Auth gate — с #2558 применяется ТОЛЬКО к Site Finder (handle /api/* и
# handle {} ниже). Trade-In уже отработал и short-circuit'нул выше.
import caddy/users.caddy.snippet
handle /api/* {
reverse_proxy backend:8000 {
header_up X-Authenticated-User {http.auth.user.id}
@ -137,7 +195,7 @@ www.gendsgn.ru {
# МЕРА B2C — публичный периметр (ЭТАП 1 плана B2C-запуска, БЕЗ функционала).
#
# Архитектурное решение: отдельный поддомен, а НЕ дырка в блоке gendsgn.ru
# Архитектурное решение: отдельный домен, а НЕ дырка в блоке gendsgn.ru
# выше. На gendsgn.ru модель "запрещено всё, кроме дырок ВЫШЕ auth-import" —
# порядко-зависимая и общая для B2B (trade-in v2, admin, scrapers, /api/*).
# Здесь, наоборот, allowlist-by-default: basic_auth НЕТ ВООБЩЕ (не импортируем
@ -150,17 +208,17 @@ www.gendsgn.ru {
#
# Next.js basePath=/trade-in запечён в prod-образ tradein-frontend (тот же
# контейнер, что обслуживает и gendsgn.ru/trade-in/*, см. build-args в
# .forgejo/workflows/deploy-tradein.yml) — поэтому корень поддомена rewrite'ится
# .forgejo/workflows/deploy-tradein.yml) — поэтому корень домена rewrite'ится
# на internal-путь /trade-in/mera-public (страница-заглушка,
# tradein-mvp/frontend/src/app/mera-public/). Пользователь префикс /trade-in
# никогда не видит — rewrite меняет путь ТОЛЬКО для Caddy→backend запроса,
# это не HTTP-редирект браузера.
#
# DNS: A-record mera.gendsgn.ru → IP VPS — ТРЕБУЕТСЯ ДО того, как сюда придёт
# DNS: A-record meraocenka.ru → IP VPS — ТРЕБУЕТСЯ ДО того, как сюда придёт
# реальный трафик. Если записи ещё нет на момент деплоя этого блока: `caddy
# reload`/`up -d --force-recreate caddy` в deploy.yml НЕ падает (конфиг
# синтаксически валиден, ошибка сертификата асинхронна и per-hostname) — Caddy
# просто залогирует неудачную попытку ACME-выпуска для mera.gendsgn.ru (DNS не
# просто залогирует неудачную попытку ACME-выпуска для meraocenka.ru (DNS не
# резолвится на этот сервер → HTTP-01/TLS-ALPN challenge недостижим) и продолжит
# ретраить с backoff, ПОКА запись не появится. Остальные site-блоки в этом же
# Caddyfile (gendsgn.ru, obsidian.gendsgn.ru и т.д.) не затрагиваются —
@ -169,11 +227,11 @@ www.gendsgn.ru {
# появления DNS могут исчерпать rate-limit Let's Encrypt (5 failed
# validations/hostname/hour) — не критично, просто подождать; `docker volume
# rm gendesign_caddy_data` для этого НЕ нужен (и вообще требует user-approval).
mera.gendsgn.ru {
meraocenka.ru {
encode zstd gzip
log {
output file /var/log/caddy/mera.gendsgn.ru.log
output file /var/log/caddy/meraocenka.ru.log
}
# Единственная публичная страница этого этапа — заглушка "скоро".
@ -197,6 +255,23 @@ mera.gendsgn.ru {
}
}
# Домены-спутники МЕРА → 301 на канонический meraocenka.ru.
# Решение 2026-07-31: канонический адрес ровно один, остальные две регистрации
# ловят (а) альтернативный транслит «оценка» — ocenka/otsenka, на слух
# неразличимы, (б) прежний рабочий вариант merahome. Отдельные site-блоки, а не
# matcher внутри основного: Caddy матчит по hostname и выпускает свой
# сертификат на каждый, поэтому DNS A-record нужен для КАЖДОГО из них — иначе
# ACME для этого хоста будет ретраиться (безвредно, см. комментарий выше, но
# лучше завести записи сразу).
# `{uri}` сохраняет путь и query — короткая ссылка с визитки не теряет ?id=.
merahome.ru {
redir https://meraocenka.ru{uri} permanent
}
meraotsenka.ru {
redir https://meraocenka.ru{uri} permanent
}
# Obsidian Self-hosted LiveSync (CouchDB backend).
# Auto-TLS Let's Encrypt. CORS уже включён на стороне CouchDB через bootstrap
# (см. scripts/setup-couchdb.sh). Basic-auth — на стороне CouchDB (admin user).

View file

@ -39,6 +39,39 @@ roles:
- "/admin/**"
- "/api/v1/admin/**"
- "/trade-in/api/v1/admin/**"
# Внутренние разделы, закрытые от клиентских аккаунтов (решение владельца
# продукта 2026-07-31): «Доля в продаже» — аналитика рынка, «Кэш» —
# состояние кэшей/скраперов. Зеркало deny-списка DB-ролей employee/manager
# (tradein-mvp/backend/app/services/auth_session.py: DB_ROLE_PATHS).
#
# Зачем копия здесь, если клиенты ходят session-cookie'ой: снаружи легаси
# trusted-header ветка НЕДОСТИЖИМА — с #2558 Caddy срезает входящий
# X-Authenticated-User на всём /trade-in/* (`header_up
# -X-Authenticated-User` в handle /trade-in/api/* и в @tradein), так что
# ни один клиентский аккаунт по ней не ходит. Паттерны нужны для другого:
# 1) ВНУТРИСЕТЕВОЙ dual-mode трафик — запросы изнутри gendesign_shared с
# валидным X-Internal-Auth-Secret; ими ходят QA-смоуки вида
# `docker exec tradein-backend curl localhost:8000
# -H 'X-Authenticated-User: ...'` — они резолвятся именно через
# roles.yaml, и без этих строк смоук показал бы 200 там, где
# реальный клиент получает 403;
# 2) чтобы legacy-pilot не расходился с DB-employee, если dual-режим
# когда-нибудь снова окажется на периметре (откат #2558 / новый
# фронт-прокси) — тогда расхождение молча откроет разделы.
# НЕ удалять как «мёртвые»: они мёртвые только пока Caddy режет заголовок.
#
# Страницы + их API вместе: deny гейтит пункт меню (Topbar через /me),
# саму страницу (RouteGuard) и серверные ручки (rbac_guard).
#
# cache-stats закрыт ГЛОБОМ, а не точным путём, намеренно: точный паттерн
# обходится трейлинг-слэшем ('…/cache-stats/' не равен '…/cache-stats' →
# allowed), и защита повисала бы на Starlette redirect_slashes, а не на
# RBAC. '<prefix>/**' → '^<prefix>(?:/.*)?$': сам путь + слэш + подпути,
# но НЕ соседи по префиксу ('…/cache-statistics' не матчится).
- "/trade-in/sale-share/**"
- "/trade-in/cache/**"
- "/trade-in/api/v1/buildings/**"
- "/trade-in/api/v1/trade-in/cache-stats/**"
analyst:
# #962 (EPIC18, ТЗ §19): analyst видит ВСЁ (deals, insights, exports,
# site-finder, analytics, concept) КРОМЕ admin/data-management.
@ -48,12 +81,28 @@ roles:
# для любого role != "admin" → analyst авто-403 на admin-API без доп. кода.
# deny ниже драйвит фронтовый RouteGuard (deny_paths из /me) для UI-gating
# /admin/** страниц.
# Клиентский deny 2026-07-31 (см. pilot выше) распространён на analyst
# ЧАСТИЧНО — асимметрия намеренная, не недосмотр:
# «Поиск домов» (/trade-in/sale-share + /api/v1/buildings/**) — ЗАКРЫТ.
# Решение владельца продукта 2026-07-31: это ТЕСТОВЫЙ продукт, доступ
# только у admin. «Только у админа» = включая внутренние роли, поэтому
# analyst тоже в deny.
# «Кэш» (/trade-in/cache + cache-stats) — ОСТАВЛЕН открытым: это не
# продукт, а диагностика состояния кэшей/скраперов, т.е. ровно тот
# рабочий инструмент, ради которого роль analyst и заведена
# («видит ВСЁ кроме admin-управления», см. выше).
# Обе стороны этой асимметрии запиннены тестом
# tradein-mvp/backend/tests/test_rbac.py::test_yaml_roles_deliberately_outside_client_deny
# — если решение поменяется, тест упадёт и заставит обновить и его, и этот
# комментарий, а не тихо разойтись с реальностью.
paths:
- "/**"
deny:
- "/admin/**"
- "/api/v1/admin/**"
- "/trade-in/api/v1/admin/**"
- "/trade-in/sale-share/**"
- "/trade-in/api/v1/buildings/**"
expired:
# Пробный доступ закончился — нет доступа ни к чему. Аккаунт остаётся в
# caddy/users.caddy.snippet (basic_auth), чтобы дойти до фронта и увидеть
@ -70,7 +119,8 @@ users:
admin: admin
kopylov: pilot
user1: pilot
user2: pilot # «Брусника» — доступ восстановлен 2026-07-13 (снят trial-expire от 2026-07-09)
user2: expired # «Брусника» — доступ закрыт 2026-07-30 (решение владельца продукта;
# ранее: восстановлен 2026-07-13, trial-expire 2026-07-09)
user3: pilot
user4: pilot
user5: pilot

View file

@ -0,0 +1,160 @@
"""Инварианты миграций БД `auth` (data/sql/auth/*.sql) + её bootstrap (ops/db-bootstrap/*.sql).
Прецедента manifest-теста для КОРНЕВОГО data/sql в этом репозитории нет (он есть только
в tradein: tradein-mvp/backend/tests/test_migrations_manifest.py по
tradein-mvp/backend/data/sql/_manifest_applied.txt). Заводить manifest на 154 legacy-файла
корневого каталога не задача этого PR, поэтому здесь проверяются инварианты, которые
можно проверить БЕЗ снимка «уже применённого»: они выполнимы на новом каталоге с первого
дня и ловят регрессии, которые иначе всплывают только на проде во время деплоя.
Тест не требует БД только чтение файлов.
"""
from __future__ import annotations
import re
from pathlib import Path
_REPO_ROOT = Path(__file__).resolve().parents[3]
_AUTH_SQL_DIR = _REPO_ROOT / "data" / "sql" / "auth"
_BOOTSTRAP_SQL_DIR = _REPO_ROOT / "ops" / "db-bootstrap"
_DEPLOY_WORKFLOW = _REPO_ROOT / ".forgejo" / "workflows" / "deploy.yml"
_FILENAME_RE = re.compile(r"^(\d{3})_[a-z0-9_]+\.sql$")
# Признаки утёкшего пароля в git. bcrypt-хеши ($2a$/$2b$/$2y$) запрещены наравне с
# plaintext: хеш из репозитория брутфорсится офлайн и переживает ротацию пароля,
# оставаясь в истории коммитов. Конвенция репо — сид вставляет password_hash = NULL,
# значения проставляются на проде (прецедент: tradein м.193).
_SECRET_PATTERNS = (
re.compile(r"\$2[aby]\$\d{2}\$"), # bcrypt hash
re.compile(r"PASSWORD\s+'", re.IGNORECASE), # CREATE/ALTER ROLE ... PASSWORD 'literal'
)
def _auth_sql_files() -> list[Path]:
"""Файлы, к которым применимы конвенции миграций (имя NNN_*, обёртка BEGIN/COMMIT)."""
return sorted(_AUTH_SQL_DIR.glob("*.sql"))
def _secret_scanned_files() -> list[Path]:
"""Файлы, по которым гоняется поиск паролей/хешей — ШИРЕ, чем список миграций.
НЕ «унифицируй» этот список с _auth_sql_files(): разделение намеренное.
* data/sql/auth/*.sql миграции: обязаны иметь имя NNN_snake_case.sql и обёртку
BEGIN;/COMMIT; (см. test_filenames_and_unique_prefix, test_migrations_are_transactional).
* ops/db-bootstrap/*.sql bootstrap: НЕ миграции, поэтому намеренно без NNN-префикса
(порядок задан явными шагами deploy.yml, не сортировкой) и намеренно без транзакции
(CREATE DATABASE запрещён внутри транзакционного блока). Прогонять по ним проверки
имён/BEGIN-COMMIT значит сломать тест на корректных файлах.
А вот запрет на пароли применим к обоим каталогам, и именно bootstrap здесь важнее:
единственное место в репозитории с конструкцией `ALTER ROLE ... PASSWORD` это
ops/db-bootstrap/set_*_password.sql, то есть ровно тот файл, куда проще всего однажды
«временно» вписать литерал вместо чтения из env. Другого контроля на это нет:
в .pre-commit-config.yaml из секрет-сканеров только detect-private-key (bcrypt не ловит),
а репо-wide grep невозможен caddy/users.caddy.snippet легально содержит bcrypt-хеши
действующих логинов.
"""
return _auth_sql_files() + sorted(_BOOTSTRAP_SQL_DIR.glob("*.sql"))
def test_scanned_dirs_are_not_empty() -> None:
"""Sanity: пути до каталогов не разъехались (иначе все проверки ниже — пустые).
Red => каталог переименован/перенесён, а тест этого не заметил бы: `glob` по
несуществующему пути возвращает [], и все циклы ниже стали бы no-op'ами, оставаясь
зелёными. Особенно опасно для проверки паролей «зелено, потому что ничего не проверено».
"""
assert _auth_sql_files(), f"Не найдено *.sql в {_AUTH_SQL_DIR}"
assert sorted(_BOOTSTRAP_SQL_DIR.glob("*.sql")), f"Не найдено *.sql в {_BOOTSTRAP_SQL_DIR}"
def test_filenames_and_unique_prefix() -> None:
"""Имя вида NNN_snake_case.sql, префикс NNN уникален.
Red => прод применяет файлы в порядке `ls | sort`; два файла с одним NNN дают
неоднозначный порядок (например, роль/гранты раньше таблиц). Присвой следующий
свободный номер.
"""
seen: dict[str, str] = {}
bad_names: list[str] = []
collisions: list[str] = []
for path in _auth_sql_files():
m = _FILENAME_RE.match(path.name)
if m is None:
bad_names.append(path.name)
continue
prefix = m.group(1)
if prefix in seen:
collisions.append(f"{path.name} (префикс {prefix} уже у {seen[prefix]})")
else:
seen[prefix] = path.name
assert not bad_names, (
f"Имена не соответствуют NNN_snake_case.sql: {bad_names}. "
"Порядок применения на проде определяется сортировкой имён."
)
assert not collisions, "Дублирующийся NNN-префикс: " + "; ".join(collisions)
def test_migrations_are_transactional() -> None:
"""Каждая миграция обёрнута в BEGIN; ... COMMIT; (.claude/rules/sql.md).
Red => частично применённая миграция оставит БД auth в промежуточном состоянии:
деплой падает на ON_ERROR_STOP, а уже выполненный DDL не откатывается.
"""
broken: list[str] = []
for path in _auth_sql_files():
text = path.read_text(encoding="utf-8")
statements = [
line.strip()
for line in text.splitlines()
if line.strip() and not line.strip().startswith("--")
]
if not statements or statements[0] != "BEGIN;" or statements[-1] != "COMMIT;":
broken.append(path.name)
assert (
not broken
), f"Миграции без обёртки BEGIN;/COMMIT;: {broken} (.claude/rules/sql.md → Structure)."
def test_no_password_material_in_auth_sql() -> None:
"""Ни в data/sql/auth, ни в ops/db-bootstrap нет plaintext-паролей и bcrypt-хешей.
Покрытие шире каталога миграций сознательно обоснование в _secret_scanned_files().
Red => пароль/хеш попал в git. Убери значение: сид вставляет password_hash = NULL,
пароль роли ставится из env через ops/db-bootstrap/set_auth_app_password.sql
(значение приезжает из .env.runtime на VPS и в репозитории не существует).
"""
hits: list[str] = []
for path in _secret_scanned_files():
rel = path.relative_to(_REPO_ROOT).as_posix()
text = path.read_text(encoding="utf-8")
for line_no, line in enumerate(text.splitlines(), start=1):
if line.lstrip().startswith("--"):
continue # комментарии описывают запрет, а не нарушают его
for pattern in _SECRET_PATTERNS:
if pattern.search(line):
hits.append(f"{rel}:{line_no}: {line.strip()}")
assert not hits, "Похоже на пароль/хеш в SQL: " + "; ".join(hits)
def test_deploy_workflow_applies_auth_migrations() -> None:
"""deploy.yml реально прогоняет data/sql/auth/*.sql.
Каталог обособлен намеренно: основной цикл миграций использует `ls -1 data/sql/*.sql`
и в подкаталоги НЕ рекурсирует (чтобы файлы auth физически не могли примениться в БД
gendesign). Обратная сторона без отдельного цикла в deploy.yml эти файлы не
применяются вообще и никто этого не заметит. Red => wiring удалён или переименован.
"""
workflow = _DEPLOY_WORKFLOW.read_text(encoding="utf-8")
assert "data/sql/auth/*.sql" in workflow, (
f"В {_DEPLOY_WORKFLOW.name} нет цикла по data/sql/auth/*.sql — миграции БД auth "
"не применяются на деплое."
)
assert (
"ops/db-bootstrap/create_auth_db.sql" in workflow
), f"В {_DEPLOY_WORKFLOW.name} нет bootstrap-шага создания БД auth."

View file

@ -110,11 +110,24 @@ def client() -> TestClient:
# ---------------------------------------------------------------------------
# Пилотные логины user1..user10 в auth/roles.yaml. user2 — «Брусника»: доступ
# закрыт владельцем продукта 2026-07-30, роль переведена pilot → expired. Это
# ЕДИНСТВЕННОЕ отклонение от «все userN = pilot», и оно ожидаемое; хардкод
# именно здесь, отдельной константой, а не магическим `if` в цикле.
_EXPIRED_PILOT_LOGINS = {"user2": "«Брусника», доступ закрыт 2026-07-30"}
def test_get_role_known_users() -> None:
"""Ловит рассинхрон auth/roles.yaml с ожиданиями теста: roles.yaml лежит вне
`backend/**`, поэтому правка ролей не попадает в paths-filter CI и такой
рассинхрон CI молча пропускает (так и случилось с user2 expired)."""
assert auth_mod.get_role("admin") == "admin"
assert auth_mod.get_role("kopylov") == "pilot"
for n in range(1, 11):
assert auth_mod.get_role(f"user{n}") == "pilot"
login = f"user{n}"
expected = "expired" if login in _EXPIRED_PILOT_LOGINS else "pilot"
why = _EXPIRED_PILOT_LOGINS.get(login, "обычный пилотный логин")
assert auth_mod.get_role(login) == expected, f"{login}: ожидали {expected}{why}"
def test_get_role_unknown_user_raises() -> None:

View file

@ -0,0 +1,123 @@
-- auth/001: users + sessions — единое хранилище доступов для «Меры» и «Птицы».
--
-- WHY (почему отдельная БД и почему таблицы называются нейтрально):
-- Владелец продукта решил (2026-07-31) свести вход в «Меру» (trade-in, /trade-in) и
-- «Птицу» (раздел Site Finder, /site-finder/analysis/[cad]/ptica) к ОДНОЙ нейтральной
-- форме входа, вместо браузерного popup'а Caddy basic_auth. Значит, у хранилища доступов
-- два потребителя, и оно не должно принадлежать ни одному из них: живёт в отдельной БД
-- `auth` на платформенном сервере gendesign-postgres (тот же кластер, отдельная база —
-- новый контейнер не заводим; оба бэкенда сидят в сети gendesign_shared и TCP-достают
-- до gendesign-postgres-1:5432, проверено на проде 2026-07-31).
-- Отсюда имена без префикса продукта: `users`, а не `tradein_users`. Префикс продукта в
-- нейтральном хранилище означал бы, что вторая система — гость в чужой таблице, и через
-- полгода никто бы не помнил, кто владелец схемы.
--
-- Здесь НЕТ колонки `role` — сознательно. Идентичность («кто это, какой у него пароль,
-- активен ли доступ») общая для двух продуктов; полномочия внутри продукта (admin/manager/
-- employee в «Мере», админ-роуты в «Птице») — это знание продукта, оно остаётся в
-- продуктовых БД (tradein_users.role) и не переезжает сюда. Иначе `auth` пришлось бы
-- менять каждый раз, когда в одном из продуктов появляется новая роль.
--
-- WHAT:
-- 1. users — identity. password_hash NULL допустим (см. комментарий к колонке): пароли
-- НИКОГДА не попадают в git, ни plaintext, ни bcrypt-хешем — конвенция репо, прецедент
-- tradein-mvp/backend/data/sql/193_tradein_users_seed.sql. Сид (003) вставляет строки
-- с password_hash = NULL, хеши проставляются на проде отдельно.
-- 2. sessions — токен-based сессии, ON DELETE CASCADE от users (удалили пользователя —
-- его сессии теряют смысл). last_seen_at отдельно от created_at — для idle-timeout,
-- иначе «сессия жива 30 дней» и «человек не заходил 30 дней» неразличимы.
-- 3. ASCII-CHECK на username — обязателен ДО появления прод-данных (см. ниже).
--
-- IDEMPOTENCY:
-- CREATE TABLE IF NOT EXISTS + CREATE INDEX IF NOT EXISTS; CHECK-констрейнты объявлены
-- inline в CREATE TABLE, а не через ALTER — при повторном прогоне CREATE TABLE не
-- выполняется вообще, значит констрейнт физически не может задублироваться (паттерн из
-- 192_tradein_users_auth.sql).
--
-- Тип id: `bigint GENERATED ALWAYS AS IDENTITY` — стандартный (SQL-standard) эквивалент
-- bigserial: та же bigint-колонка на той же последовательности, но sequence принадлежит
-- таблице жёстко и не переживает DROP COLUMN сиротой, а прямой INSERT в id запрещён
-- (случайная вставка «своего» id, ломающая счётчик, невозможна). Ровно так объявлен
-- tradein_users.id в 192 — держим один тип на обе таблицы, чтобы будущий код, читающий
-- обе, не спотыкался о разницу.
--
-- Dependencies: нет (пустая БД `auth`, создаётся bootstrap-шагом деплоя,
-- см. ops/db-bootstrap/create_auth_db.sql).
-- Deploy order: Foundation. Роль приложения + гранты — 002, сид — 003. Python-код логина,
-- логин-страница и снятие Caddy basic_auth — отдельные PR'ы ПОСЛЕ этого
-- (SQL-схема первой, см. .claude/rules/sql.md «Migration order»).
BEGIN;
CREATE TABLE IF NOT EXISTS users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
username text NOT NULL UNIQUE,
password_hash text NULL,
display_name text NULL,
org_name text NULL,
email text NULL,
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT users_username_ascii_ck CHECK (username ~ '^[A-Za-z0-9._-]{3,64}$')
);
COMMENT ON TABLE users IS
'Единое хранилище доступов для «Меры» (trade-in) и «Птицы» (Site Finder) — только '
'идентичность. Полномочия внутри продукта (роли) остаются в продуктовых БД: иначе эту '
'таблицу пришлось бы менять при каждом изменении ролевой модели любого из продуктов.';
COMMENT ON COLUMN users.password_hash IS
'NULL = пароль ещё не проставлен, вход по паролю для этой строки невозможен. Хеши '
'НИКОГДА не хранятся в git (ни в сидах, ни в фикстурах) — их проставляют на проде '
'отдельно от миграции; иначе один утёкший коммит открывает вход всем аккаунтам сразу.';
COMMENT ON COLUMN users.is_active IS
'false = доступ закрыт владельцем продукта. Отдельная колонка, а не удаление строки: '
'удаление каскадом снесло бы сессии и историю, а закрытие доступа обратимо и его надо '
'уметь отличать от «такого пользователя никогда не было».';
COMMENT ON COLUMN users.org_name IS
'Организация пользователя. NULL, пока реальные данные не подтверждены владельцем '
'продукта — выдуманное название хуже пустого, оно выглядит достоверным.';
COMMENT ON CONSTRAINT users_username_ascii_ck ON users IS
'Fail-closed запрет не-ASCII логинов (перенесено из tradein м.193, deep-review #2561): '
'downstream-код кодирует username сессии через encode("latin-1","replace"), поэтому два '
'кириллических логина ОДИНАКОВОЙ длины схлопываются в одну и ту же byte-строку из «?» — '
'разные люди получают общую идентичность, общую квоту и взаимный IDOR (один видит данные '
'другого). Констрейнт на уровне схемы, а не проверка в UI/API: проверку в коде однажды '
'забудут добавить в новый путь создания пользователя, схему обойти нельзя.';
CREATE TABLE IF NOT EXISTS sessions (
token text PRIMARY KEY,
user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
last_seen_at timestamptz NOT NULL DEFAULT now(),
ip_address inet NULL,
user_agent text NULL
);
COMMENT ON TABLE sessions IS
'Активные сессии единой формы входа (общие для «Меры» и «Птицы»). ON DELETE CASCADE от '
'users: оставшаяся сессия удалённого пользователя — это действующий доступ без владельца.';
COMMENT ON COLUMN sessions.last_seen_at IS
'Обновляется на каждом запросе — нужен для idle-timeout: без него «сессия не истекла» и '
'«человек ещё работает» неразличимы, и забытая открытая вкладка живёт до expires_at.';
COMMENT ON COLUMN sessions.ip_address IS
'IP на момент выдачи токена — для разбора инцидентов («откуда зашли под этим логином»), '
'не для авторизации: привязка к IP ломает мобильных пользователей при смене сети.';
-- Индексы — как в tradein м.192: уборка протухших сессий по expires_at и выборка/отзыв
-- всех сессий одного пользователя по user_id (FK сам по себе индекс не создаёт, а без него
-- ON DELETE CASCADE на users делает seq scan по всей таблице сессий).
CREATE INDEX IF NOT EXISTS sessions_expires_at_idx
ON sessions (expires_at);
CREATE INDEX IF NOT EXISTS sessions_user_id_idx
ON sessions (user_id);
COMMIT;

View file

@ -0,0 +1,82 @@
-- auth/002: роль приложения auth_app + гранты (least privilege).
--
-- WHY:
-- Миграции этой БД прогоняются суперюзером кластера ($POSTGRES_USER), он же владелец
-- таблиц. Бэкенды «Меры» и «Птицы» ходить под суперюзером не должны: скомпрометированный
-- бэкенд не обязан уметь DROP TABLE users. Поэтому отдельная login-роль с точечными
-- грантами. БД `auth` НЕ принадлежит auth_app (владелец — суперюзер): владелец таблицы
-- имеет на неё все права независимо от GRANT'ов, и разграничение ниже стало бы фикцией.
--
-- Пароль роли здесь НЕ задаётся — роль создаётся passwordless, пароль ставится отдельным
-- bootstrap-шагом деплоя из env (AUTH_DB_PASSWORD в /opt/gendesign/backend/.env.runtime,
-- см. ops/db-bootstrap/set_auth_app_password.sql). Ровно тот же паттерн, что у
-- gendesign_reader (tradein м.101 + set_gendesign_reader_password.sql) и tradein_fdw_reader
-- (data/sql/100_tradein_fdw_role.sql). Пароль в git не попадает ни при каких условиях.
--
-- Периметр прав (обосновано по-операционно):
-- sessions — SELECT/INSERT/UPDATE/DELETE. Полный набор: выдать токен (INSERT), проверить
-- на каждом запросе (SELECT), обновить last_seen_at (UPDATE), разлогинить и вычистить
-- протухшие (DELETE).
-- users — SELECT (найти по username, прочитать hash и is_active) + UPDATE (смена пароля
-- самим пользователем и проставление хеша админом).
-- users — INSERT/DELETE НЕ выдаются, сознательно:
-- * INSERT — создание аккаунтов в PR-1 не существует ни как код, ни как UI. Выдать грант
-- «на будущее» = держать открытой операцию, которой никто не пользуется и которую никто
-- не тестирует. Когда появится админский путь создания пользователей, грант добавляется
-- новой миграцией в одну строку (плюс GRANT USAGE на sequence, идентичность требует
-- nextval). Обратная ошибка дороже: снять грант, на который уже опирается прод-код,
-- нельзя без синхронного релиза.
-- * DELETE — не выдаётся и дальше: закрытие доступа делается через is_active = false
-- (см. комментарий к колонке в 001). Физическое удаление каскадом сносит сессии и
-- обрывает связь с историей действий пользователя в продуктовых БД, где user_id/username
-- остаются висеть; это операция уровня «руками через psql с осознанием последствий»,
-- а не то, что должен уметь HTTP-хендлер.
--
-- IDEMPOTENCY:
-- CREATE ROLE через DO-блок с проверкой pg_roles (нет ADD ROLE IF NOT EXISTS), GRANT/REVOKE
-- идемпотентны по определению. Повторный прогон — no-op. Роли в PostgreSQL общие на кластер,
-- поэтому DO-блок отработает корректно, даже если роль уже создана из другой БД.
--
-- Dependencies: 001_identity_schema.sql (гранты ссылаются на users/sessions).
BEGIN;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'auth_app') THEN
CREATE ROLE auth_app LOGIN;
END IF;
END$$;
COMMENT ON ROLE auth_app IS
'Прикладная роль единой формы входа («Мера» + «Птица»). Пароль ставится '
'.forgejo/workflows/deploy.yml из env AUTH_DB_PASSWORD (backend/.env.runtime) через '
'ops/db-bootstrap/set_auth_app_password.sql. Пароль никогда не хранится в SQL-миграциях.';
-- Никто, кроме владельца БД и явно поименованных ролей, не должен даже подключаться:
-- по умолчанию PostgreSQL даёт CONNECT роли PUBLIC, то есть любая login-роль кластера
-- (glitchtip, tradein_fdw_reader, gendesign_reader) может открыть сессию в `auth`.
-- Хранилище паролей — не то место, где стоит полагаться на «а таблицы им всё равно не видны».
--
-- ЭТА СТРОКА ПРОДУБЛИРОВАНА в ops/db-bootstrap/create_auth_db.sql — намеренно, инвариант
-- держится в двух местах. Здесь — ради самодостаточности миграции: применённая на пустую БД
-- (scratch/staging, ручной psql -f) она обязана давать полный периметр прав, не полагаясь на
-- то, что кто-то отдельно прогнал bootstrap. В bootstrap — ради переприменяемости: миграция
-- выполняется РОВНО ОДИН РАЗ (трекинг в _schema_migrations), а БД может быть пересоздана из
-- дампа в обход миграций, и тогда дефолтный PUBLIC-CONNECT вернулся бы молча. Не «сокращай»
-- дубль — ни одна из копий не покрывает сценарий другой.
REVOKE ALL ON DATABASE auth FROM PUBLIC;
-- Defense-in-depth: явный REVOKE-периметр перед точечными грантами — любые унаследованные
-- или PUBLIC-гранты на существующих объектах обнуляются (паттерн из 100_tradein_fdw_role.sql).
REVOKE ALL ON ALL TABLES IN SCHEMA public FROM auth_app;
REVOKE ALL ON ALL SEQUENCES IN SCHEMA public FROM auth_app;
REVOKE ALL ON ALL FUNCTIONS IN SCHEMA public FROM auth_app;
GRANT CONNECT ON DATABASE auth TO auth_app;
GRANT USAGE ON SCHEMA public TO auth_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON sessions TO auth_app;
GRANT SELECT, UPDATE ON users TO auth_app;
COMMIT;

View file

@ -0,0 +1,111 @@
-- auth/003: сид 13 существующих аккаунтов (org-карта владельца продукта, 2026-07-30/31).
--
-- WHY:
-- 001 создала схему, но без данных единая форма входа не заработает: реальные аккаунты
-- сейчас живут только в Caddy basic_auth (caddy/users.caddy.snippet + tradein auth/roles.yaml)
-- и в tradein_users. Эта миграция переносит список людей — БЕЗ ЕДИНОГО ПАРОЛЯ.
--
-- password_hash = NULL у ВСЕХ строк. Это конвенция репо, а не недоделка: ни plaintext, ни
-- bcrypt-хеш не должны попадать в git (прецедент — tradein-mvp/backend/data/sql/
-- 193_tradein_users_seed.sql, там сид тоже вставляет NULL, хеши проставляются отдельно на
-- проде). Хеш в git — это офлайн-brute-force для любого, кто получил доступ к репозиторию,
-- и он переживает любую ротацию пароля в истории коммитов.
-- Пока hash = NULL, вход по паролю через новую форму для строки невозможен, но доступ НЕ
-- теряется: PR-1 ничего не переключает, прод продолжает пускать через существующий
-- Caddy basic_auth ровно как сейчас. Переключение — отдельные PR'ы.
--
-- Состав (утверждён владельцем продукта):
-- admin — владелец
-- kopylov — отдельный клиент, display_name «Копылов»
-- praktika — ГК «Практика»
-- user1, user3..user10 — свободные слоты, is_active = true
-- user2 — «Брусника», is_active = FALSE (доступ закрыт 2026-07-30);
-- в roles.yaml он role=expired — расхождение семантики,
-- см. ⚠️ у строки user2 в VALUES ниже
-- display_name заполнен только у kopylov (единственная фамилия, подтверждённая в коде:
-- tradein auth.py::_USERNAME_PROFILE). Остальным NULL — реальных данных нет, выдумывать
-- нельзя: выдуманное ФИО в UI неотличимо от настоящего.
-- QA-фикстуры НЕ мигрируются — им нечего делать в общем хранилище доступов двух продуктов.
-- Состав фикстур неоднороден, и это важно при сверке списков (проверено по обоим файлам):
-- admintest, pilottest — действующие логины: есть И в caddy/users.caddy.snippet
-- (basic_auth-запись с хешем), И в auth/roles.yaml (role-mapping). Реально входят.
-- analysttest, expiredtest — существуют ТОЛЬКО в auth/roles.yaml как role-mapping,
-- basic_auth-записи в caddy/users.caddy.snippet у них нет, то есть войти под ними
-- снаружи сегодня нельзя вообще. Это тестовые фикстуры, а не аккаунты: analysttest
-- гоняется в backend/tests (test_rbac.py, test_insights.py, test_audit_middleware.py),
-- expiredtest — в tradein-mvp/backend/tests/test_rbac.py как покрытие role=expired.
--
-- IDEMPOTENCY (логика и обоснование перенесены из tradein м.193, deep-review #2564):
-- INSERT ... ON CONFLICT (username) DO UPDATE, но НЕ безусловно: password_hash, display_name,
-- org_name, email защищены COALESCE(текущее, EXCLUDED). Если админ уже проставил пароль или
-- поправил профиль между двумя прогонами файла (обычный auto-apply трекает filename в
-- _schema_migrations и не запускает файл дважды на одном окружении — но ручной re-apply при
-- recovery и scratch/staging БД такого трекинга не имеют), повторный прогон НЕ должен
-- затереть это состояние NULL-ом. В м.193 это был живой баг: назначенный через API manager_id
-- тихо обнулялся повторным прогоном сида.
-- Направление COALESCE односторонее: NULL в БД можно дозаполнить значением из сида, но
-- значение из БД никогда не перетирается сидом.
--
-- is_active НАМЕРЕННО отсутствует в SET — и не как COALESCE тоже: колонка NOT NULL, значит
-- COALESCE(NOT NULL-значение, x) никогда не возьмёт x, это был бы мёртвый код с видимостью
-- защиты. Открытие/закрытие доступа — решение владельца продукта, оно принимается в
-- интерфейсе, а не повторным прогоном seed-файла: после первой вставки колонка сознательно
-- «замораживается» на текущем значении в БД.
-- (В м.193 в SET присутствовал ещё role — как источник истины org-карты. Здесь колонки role
-- нет вовсе: полномочия остаются в продуктовых БД, см. заголовок 001.)
--
-- updated_at = now() выставляется на любом конфликте, даже когда ни одна колонка фактически
-- не изменилась — паритет с м.193; «строка была затронута прогоном сида» это честно отражает.
--
-- Разрывы в users.id после повторного прогона — норма, НЕ следы удалённых строк. Дефолт
-- GENERATED ALWAYS AS IDENTITY вычисляется ДО обнаружения конфликта, поэтому каждый
-- повторный прогон сжигает 13 значений последовательности впустую. Функционально безвредно;
-- упомянуто, чтобы дыры в id не увели разбор инцидента в сторону «кого-то удалили».
--
-- Dependencies: 001_identity_schema.sql (users + ASCII-CHECK на username; все логины ниже
-- ASCII, констрейнту не противоречат).
BEGIN;
INSERT INTO users (username, password_hash, display_name, org_name, email, is_active)
VALUES
('admin', NULL, NULL, NULL, NULL, true),
('kopylov', NULL, 'Копылов', NULL, NULL, true),
('praktika', NULL, NULL, NULL, NULL, true),
('user1', NULL, NULL, NULL, NULL, true),
-- user2 — «Брусника», доступ закрыт владельцем продукта 2026-07-30.
--
-- ⚠️ ОТКРЫТАЯ РАЗВИЛКА, решается в PR-2/3 (переключение на единую форму входа), НЕ здесь:
-- сегодня в auth/roles.yaml у user2 role=expired, и семантика ДРУГАЯ, чем is_active=false.
-- expired != disabled: expired-юзер проходит гейт (basic_auth-запись в
-- caddy/users.caddy.snippet у него есть), доходит до фронта и видит осмысленный экран
-- «пробный доступ закончился» (roles.yaml → блок expired: paths: [] + deny "/**";
-- frontend NoAccessScreen variant="trial"). is_active=false — это отказ на этапе входа,
-- неотличимый для пользователя от «неверный пароль».
-- Сейчас расхождение безобидно: PR-1 ничего не переключает, прод по-прежнему ходит через
-- Caddy basic_auth + roles.yaml, и никакой код эту колонку не читает. Но в момент
-- переключения trial-экран пропадёт МОЛЧА — тесты не упадут, роль просто перестанет
-- существовать как состояние. Решать тогда: если trial-UX сохраняем, нужно отдельное
-- состояние (колонка status / отдельная роль), а не булев флаг — is_active схлопывает
-- «доступ закрыт» и «пробный период истёк» в одно значение. Схему в этом PR НЕ трогаем.
('user2', NULL, NULL, NULL, NULL, false),
('user3', NULL, NULL, NULL, NULL, true),
('user4', NULL, NULL, NULL, NULL, true),
('user5', NULL, NULL, NULL, NULL, true),
('user6', NULL, NULL, NULL, NULL, true),
('user7', NULL, NULL, NULL, NULL, true),
('user8', NULL, NULL, NULL, NULL, true),
('user9', NULL, NULL, NULL, NULL, true),
('user10', NULL, NULL, NULL, NULL, true)
ON CONFLICT (username) DO UPDATE SET
-- COALESCE(текущее, EXCLUDED): сид дозаполняет пустые поля, но никогда не затирает
-- уже проставленные вручную (в первую очередь password_hash — иначе повторный прогон
-- отключал бы вход всем, кому пароль уже выдали).
password_hash = COALESCE(users.password_hash, EXCLUDED.password_hash),
display_name = COALESCE(users.display_name, EXCLUDED.display_name),
org_name = COALESCE(users.org_name, EXCLUDED.org_name),
email = COALESCE(users.email, EXCLUDED.email),
-- is_active НЕ в SET: NOT NULL-колонка, COALESCE был бы мёртвым кодом (см. IDEMPOTENCY).
updated_at = now();
COMMIT;

View file

@ -21,7 +21,7 @@
| **Forgejo repo variables** (`vars.*`) | non-sensitive toggles (`LLM_ENABLED`, `OWN_DEVELOPER_IDS`) | ❌ нет | Forgejo Actions runner |
| **GitHub repo secrets** (зеркало для `.github/workflows/`) | deploy SSH key (obsidian-стек) | ❌ нет | GitHub Actions (только obsidian deploy) |
| **`/opt/gendesign/.env`** (VPS, root-only, chmod 600) | DB creds, GlitchTip infra-secrets, FDW/reader passwords, прокси, COMPOSE_PROFILES | ❌ `.gitignore` | docker compose (main + obsidian + tradein стеки) |
| **`/opt/gendesign/backend/.env.runtime`** (VPS, chmod 600) | runtime overlay: `SENTRY_RELEASE`, `GLITCHTIP_DSN`, `OBJECTIVE_API_KEY`, `OPENAI_API_KEY`, `OWN_DEVELOPER_IDS`, `GENDESIGN_FDW_PASSWORD`, `COUCHDB_*` | ❌ `.gitignore` | backend/worker/beat/couchdb |
| **`/opt/gendesign/backend/.env.runtime`** (VPS, chmod 600) | runtime overlay: `SENTRY_RELEASE`, `GLITCHTIP_DSN`, `OBJECTIVE_API_KEY`, `OPENAI_API_KEY`, `OWN_DEVELOPER_IDS`, `GENDESIGN_FDW_PASSWORD`, `AUTH_DB_PASSWORD`, `COUCHDB_*` | ❌ `.gitignore` | backend/worker/beat/couchdb |
| **`/opt/gendesign/tradein-mvp/backend/.env.runtime`** (VPS, chmod 600) | tradein DB creds, Yandex/DaData ключи, прокси-URL, Cian-логин, reader password | ❌ `.gitignore` | tradein стек |
| **`caddy/users.caddy.snippet`** (in git) | bcrypt-хеши basic_auth пилотных юзеров | ✅ да (хеши, не plaintext) | Caddy |
| **Obsidian vault `meta/00_credentials.md`** | реестр **значений** всех секретов + audit-log ротаций | ❌ (вне репо) | Anton |
@ -62,6 +62,7 @@
| `POSTGRES_PASSWORD` | `.env` | Пароль роли `gendesign` (PostGIS 16) | **E** (DB password) |
| `POSTGRES_USER` / `POSTGRES_DB` | `.env` | Имя роли / БД (не секрет, но в `.env`) | **E** |
| `GENDESIGN_FDW_PASSWORD` | `backend/.env.runtime` | Пароль роли `tradein_fdw_reader` (FDW из main → tradein). Применяется через `ops/db-bootstrap/set_tradein_fdw_password.sql` | **E** |
| `AUTH_DB_PASSWORD` | `backend/.env.runtime` | Пароль роли `auth_app` — БД `auth` на gendesign-postgres (единое хранилище доступов «Меры» и «Птицы»). Применяется через `ops/db-bootstrap/set_auth_app_password.sql` на деплое. Переменная задаётся на VPS вручную; пока не задана — шаг пропускается с warning'ом | **E** |
| `COUCHDB_PASSWORD` / `COUCHDB_USER` | `backend/.env.runtime` | CouchDB (Obsidian LiveSync, `obsidian.gendsgn.ru`) | **E** |
| `GLITCHTIP_DSN` | `backend/.env.runtime` | Backend GlitchTip DSN (перезаписывается deploy из `GLITCHTIP_BACKEND_DSN`) | **C** |
| `GLITCHTIP_DB_PASS` | `.env` | Пароль БД GlitchTip-стека | **E** |

View file

@ -0,0 +1,67 @@
-- Создание БД `auth` — единого хранилища доступов «Меры» и «Птицы» (идемпотентно).
--
-- Applied by .forgejo/workflows/deploy.yml ПЕРЕД миграциями data/sql/auth/*.sql:
-- docker compose ... exec -T postgres psql -U "$POSTGRES_USER" -d postgres \
-- -v ON_ERROR_STOP=on < ops/db-bootstrap/create_auth_db.sql
-- Подключение обязательно к БД `postgres`: нельзя создать базу, находясь в ней самой.
--
-- ПОЧЕМУ ЭТО НЕ МИГРАЦИЯ:
-- CREATE DATABASE запрещён внутри транзакционного блока, а .claude/rules/sql.md требует
-- от каждого файла в data/sql обёртки BEGIN/COMMIT. Плюс миграции `auth` по определению
-- выполняются уже ВНУТРИ БД `auth` — то есть создать её собой они не могут. Отсюда
-- отдельный bootstrap-шаг, по образцу scripts/bootstrap_glitchtip.sh (там так же
-- заводится вторая БД на этом же сервере).
--
-- ПОЧЕМУ \gexec, А НЕ DO-БЛОК:
-- DO-блок — это функция, она выполняется внутри транзакции, значит CREATE DATABASE в ней
-- недопустим. \gexec строит текст команды на стороне клиента и отправляет её отдельным
-- стейтментом. Если WHERE NOT EXISTS отфильтровал строку, \gexec не получает ничего и
-- молча ничего не делает — это и даёт идемпотентность без ошибки на повторном прогоне.
-- ON_ERROR_STOP=on распространяется и на команды, выполненные через \gexec.
--
-- ВЛАДЕЛЕЦ БД — $POSTGRES_USER (суперюзер кластера), НЕ auth_app. Владелец объекта имеет на
-- него все права в обход GRANT'ов; если бы БД и таблицы принадлежали прикладной роли,
-- точечные гранты в data/sql/auth/002_auth_app_role.sql были бы декорацией. Роль auth_app
-- создаётся миграцией 002 и получает только нужные DML-права.
--
-- TEMPLATE template0 — сознательно, а не template1 (шаблон по умолчанию): template0
-- гарантированно пуст и неизменяем, а в template1 любой может доустановить расширения или
-- объекты, и они молча окажутся в хранилище паролей. На образе postgis:16-3.4 сегодня
-- postgis лежит в template_postgis, а template1 чист (проверено локально на том же образе),
-- но полагаться на это как на инвариант незачем — template0 снимает вопрос навсегда.
-- ENCODING 'UTF8' указан явно (кластер и так UTF8 — вся кириллица gendesign лежит в нём),
-- чтобы кодировка хранилища логинов не зависела от того, с какими аргументами когда-нибудь
-- пересоздадут кластер.
--
-- Пароля в этом файле нет и быть не может: роль создаётся passwordless в миграции 002,
-- пароль ставится отдельным шагом из env (ops/db-bootstrap/set_auth_app_password.sql).
SELECT 'CREATE DATABASE auth TEMPLATE template0 ENCODING ''UTF8'';'
WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'auth')
\gexec
-- Единственная преграда для «любая login-роль кластера (glitchtip, tradein_fdw_reader,
-- gendesign_reader) открывает сессию в хранилище паролей»: по умолчанию PostgreSQL выдаёт
-- CONNECT роли PUBLIC при создании БД.
--
-- ДУБЛЬ С data/sql/auth/002_auth_app_role.sql — НАМЕРЕННЫЙ, не копипаста. Инвариант держится
-- в двух местах, потому что у файлов разный жизненный цикл:
-- * здесь (bootstrap) — ради ПЕРЕПРИМЕНЯЕМОСТИ: этот файл гоняется на КАЖДОМ деплое, там же,
-- где создаётся БД. Если `auth` восстановят из дампа или пересоздадут в обход миграций,
-- база появится с дефолтным PUBLIC-CONNECT, а 002 уже числится применённой в
-- _schema_migrations и второй раз не выполнится — REVOKE молча не вернётся.
-- * в 002 — ради САМОДОСТАТОЧНОСТИ миграции: применённая на пустую БД (scratch/staging,
-- ручной psql -f) она обязана давать полный периметр прав без чтения bootstrap-файлов.
-- Удалять любую из двух копий нельзя: каждая закрывает сценарий, который другая не покрывает.
--
-- Выполнимо из подключения к БД `postgres` (мы именно в ней): права на объект DATABASE живут
-- в pg_database.datacl — это общий на кластер каталог, не локальный для БД, в отличие от
-- грантов на таблицы/схемы. Проверено эмпирически на postgis:16-3.4 (REVOKE из сессии в
-- `postgres` по другой БД убирает `=Tc/` из datacl, has_database_privilege('public', …,
-- 'CONNECT') → false). Команда идемпотентна — повторный прогон бесплатен.
REVOKE ALL ON DATABASE auth FROM PUBLIC;
COMMENT ON DATABASE auth IS
'Единое хранилище доступов: «Мера» (trade-in) и «Птица» (Site Finder). Схема — '
'data/sql/auth/*.sql, применяется отдельным циклом миграций в .forgejo/workflows/deploy.yml '
'(таблица _schema_migrations живёт внутри этой же БД).';

View file

@ -0,0 +1,50 @@
-- Set auth_app password from env.
-- Applied by .forgejo/workflows/deploy.yml after auth DB migrations:
-- psql -v pw="$AUTH_DB_PASSWORD" < ops/db-bootstrap/set_auth_app_password.sql
-- Источник переменной: AUTH_DB_PASSWORD из /opt/gendesign/backend/.env.runtime (chmod 600,
-- вне git). Зеркало паттерна ops/db-bootstrap/set_tradein_fdw_password.sql и
-- tradein-mvp/ops/db-bootstrap/set_gendesign_reader_password.sql.
--
-- Idempotent: ALTER если роль существует, NOTICE и продолжает если нет (миграция
-- data/sql/auth/002_auth_app_role.sql могла ещё не примениться на первом деплое).
-- Пароль НИКОГДА не хранится в этом файле или в git — только имя переменной.
--
-- Format %L экранирует пароль как SQL string literal — безопасно даже с кавычками.
--
-- psql variable substitution (:'pw') НЕ интерполируется внутри dollar-quoted блока ($$...$$)
-- — это правило psql, не bug. Поэтому password передаём в DO через сессионный GUC
-- (set_config), который psql интерполирует ВНЕ dollar quote, и читаем внутри через
-- current_setting(). По той же причине файл подаётся через stdin, а НЕ через `psql -c`.
-- Reference incident: deploy 2026-05-24 (post-merge PR #503) упал на
-- "syntax error at or near ':'" именно на этом.
--
-- ⚠️ `set_config(name, value, is_local) -> text` ВОЗВРАЩАЕТ установленное значение. Без
-- `\o /dev/null` psql напечатал бы пароль на stdout → leak в Forgejo Actions deploy logs
-- (retained, visible всем с repo read access). Поэтому оба set_config обёрнуты в
-- `\o /dev/null` / `\o` — глушится только их вывод, NOTICE из DO block (сигнал
-- идемпотентности) остаётся видимым.
--
-- Rollback path: НЕ revert этого файла (вернёт сломанный :'pw' внутри $$). Корректный
-- rollback — unset AUTH_DB_PASSWORD в /opt/gendesign/backend/.env.runtime на VPS, deploy.yml
-- тогда пропустит этот шаг полностью (роль останется без пароля = логин по паролю невозможен).
\o /dev/null
SELECT set_config('app.auth_pw', :'pw', false);
\o
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'auth_app') THEN
EXECUTE format('ALTER ROLE auth_app WITH PASSWORD %L', current_setting('app.auth_pw'));
RAISE NOTICE 'auth_app password set';
ELSE
RAISE NOTICE 'auth_app role missing — migration data/sql/auth/002_auth_app_role.sql not applied yet';
END IF;
END $$;
-- Clear GUC after use (defense-in-depth — не оставляем password в session state даже на
-- short connection). Same \o trick — set_config return value is empty string here, но лишний
-- row в stdout всё равно не нужен.
\o /dev/null
SELECT set_config('app.auth_pw', '', false);
\o

View file

@ -1,26 +1,27 @@
#!/usr/bin/env bash
# Регресс-тест публичного B2C-периметра МЕРА (ЭТАП 1 плана B2C-запуска).
#
# Проверяет 4 инварианта периметра (см. корневой Caddyfile):
# 1. mera.gendsgn.ru отдаёт 200 анонимно (публичная заглушка).
# 2. mera.gendsgn.ru/v2 (B2B-путь) отдаёт 404 — allowlist-by-default,
# Проверяет инварианты периметра (см. корневой Caddyfile):
# 1. meraocenka.ru отдаёт 200 анонимно (публичная заглушка).
# 2. meraocenka.ru/v2 (B2B-путь) отдаёт 404 — allowlist-by-default,
# НЕ был случайно проброшен на B2B-дерево tradein-frontend.
# 3. gendsgn.ru/trade-in/v2 по-прежнему отдаёт 401 анониму (basic_auth gate
# не тронут этим этапом).
# 4. gendsgn.ru/api/v1/admin/* отдаёт 401 анониму (тот же gate).
# 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 mera.gendsgn.ru → IP VPS
# ВАЖНО: проверки 1 и 2 требуют, чтобы DNS A-record meraocenka.ru → IP VPS
# уже существовал И деплой прошёл (сертификат Let's Encrypt выпущен). Пока
# записи нет — они ожидаемо падают (DNS resolution failure / TLS handshake
# failure), это НЕ регресс периметра gendsgn.ru. Проверки 3 и 4 не зависят от
# DNS нового поддомена и обязаны быть зелёными всегда.
# 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://mera.gendsgn.ru}"
BASE_MERA="${SMOKE_MERA_BASE:-https://meraocenka.ru}"
BASE_MAIN="${SMOKE_MAIN_BASE:-https://gendsgn.ru}"
fail=0
@ -39,20 +40,34 @@ check() {
echo "== МЕРА B2C perimeter smoke (ЭТАП 1) =="
# 1. Публичный поддомен отдаёт 200 анонимно.
check "mera.gendsgn.ru root — public 200" "$BASE_MERA/" 200
# 1. Публичный домен отдаёт 200 анонимно.
check "meraocenka.ru root — public 200" "$BASE_MERA/" 200
# 2. B2B-путь на публичном поддомене — 404 (allowlist-by-default), не 200/401.
check "mera.gendsgn.ru/v2 — B2B path must 404" "$BASE_MERA/v2" 404
# 2. B2B-путь на публичном домене — 404 (allowlist-by-default), не 200/401.
check "meraocenka.ru/v2 — B2B path must 404" "$BASE_MERA/v2" 404
# 3. gendsgn.ru/trade-in/v2 по-прежнему закрыт basic_auth анониму.
check "gendsgn.ru/trade-in/v2 — still 401 anonymous" "$BASE_MAIN/trade-in/v2" 401
# 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"

View file

@ -246,7 +246,8 @@ def _clean_address_for_geocode(addr: str) -> str:
"""Чистим address для геокодера.
Cian отдаёт «улица Латвийская, 56/3 · р-н Чкаловский» суффикс ' · ...'
мешает Nominatim. Берём часть до ' · '. N1 отдаёт «Репина, 75/2 стр.» ок.
мешает Nominatim. Берём часть до ' · '. Остальные источники такого суффикса
не используют адрес остаётся без изменений.
"""
main = addr.split(" · ")[0].strip()
return main or addr
@ -260,7 +261,7 @@ async def geocode_missing(
) -> dict:
"""Геокодинг listings ИЛИ deals у которых нет lat/lon (используя address).
target=listings (по умолч.) объявления Cian/N1; target=deals сделки Росреестра.
target=listings (по умолч.) объявления; target=deals сделки Росреестра.
Чанк-обработка с бюджетом по времени (~240с, заведомо меньше cron
`curl -m 320`): за вызов геокодим сколько успеваем, остаток уходит в
`remaining`, cron вызывает в цикле пока `remaining` > 0.
@ -269,12 +270,8 @@ async def geocode_missing(
адреса не выбираются повторно 7 дней cron-loop завершается, не зацикливается.
geom обновляется автоматически триггером.
"""
# Доп. фильтр для listings — у Avito/N1 встречаются плейсхолдер-адреса.
extra_filter = (
"AND address NOT LIKE '%(Avito)%' AND address NOT LIKE '%(N1)%'"
if target == "listings"
else ""
)
# Доп. фильтр для listings — у Avito встречаются плейсхолдер-адреса.
extra_filter = "AND address NOT LIKE '%(Avito)%'" if target == "listings" else ""
rows = (
db.execute(
text(

View file

@ -0,0 +1,156 @@
"""POST /api/v1/auth/login + /logout — DB-backed session auth (#2552, эпик #2549).
Переходный механизм, параллельный legacy Caddy trusted-header auth (roles.yaml).
См. `app.core.rbac.rbac_guard` (dual-mode resolver) и `app.services.auth_session`
(session CRUD). Mounted at `/api/v1/auth`; через Caddy `uri strip_prefix /trade-in`
это `/trade-in/api/v1/auth/*` снаружи.
Security:
- Неверные creds (неизвестный username / неактивен / password_hash NULL /
неверный пароль) ОДИНАКОВЫЙ 401 с generic сообщением не раскрываем,
существует ли username (user-enumeration защита).
- #2552 post-review Medium 2: `verify_password` ВСЕГДА вызывается ровно
один раз для несуществующего username / NULL password_hash сверяем
против статичного dummy-хеша (`_DUMMY_PASSWORD_HASH`, сгенерирован один
раз на импорте модуля), результат игнорируется. Без этого короткое
замыкание (`user is None сразу 401`) давало наблюдаемую разницу во
времени ответа (~1мс без bcrypt vs ~100-300мс с ним) классический
timing-oracle для user-enumeration, даже при одинаковом detail-сообщении.
- Rate-limit по (username, IP) ЖЁСТЧЕ общего `RateLimitMiddleware`
(`/api/*`), т.к. login типичная brute-force поверхность. Использует
`SlidingWindowLimiter` (тот же примитив, что и общий rate-limit). Ключ
length-prefixed (`len(username):username:ip`) без этого произвольный
username с `:` внутри мог бы схлопнуть бюджет с другой (username, ip)
парой (IPv6-адреса тоже содержат `:`, так что просто эскейпить разделитель
в username недостаточно паразитная граница возможна с обеих сторон).
- Raw-пароль НИКОГДА не логируется и не попадает в user_events payload
только username/ip/user_agent/path/method (см. schedule_event ниже).
"""
from __future__ import annotations
import logging
import secrets
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.db import get_db
from app.core.password import hash_password, verify_password
from app.core.ratelimit import SlidingWindowLimiter, _client_ip
from app.services.auth_session import create_session, get_user_by_username, revoke_session
from app.services.user_events import schedule_event
logger = logging.getLogger(__name__)
router = APIRouter()
# Отдельный, более узкий бюджет чем общий per-user/per-IP `/api/*` лимит
# (см. app.core.ratelimit.SlidingWindowLimiter docstring — designed именно для
# такого случая). Ключ = username+IP: не даёт распределённому brute-force по
# ОДНОМУ аккаунту с разных IP уйти от лимита целиком (per-IP было бы недостаточно),
# и не блокирует ВЕСЬ IP из-за перебора чужих логинов одним же клиентом.
_LOGIN_LIMITER = SlidingWindowLimiter(
limit=settings.login_rate_limit,
window_s=settings.login_rate_limit_window_s,
)
# Timing-oracle защита (см. module docstring): bcrypt-хеш случайного пароля,
# сгенерированный ОДИН РАЗ на импорте модуля — используется вместо
# password_hash, когда юзер не найден/деактивирован/без пароля, чтобы
# `verify_password` (доминирующая по времени операция, ~100-300мс) всегда
# отрабатывала полный bcrypt-компар, независимо от того, существует ли аккаунт.
_DUMMY_PASSWORD_HASH = hash_password(secrets.token_urlsafe(16))
_INVALID_CREDENTIALS_DETAIL = "неверный логин или пароль"
class LoginRequest(BaseModel):
username: str
password: str
class LoginResponse(BaseModel):
ok: bool = True
@router.post("/login", response_model=LoginResponse)
async def login(
body: LoginRequest,
request: Request,
response: Response,
db: Annotated[Session, Depends(get_db)],
) -> LoginResponse:
ip = _client_ip(request)
user_agent = request.headers.get("user-agent")
rate_key = f"{len(body.username)}:{body.username}:{ip}"
retry_after = _LOGIN_LIMITER.check(rate_key)
if retry_after is not None:
raise HTTPException(
status_code=429,
detail="слишком много попыток входа, попробуйте позже",
headers={"Retry-After": str(int(retry_after) + 1)},
)
user = get_user_by_username(db, body.username)
hash_to_check = (
user["password_hash"]
if user is not None and user["password_hash"] is not None
else _DUMMY_PASSWORD_HASH
)
# ВСЕГДА вызывается — dummy-хеш при отсутствующем юзере/NULL password_hash
# держит время ответа одинаковым независимо от существования аккаунта.
password_ok = verify_password(body.password, hash_to_check)
credentials_ok = user is not None and user["is_active"] and password_ok
if not credentials_ok:
schedule_event(
event_type="login_failed",
username=body.username,
ip=ip,
user_agent=user_agent,
path="/api/v1/auth/login",
method="POST",
)
raise HTTPException(status_code=401, detail=_INVALID_CREDENTIALS_DETAIL)
assert user is not None # narrowed by credentials_ok above
token = create_session(db, user_id=user["user_id"], ip=ip, user_agent=user_agent)
response.set_cookie(
key=settings.session_cookie_name,
value=token,
max_age=settings.session_ttl_hours * 3600,
httponly=True,
secure=True,
samesite="lax",
path="/",
)
schedule_event(
event_type="login_success",
username=user["username"],
ip=ip,
user_agent=user_agent,
path="/api/v1/auth/login",
method="POST",
)
return LoginResponse(ok=True)
@router.post("/logout")
async def logout(
request: Request,
response: Response,
db: Annotated[Session, Depends(get_db)],
) -> dict[str, bool]:
token = request.cookies.get(settings.session_cookie_name)
if token:
revoke_session(db, token)
response.delete_cookie(key=settings.session_cookie_name, path="/")
return {"ok": True}

View file

@ -21,14 +21,27 @@ router = APIRouter()
async def lookup(
address: Annotated[str, Query(min_length=3, max_length=500)],
db: Annotated[Session, Depends(get_db)],
city_hint: Annotated[
str | None,
Query(
max_length=100,
description=(
"Город, если известен вызывающему (например выбран пользователем "
"на предыдущем шаге UI). #2576: без него геокодер БОЛЬШЕ НЕ "
"подставляет 'Екатеринбург' молча — ответ может помечаться "
"city_ambiguous=true."
),
),
] = None,
) -> GeocodeResult:
"""Геокодинг адреса → lat/lon.
Примеры:
/api/v1/geocode/lookup?address=ул.+Малышева+30+Екатеринбург
/api/v1/geocode/lookup?address=Куйбышева+50+Екатеринбург
/api/v1/geocode/lookup?address=Ленина+1&city_hint=Нижний+Тагил
"""
result = await geocode(address, db)
result = await geocode(address, db, city_hint=city_hint)
if result is None:
raise HTTPException(status_code=404, detail=f"Address not found: {address}")
return result
@ -55,6 +68,16 @@ async def suggest_addresses(
q: Annotated[str, Query(min_length=2, max_length=200, description="Запрос для автокомплита")],
limit: Annotated[int, Query(ge=1, le=15)] = 8,
db: Annotated[Session, Depends(get_db)] = None, # type: ignore[assignment]
city_hint: Annotated[
str | None,
Query(
max_length=100,
description=(
"Город, если известен вызывающему (#2576) — без него подсказки "
"БОЛЬШЕ НЕ ограничиваются молчаливо Екатеринбургом."
),
),
] = None,
) -> SuggestResponse:
"""Автокомплит адресов в Свердловской области (region 66; ЕКБ — основной трафик,
остаётся быстрым fast-path).
@ -66,8 +89,9 @@ async def suggest_addresses(
Пример:
/api/v1/geocode/suggest?q=Малышева
/api/v1/geocode/suggest?q=Цвиллинга # → пусто, такой улицы в ЕКБ нет
/api/v1/geocode/suggest?q=Ленина+1&city_hint=Нижний+Тагил
"""
items = await suggest(q, db=db, limit=limit)
items = await suggest(q, db=db, limit=limit, city_hint=city_hint)
return SuggestResponse(
items=[
SuggestItem(
@ -100,11 +124,11 @@ class ReverseResponse(BaseModel):
precision: str = Field(
...,
description=(
"Yandex-style: exact/number/street/range/near/locality/other/cadastral. "
"exact/number/street/range/near/locality/other/cadastral. "
"Фронт двигает marker только если exact/number/cadastral."
),
)
provider: str = Field(..., description="cadastral | yandex | nominatim")
provider: str = Field(..., description="cadastral | nominatim")
@router.get("/reverse", response_model=ReverseResponse)

View file

@ -7,16 +7,26 @@ Mounted at /api/v1/me; через Caddy `uri strip_prefix /trade-in` это ст
Caddy basic_auth пропускает `X-Authenticated-User: <username>` через
`header_up` в каждом reverse_proxy. Frontend дёргает /me чтобы понять
кому что показывать.
#2552: session-first. Валидная DB-session cookie (см. app.services.auth_session)
отдаёт scope из tradein_users (role/display_name/org/email) БЕЗ похода в
roles.yaml. Без cookie (или невалидная/истёкшая) legacy X-Authenticated-User
путь, БЕЗ ИЗМЕНЕНИЙ (regression недопустим существующие тесты держат его
бит-в-бит).
"""
from __future__ import annotations
import logging
from typing import Annotated
from typing import Annotated, Any
from fastapi import APIRouter, Header, HTTPException
from fastapi import APIRouter, Depends, Header, HTTPException, Request
from sqlalchemy.orm import Session
from app.core.auth import UserScope, get_user_scope
from app.core.config import settings
from app.core.db import get_db
from app.services.auth_session import get_db_role_scope, get_session_user
logger = logging.getLogger(__name__)
@ -25,9 +35,40 @@ router = APIRouter()
@router.get("/me")
async def me(
request: Request,
db: Annotated[Session, Depends(get_db)],
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
) -> UserScope:
"""Return the current user's RBAC scope (role + allowed/deny paths)."""
) -> UserScope | dict[str, Any]:
"""Return the current user's RBAC scope (role + allowed/deny paths).
Return type is a union (не только `UserScope`) `UserScope.role` это
`Literal["admin","pilot","analyst","expired"]` (legacy roles.yaml names),
а DB-роли (tradein_users.role) `"admin"/"manager"/"employee"`. FastAPI
строит response-схему из return-аннотации; жёсткий `UserScope` завернул бы
"employee"/"manager" в ResponseValidationError. Итоговая JSON-форма
ОДИНАКОВАЯ (те же 8 ключей) для обеих веток.
"""
token = request.cookies.get(settings.session_cookie_name)
if token:
try:
session_user = get_session_user(db, token)
except Exception:
logger.exception("me: session lookup failed")
session_user = None
if session_user is not None:
role = session_user["role"]
allowed_paths, deny_paths = get_db_role_scope(role)
return {
"username": session_user["username"],
"role": role,
"allowed_paths": allowed_paths,
"deny_paths": deny_paths,
"brand": None,
"display_name": session_user["display_name"],
"org": session_user["org_name"],
"email": session_user["email"],
}
if not x_authenticated_user:
raise HTTPException(
status_code=401,

View file

@ -29,20 +29,48 @@ support-моста (`app.services.tgbot.bridge`, data/sql/186_tg_support.sql).
`username` thread_id для отправки не нужен вообще, поэтому эту БД-операцию
можно безопасно отложить до после успешного sendMessage. Бонус: неудачная
отправка больше не создаёт тред.
Анонимная ветка (`/support/anon/*`, инцидент 2026-07-31)
-------------------------------------------------------
Ровно те же 4 действия, но БЕЗ авторизации доступны с экрана входа. Причина:
после cutover'а на свою авторизацию (#2558) единственным каналом в поддержку был
чат ЗА логином, а самая частая причина писать в поддержку как раз «не могу
войти». 2026-07-31 «Практика» весь день билась в форму (5 login_failed, 0
успешных) и достучаться из продукта не могла ничем.
Идентичность анонима opaque-токен в httpOnly-куке (`_ANON_COOKIE_NAME`),
тред живёт в тех же `web_support_threads` под ключом `anon:<token>`. Двоеточие
делает коллизию с реальным логином структурно невозможной: `tradein_users`
допускает только `^[A-Za-z0-9._-]{3,64}$` (CHECK из миграции 193 + Pydantic),
двоеточия там быть не может аноним НИКОГДА не попадёт в чужой тред и не
«станет» существующим юзером.
Изоляция тредов та же, что у авторизованной ветки, и по той же причине:
thread_id не принимается снаружи ни в каком виде, тред резолвится
ИСКЛЮЧИТЕЛЬНО из куки. Кука здесь bearer-токен своего треда, поэтому
httpOnly+Secure+SameSite=Lax (как session-cookie) и `token_urlsafe(18)`
(144 бита) вместо чего-то угадываемого.
В Telegram-топик уходит НЕ сам токен, а `anon-<6 hex от sha256(токен)>`
(`_anon_display_id`): оператору нужен стабильный ярлык треда, а не bearer
зеркало топика читают люди и пересылают дальше.
"""
from __future__ import annotations
import hashlib
import logging
import re
import secrets
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from pydantic import BaseModel, Field, field_validator
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.db import get_db
from app.core.ratelimit import SlidingWindowLimiter
from app.core.ratelimit import SlidingWindowLimiter, _client_ip
from app.services.tgbot import web_support_storage as storage
from app.services.tgbot.bridge import SERVICE_UNAVAILABLE_TEXT
from app.services.tgbot.client import TelegramApiError, TelegramClient
@ -262,3 +290,204 @@ def mark_support_read(
storage.mark_read(db, thread_id=thread_id)
db.commit()
return StatusOut()
# ---------------------------------------------------------------------------
# Анонимная ветка — поддержка без входа (см. блок в докстринге модуля)
# ---------------------------------------------------------------------------
_ANON_COOKIE_NAME = "tradein_support_anon"
# 30 дней: тред должен пережить «напишу вечером — отвечут утром», но не жить вечно.
_ANON_COOKIE_MAX_AGE_S = 30 * 24 * 3600
# Двоеточие → структурная невозможность коллизии с реальным логином (докстринг).
_ANON_THREAD_PREFIX = "anon:"
# Форма того, что МЫ выдаём (`token_urlsafe(18)` → 24 символа из [A-Za-z0-9_-]).
# Кука клиент-контролируема: без этой проверки в ключ треда (а значит в SQL-параметр
# и в лог) уехала бы произвольная строка из браузера. Не матчится — считаем куку
# отсутствующей и выдаём новую, а не пытаемся «починить» присланное.
_ANON_TOKEN_RE = re.compile(r"^[A-Za-z0-9_-]{16,64}\Z")
# Публичная ручка записи в общий Telegram-топик — поверхность для спама, которой у
# авторизованной ветки нет. Два независимых бюджета:
# 1) per-token (`_send_limiter`, 12/мин — тот же объект, ключи не пересекаются:
# анонимные начинаются с "anon:", что невозможно для username);
# 2) per-IP — именно он ловит обход ротацией куки (сбросил куку → новый токен →
# бюджет (1) снова пуст). Окно широкое и щедрое для живого диалога: реальный
# сценарий — «не могу войти, помогите», несколько сообщений подряд.
_ANON_IP_RATE_LIMIT = 10
_ANON_IP_RATE_WINDOW_S = 600.0
_anon_ip_limiter = SlidingWindowLimiter(limit=_ANON_IP_RATE_LIMIT, window_s=_ANON_IP_RATE_WINDOW_S)
def _anon_display_id(token: str) -> str:
"""Стабильный НЕсекретный ярлык треда для оператора — см. докстринг модуля.
sha256, а не префикс токена: префикс это часть bearer'а, а зеркало уходит
в Telegram-топик, который читают люди и пересылают дальше.
"""
return f"anon-{hashlib.sha256(token.encode('utf-8')).hexdigest()[:6]}"
def _read_anon_token(request: Request) -> str | None:
"""Токен из куки, если он валидной формы; иначе None (кука считается отсутствующей)."""
raw = request.cookies.get(_ANON_COOKIE_NAME)
if raw is None or not _ANON_TOKEN_RE.match(raw):
return None
return raw
def _anon_thread_key(token: str) -> str:
return f"{_ANON_THREAD_PREFIX}{token}"
def _set_anon_cookie(response: Response, token: str) -> None:
response.set_cookie(
key=_ANON_COOKIE_NAME,
value=token,
max_age=_ANON_COOKIE_MAX_AGE_S,
httponly=True,
secure=True,
samesite="lax",
path="/",
)
@router.post("/support/anon/messages", response_model=SupportMessageOut)
async def send_anon_support_message(
payload: SupportMessageInput,
request: Request,
response: Response,
db: Annotated[Session, Depends(get_db)],
) -> SupportMessageOut:
"""Сообщение в поддержку БЕЗ входа. Порядок операций — как в авторизованной
ветке (H1 в докстринге модуля): БД трогаем только после успешного sendMessage.
Кука выставляется тоже только на успехе иначе первая же неудачная попытка
(бот не настроен / Telegram лёг) закрепляла бы за посетителем пустой тред.
"""
if not _bot_configured():
raise HTTPException(status_code=503, detail=SERVICE_UNAVAILABLE_TEXT)
token = _read_anon_token(request)
is_new_token = token is None
if token is None:
token = secrets.token_urlsafe(18)
thread_key = _anon_thread_key(token)
ip = _client_ip(request)
# Оба бюджета — non-destructive peek (review L3): неудачная отправка не
# должна стоить посетителю попытки. `.record()` только на успех, ниже.
for retry_after in (_send_limiter.retry_after(thread_key), _anon_ip_limiter.retry_after(ip)):
if retry_after is not None:
raise HTTPException(
status_code=429,
detail="Слишком много сообщений. Попробуйте позже.",
headers={"Retry-After": str(int(retry_after) + 1)},
)
display_id = _anon_display_id(token)
client = TelegramClient(settings.telegram_bot_token)
try:
mirrored = await client.send_message(
chat_id=settings.telegram_support_chat_id,
text=_format_anon_mirror_text(display_id, payload.text),
message_thread_id=settings.telegram_support_topic_id or None,
timeout=_INTERACTIVE_SEND_TIMEOUT_S,
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
)
except TelegramApiError:
# Ни текст сообщения (ПДн), ни токен (bearer треда) в лог не попадают.
logger.exception(
"web support (anon): не удалось отправить зеркало в топик (%s)", display_id
)
raise HTTPException(status_code=502, detail=SERVICE_UNAVAILABLE_TEXT) from None
_send_limiter.record(thread_key)
_anon_ip_limiter.record(ip)
topic_message_id = mirrored.get("message_id") if isinstance(mirrored, dict) else None
if topic_message_id is None:
logger.warning(
"web support (anon): Telegram sendMessage не вернул message_id (%s) — "
"ответ оператора на это сообщение не будет смаршрутизирован",
display_id,
)
thread_id = storage.get_or_create_thread(db, thread_key)
row = storage.record_inbound(
db,
thread_id=thread_id,
text_body=payload.text,
topic_message_id=topic_message_id,
support_chat_id=settings.telegram_support_chat_id,
)
db.commit()
if is_new_token:
_set_anon_cookie(response, token)
logger.info("web support (anon): message sent %s thread_id=%d", display_id, thread_id)
return SupportMessageOut(**row)
def _format_anon_mirror_text(display_id: str, message_text: str) -> str:
"""Помечает зеркало как пришедшее с сайта ОТ НЕЗАЛОГИНЕННОГО посетителя.
Оператору это ключевой контекст: у такого обращения нет аккаунта, по которому
можно посмотреть историю, и самая вероятная причина написать как раз
невозможность войти (инцидент 2026-07-31).
"""
return f"[С САЙТА · БЕЗ ВХОДА] {display_id}:\n{message_text}"
@router.get("/support/anon/messages", response_model=list[SupportMessageOut])
def list_anon_support_messages(
request: Request,
db: Annotated[Session, Depends(get_db)],
since: Annotated[int, Query(ge=0)] = 0,
) -> list[SupportMessageOut]:
"""Свой тред по куке. Нет куки / нет треда → пустой список, НЕ 401: виджет
поллит эту ручку и до первого сообщения, 401 там был бы ложной ошибкой.
Sync `def` (review M3) см. `list_support_messages`.
"""
token = _read_anon_token(request)
if token is None:
return []
thread_id = storage.find_thread_id(db, _anon_thread_key(token))
if thread_id is None:
return []
rows = storage.list_messages(
db, thread_id=thread_id, since_id=since, limit=_LIST_MESSAGES_LIMIT
)
return [SupportMessageOut(**r) for r in rows]
@router.get("/support/anon/unread", response_model=UnreadOut)
def get_anon_support_unread(
request: Request,
db: Annotated[Session, Depends(get_db)],
) -> UnreadOut:
"""Sync `def` (review M3) — см. `list_support_messages`."""
token = _read_anon_token(request)
if token is None:
return UnreadOut(unread=0)
thread_id = storage.find_thread_id(db, _anon_thread_key(token))
if thread_id is None:
return UnreadOut(unread=0)
return UnreadOut(unread=storage.count_unread(db, thread_id=thread_id))
@router.post("/support/anon/read", response_model=StatusOut)
def mark_anon_support_read(
request: Request,
db: Annotated[Session, Depends(get_db)],
) -> StatusOut:
"""Sync `def` (review M3) — см. `list_support_messages`."""
token = _read_anon_token(request)
if token is None:
return StatusOut()
thread_id = storage.find_thread_id(db, _anon_thread_key(token))
if thread_id is not None:
storage.mark_read(db, thread_id=thread_id)
db.commit()
return StatusOut()

View file

@ -0,0 +1,713 @@
"""Team-management API — CRUD сотрудников, квоты, история (#2554, эпик #2549).
Mounted at `/api/v1/team`; через Caddy `uri strip_prefix /trade-in` это
`/trade-in/api/v1/team/*` снаружи. `app.services.auth_session.DB_ROLE_PATHS`
уже закладывает `/api/v1/team/**` в scope роли `manager` (и `admin` через `/**`)
для `rbac_guard` (см. `app.core.rbac`) этот роутер добавляет ВТОРОЙ,
более узкий барьер именно на identity:
- `current_team_actor` резолвит юзера ТОЛЬКО из session-cookie
(`app.services.auth_session.get_session_user`). Legacy
`X-Authenticated-User` (Caddy trusted-header, dual-mode) НЕ принимается
здесь team-API новый, не участвует в переходном dual-mode auth. Без
валидной cookie 401, даже если `rbac_guard` пропустил запрос по
legacy-заголовку (напр. admin через roles.yaml).
- Роль должна быть `admin` или `manager` иначе 403.
Org-изоляция (главный инвариант фичи): manager видит/меняет ТОЛЬКО своих
employee (`tradein_users.manager_id = actor.user_id`). Чужой/несуществующий
employee_id 404 (НЕ 403) не подтверждаем/не опровергаем существование
чужого сотрудника перед manager'ом. См. `_authorize_employee`.
Кого именно можно менять через этот роутер (`_MANAGEABLE_ROLES_BY_ACTOR`):
- actor manager только `role='employee'` И только своих (как было).
- actor admin `role IN ('employee','manager')`.
Почему admin'у отдали и менеджеров (инцидент 2026-07-31): после cutover'а на
DB-auth (#2558) аккаунты `kopylov`/`praktika` сидят с `role='manager'`, а этот
роутер жёстко фильтровал `role='employee'` сбросить менеджеру пароль или
заблокировать его было НЕЧЕМ, кроме ручного psql на проде. Роль manager вводилась
как «владелец своей организации», а не как «неприкасаемый аккаунт».
`role='admin'` НЕ входит ни в один набор, и это несущий инвариант, а не
экономия: он один держит невозможность self-lockout'а. Актёр этого роутера —
всегда admin или manager (`current_team_actor`); manager до admin-строки не
дотянется по своей ветке фильтра, а admin не дотянется до admin-строки вообще
в том числе до собственной. Поэтому ни один путь ниже (block, смена пароля +
`revoke_user_sessions`) не может вырубить самого действующего админа или
разжаловать другого. Раздача/отзыв роли admin остаётся операцией уровня
миграции/psql сознательно вне API.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Annotated, Any
from urllib.parse import urlparse
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import text
from sqlalchemy.engine import RowMapping
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.auth import get_role
from app.core.config import settings
from app.core.db import get_db
from app.core.password import hash_password
from app.schemas.team import (
EmployeeCreateRequest,
EmployeeHistoryEntry,
EmployeeOut,
EmployeeUpdateRequest,
QuotaStatusOut,
)
from app.services import account_quota
from app.services.auth_session import get_session_user, revoke_user_sessions
from app.services.user_events import schedule_event
logger = logging.getLogger(__name__)
router = APIRouter()
@dataclass
class TeamActor:
"""Резолвленный из session-cookie актёр team-API — admin или manager."""
user_id: int
username: str
role: str # "admin" | "manager"
async def current_team_actor(
request: Request,
db: Annotated[Session, Depends(get_db)],
) -> TeamActor:
"""Dependency: session-only identity, роль admin|manager, иначе 401/403.
Намеренно НЕ читает `X-Authenticated-User` см. модульный docstring.
"""
token = request.cookies.get(settings.session_cookie_name)
if not token:
raise HTTPException(status_code=401, detail="valid session required")
try:
session_user = get_session_user(db, token)
except Exception:
logger.exception("team: session lookup failed")
raise HTTPException(status_code=401, detail="valid session required") from None
if session_user is None:
raise HTTPException(status_code=401, detail="valid session required")
role = session_user["role"]
if role not in ("admin", "manager"):
raise HTTPException(status_code=403, detail="admin or manager role required")
return TeamActor(
user_id=session_user["user_id"],
username=session_user["username"],
role=role,
)
def _origin_host_allowed(candidate: str) -> bool:
"""True если scheme://netloc *candidate* совпадает с одним из `settings.cors_origins`.
`cors_origins` уже является источником правды для «какие origin'ы это наш
фронт» (см. CORSMiddleware в app/main.py, ENV CORS_ORIGINS) переиспользуем
его вместо нового хардкода."""
try:
parsed = urlparse(candidate)
except ValueError:
return False
if not parsed.scheme or not parsed.netloc:
return False
origin = f"{parsed.scheme}://{parsed.netloc}"
return origin in settings.cors_origins
def _require_same_origin(request: Request) -> None:
"""CSRF defense-in-depth (issue #2554 DoD) для state-changing team-роутов
(POST/PATCH): `Origin` (или `Referer` как fallback) обязан матчить один из
`settings.cors_origins`, иначе 403.
Оба заголовка отсутствуют ПРОПУСКАЕМ (не 403). Причина: это единственный
надёжный сигнал non-browser клиента в этом стеке curl-смоуки внутри
контейнера (см. `.claude/rules/tradein.md` "Тестировать HTTP только ВНУТРИ
контейнера", `docker exec tradein-backend curl ...`) не шлют ни один из этих
заголовков, а реальный браузер (fetch/XHR/form) ВСЕГДА прикладывает Origin
на unsafe-методах (POST/PATCH) так что "оба отсутствуют" практически
невозможно для настоящего кросс-сайтового CSRF через браузер. Session-cookie
уже стоит на `SameSite=Lax` (см. `app.api.v1.auth.login`) это первый рубеж
против CSRF, Origin-check второй.
"""
candidate = request.headers.get("origin") or request.headers.get("referer")
if candidate is None:
return
if not _origin_host_allowed(candidate):
logger.warning(
"team: Origin/Referer mismatch %r on %s — possible CSRF", candidate, request.url.path
)
raise HTTPException(status_code=403, detail="origin not allowed")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
# Два статических варианта — НЕ динамическая сборка WHERE (та же мотивация, что
# у `_LIST_EMPLOYEES_*_SQL` ниже: значения и так биндятся параметрами, но
# статические ветки не провоцируют будущие правки в сторону конкатенации SQL).
# Роль 'admin' не встречается ни в одной ветке — см. модульный docstring.
_FETCH_MANAGED_EMPLOYEE_SQL = text(
"""
SELECT id, username, role, display_name, org_name, email, is_active,
manager_id, created_at
FROM tradein_users
WHERE id = :id AND role = 'employee'
"""
)
_FETCH_MANAGED_ANY_SQL = text(
"""
SELECT id, username, role, display_name, org_name, email, is_active,
manager_id, created_at
FROM tradein_users
WHERE id = :id AND role IN ('employee', 'manager')
"""
)
def _fetch_employee_row(db: Session, employee_id: int, actor: TeamActor) -> RowMapping | None:
"""Строка управляемого юзера в пределах прав *actor* — иначе None (→ 404).
Фильтр по роли делается ЗДЕСЬ, в SQL, а не в `_authorize_employee` ниже:
для manager'а строка менеджера/админа не должна даже доехать до
вызывающего кода. `None` для обоих случаев («нет такого id» и «этот id
тебе не по зубам») тот же принцип, что и 404-вместо-403 в
`_authorize_employee`: не палим существование чужой строки.
"""
sql = _FETCH_MANAGED_ANY_SQL if actor.role == "admin" else _FETCH_MANAGED_EMPLOYEE_SQL
return db.execute(sql, {"id": employee_id}).mappings().fetchone()
def _authorize_employee(actor: TeamActor, row: RowMapping | None) -> RowMapping:
"""404 (НЕ 403) если сотрудник не найден ИЛИ принадлежит другому manager'у.
Org-изоляция: manager может видеть/менять только `manager_id == actor.user_id`.
404 вместо 403 не палим существование чужого employee_id.
Для admin'а доп. проверки нет: набор строк, до которых он вообще может
дотянуться, уже ограничен ролью в `_fetch_employee_row` (employee|manager,
без admin). У менеджерских строк `manager_id` штатно NULL сравнивать его
с чем-либо здесь нечего.
"""
if row is None:
raise HTTPException(status_code=404, detail="employee not found")
if actor.role == "manager" and row["manager_id"] != actor.user_id:
raise HTTPException(status_code=404, detail="employee not found")
return row
def _upsert_quota_override(
db: Session, username: str, monthly_limit: int, actor_username: str
) -> None:
"""Upsert персонального лимита. Явная установка monthly_limit — сигнал "хочу
numeric-квоту", поэтому ВСЕГДА сбрасывает `unlimited=false` (иначе лимит может
молча не применяться прежний unlimited-грант выигрывал бы у нового limit).
`note` НЕ затирается, если уже задан (`COALESCE`): не перезаписываем
человеко-читаемую причину прошлого гранта (напр. "пилот, грант ...") молча
сгенерированной строкой; note проставляется только при первом upsert записи.
"""
db.execute(
text(
"""
INSERT INTO account_quota_overrides (username, monthly_limit, unlimited, note)
VALUES (:username, CAST(:monthly_limit AS integer), false, :note)
ON CONFLICT (username) DO UPDATE SET
monthly_limit = EXCLUDED.monthly_limit,
unlimited = false,
note = COALESCE(account_quota_overrides.note, EXCLUDED.note),
updated_at = now()
"""
),
{
"username": username,
"monthly_limit": monthly_limit,
"note": f"team-api: set by {actor_username}",
},
)
def _batch_quota_status(db: Session, usernames: list[str]) -> dict[str, dict[str, Any]]:
"""Батч-версия `account_quota.get_status` для N сотрудников — 2 SQL-запроса
вместо 2N (было 2N+3 на GET /employees, HIGH/Medium2 review PR #2563).
Семантика ИДЕНТИЧНА `account_quota.is_unlimited`/`user_limit`/`get_status`
(follow-up review PR #2563 п.2 — предыдущая версия расходилась: батч ВСЕГДА
читал `account_quota_overrides.unlimited`, а `is_unlimited` ТОЛЬКО для
username, присутствующего в roles.yaml):
- username НЕ в roles.yaml (`get_role` KeyError) unlimited=False ВСЕГДА,
`account_quota_overrides.unlimited` даже не проверяется (roles.yaml
источник правды "кто вообще может быть unlimited", override "у кого
именно из известных roles.yaml-юзеров"). Сегодня недостижимо для DB-only
сотрудников team-API (`_upsert_quota_override` всегда пишет
`unlimited=false`), но станет достижимым при ручном UPDATE
`account_quota_overrides` или расширении roles.yaml расхождение с
реальным enforcement (`check_and_raise`/`increment`, тот же `is_unlimited`)
было бы честной ложью в списке: "без лимита", который движок всё равно
считает.
- username в roles.yaml и role == admin unlimited=True (без похода в БД).
- username в roles.yaml, role != admin unlimited = override.unlimited.
limit = override.monthly_limit (читается для ЛЮБОГО username, без gate по
roles.yaml так же ведёт себя `account_quota.user_limit`), иначе глобальный
`account_quota.MONTHLY_LIMIT`.
"""
if not usernames:
return {}
overrides = (
db.execute(
text(
"""
SELECT username, monthly_limit, unlimited
FROM account_quota_overrides
WHERE username = ANY(CAST(:usernames AS text[]))
"""
),
{"usernames": usernames},
)
.mappings()
.all()
)
override_by_username = {r["username"]: r for r in overrides}
period = account_quota.current_period()
usage_rows = (
db.execute(
text(
"""
SELECT username, used
FROM account_estimate_usage
WHERE username = ANY(CAST(:usernames AS text[])) AND period_month = :period
"""
),
{"usernames": usernames, "period": period},
)
.mappings()
.all()
)
used_by_username = {r["username"]: r["used"] for r in usage_rows}
result: dict[str, dict[str, Any]] = {}
for username in usernames:
override = override_by_username.get(username)
try:
role = get_role(username)
except KeyError:
role = None
if role == "admin":
unlimited = True
elif role is not None:
unlimited = bool(override is not None and override["unlimited"])
else:
# username не в roles.yaml — is_unlimited() короткое замыкание на
# False, override НЕ проверяется (см. докстринг выше).
unlimited = False
limit = (
int(override["monthly_limit"])
if override is not None and override["monthly_limit"] is not None
else account_quota.MONTHLY_LIMIT
)
used = used_by_username.get(username, 0)
if unlimited:
result[username] = {
"limit": limit,
"used": used,
"remaining": limit,
"unlimited": True,
}
else:
remaining = max(0, limit - max(0, used))
result[username] = {
"limit": limit,
"used": used,
"remaining": remaining,
"unlimited": False,
}
return result
def _employee_out(row: RowMapping, quota: dict[str, Any]) -> EmployeeOut:
return EmployeeOut(
id=row["id"],
username=row["username"],
role=row["role"],
display_name=row["display_name"],
org_name=row["org_name"],
email=row["email"],
is_active=row["is_active"],
manager_id=row["manager_id"],
created_at=row["created_at"],
quota=QuotaStatusOut(**quota),
)
# ---------------------------------------------------------------------------
# POST /employees
# ---------------------------------------------------------------------------
@router.post("/employees", response_model=EmployeeOut, status_code=201)
async def create_employee(
body: EmployeeCreateRequest,
actor: Annotated[TeamActor, Depends(current_team_actor)],
db: Annotated[Session, Depends(get_db)],
_origin_check: Annotated[None, Depends(_require_same_origin)],
) -> EmployeeOut:
"""Создать сотрудника. Роль всегда `employee`.
manager_id: для actor.role == manager принудительно свой id (любое
значение из тела ИГНОРИРУЕТСЯ, org-изоляция инвариант #2554). Для
actor.role == admin опционально из тела, валидируется что указанный id
существует и role='manager' (иначе 422).
"""
existing = db.execute(
text("SELECT id FROM tradein_users WHERE username = :u"),
{"u": body.username},
).fetchone()
if existing is not None:
raise HTTPException(status_code=409, detail="username already exists")
try:
password_hash = hash_password(body.password)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e)) from None
manager_id: int | None
if actor.role == "manager":
# Инвариант org-изоляции: manager не может создать сотрудника под
# чужим manager_id — любое значение из тела игнорируется молча.
manager_id = actor.user_id
else:
manager_id = body.manager_id
if manager_id is not None:
mgr = db.execute(
text("SELECT id FROM tradein_users WHERE id = :id AND role = 'manager'"),
{"id": manager_id},
).fetchone()
if mgr is None:
raise HTTPException(
status_code=422,
detail="manager_id does not reference an existing manager",
)
try:
row = (
db.execute(
text(
"""
INSERT INTO tradein_users
(username, password_hash, role, manager_id, display_name, org_name,
email, is_active)
VALUES
(:username, :password_hash, 'employee', :manager_id, :display_name,
:org_name, :email, true)
RETURNING id, username, role, display_name, org_name, email, is_active,
manager_id, created_at
"""
),
{
"username": body.username,
"password_hash": password_hash,
"manager_id": manager_id,
"display_name": body.display_name,
"org_name": body.org_name,
"email": body.email,
},
)
.mappings()
.fetchone()
)
except IntegrityError:
# TOCTOU: два конкурентных POST с одинаковым username между pre-check
# выше и этим INSERT — UNIQUE-констрейнт на tradein_users.username ловит.
db.rollback()
raise HTTPException(status_code=409, detail="username already exists") from None
assert row is not None # RETURNING на успешный INSERT всегда отдаёт строку
if body.monthly_limit is not None:
_upsert_quota_override(db, body.username, body.monthly_limit, actor.username)
db.commit()
schedule_event(
event_type="employee_created",
username=actor.username,
payload={
"employee_id": row["id"],
"employee_username": row["username"],
"manager_id": manager_id,
},
)
quota = account_quota.get_status(db, body.username)
return _employee_out(row, quota)
# ---------------------------------------------------------------------------
# PATCH /employees/{id}
# ---------------------------------------------------------------------------
@router.patch("/employees/{employee_id}", response_model=EmployeeOut)
async def update_employee(
employee_id: int,
body: EmployeeUpdateRequest,
actor: Annotated[TeamActor, Depends(current_team_actor)],
db: Annotated[Session, Depends(get_db)],
_origin_check: Annotated[None, Depends(_require_same_origin)],
) -> EmployeeOut:
"""Частичное обновление сотрудника — block/unblock, лимит, профиль, пароль.
manager может патчить ТОЛЬКО своих (manager_id == actor.user_id), иначе 404.
При is_active=False ИЛИ смене пароля (new_password) обязательно revoke всех
сессий (HIGH, deep-review PR #2563): без этого блокировка/reset не подействуют
до истечения TTL текущей сессии сотрудника хуже того, sliding-refresh
(`app.services.auth_session.get_session_user`) продлевает `expires_at` на
КАЖДОМ запросе, так что скомпрометированная/чужая сессия живёт неограниченно
долго, а не «до TTL». `revoke_user_sessions` сам называет смену пароля своим
use-case см. его докстринг.
"""
row = _fetch_employee_row(db, employee_id, actor)
row = _authorize_employee(actor, row)
new_password_hash: str | None = None
if body.new_password is not None:
try:
new_password_hash = hash_password(body.new_password)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e)) from None
db.execute(
text(
"""
UPDATE tradein_users
SET display_name = COALESCE(:display_name, display_name),
org_name = COALESCE(:org_name, org_name),
email = COALESCE(:email, email),
is_active = COALESCE(CAST(:is_active AS boolean), is_active),
password_hash = COALESCE(:password_hash, password_hash),
updated_at = now()
WHERE id = :id
"""
),
{
"display_name": body.display_name,
"org_name": body.org_name,
"email": body.email,
"is_active": body.is_active,
"password_hash": new_password_hash,
"id": employee_id,
},
)
if body.monthly_limit is not None:
_upsert_quota_override(db, row["username"], body.monthly_limit, actor.username)
if body.is_active is False or body.new_password is not None:
# Обязательно ПОСЛЕ UPDATE, ДО финального commit — revoke_user_sessions
# коммитит сам (см. app.services.auth_session), это флашит и наш
# предшествующий UPDATE/quota-upsert в той же сессии. Self-lockout
# невозможен: _fetch_employee_row не отдаёт строки с role='admin'
# НИКОМУ, а manager'у — ещё и только role='employee'; т.е. actor
# (admin|manager) никогда не может патчить сам себя через этот роут.
revoke_user_sessions(db, employee_id)
db.commit()
changed_profile_fields = [
f
for f, v in (
("display_name", body.display_name),
("org_name", body.org_name),
("email", body.email),
)
if v is not None
]
if changed_profile_fields:
schedule_event(
event_type="employee_updated",
username=actor.username,
payload={
"employee_id": employee_id,
"employee_username": row["username"],
"fields": changed_profile_fields,
},
)
if body.new_password is not None:
schedule_event(
event_type="employee_password_reset",
username=actor.username,
payload={"employee_id": employee_id, "employee_username": row["username"]},
)
if body.is_active is not None:
schedule_event(
event_type="employee_blocked" if body.is_active is False else "employee_unblocked",
username=actor.username,
payload={"employee_id": employee_id, "employee_username": row["username"]},
)
if body.monthly_limit is not None:
schedule_event(
event_type="quota_changed",
username=actor.username,
payload={
"employee_id": employee_id,
"employee_username": row["username"],
"monthly_limit": body.monthly_limit,
},
)
updated_row = _fetch_employee_row(db, employee_id, actor)
assert updated_row is not None # только что успешно обновили эту же строку
quota = account_quota.get_status(db, updated_row["username"])
return _employee_out(updated_row, quota)
# ---------------------------------------------------------------------------
# GET /employees
# ---------------------------------------------------------------------------
# Два статических варианта WHERE (НЕ f-string/динамическая сборка — Medium/
# "заодно" review PR #2563: значения биндятся параметрами и без того безопасны,
# но статические ветки не провоцируют будущие правки в сторону конкатенации SQL).
#
# ORDER BY created_at DESC, id DESC — тай-брейкер по `id` ОБЯЗАТЕЛЕН (follow-up
# review PR #2563 п.1): `created_at DEFAULT now()` — время ТРАНЗАКЦИИ, а bulk-seed
# (#2557) вставляет много юзеров одной транзакцией → идентичный timestamp у N строк.
# Без тай-брейкера порядок между страницами (LIMIT/OFFSET) на PostgreSQL для
# строк-«близнецов» не гарантирован — сотрудники пропадали/дублировались бы при
# постраничном листании. `id` монотонно растёт (BIGINT IDENTITY) — детерминированный
# tie-break без доп. индекса (созданные позже = бОльший id, тот же порядок что и
# намерение DESC-сортировки по времени).
_LIST_EMPLOYEES_BY_MANAGER_SQL = text(
"""
SELECT id, username, role, display_name, org_name, email, is_active, manager_id, created_at
FROM tradein_users
WHERE role = 'employee' AND manager_id = :manager_id
ORDER BY created_at DESC, id DESC
LIMIT :limit OFFSET :offset
"""
)
# Admin-ветка: сюда попадают И менеджеры (см. модульный docstring — иначе admin
# не видит в UI строку, которой должен уметь сбросить пароль). `role='admin'`
# по-прежнему невидим и неуправляем. Сортировка по (created_at, id) общая для
# обеих ролей — намеренно: seed (#2557) вставил всех одной транзакцией, так что
# группировка «сначала менеджеры» дала бы ложное ощущение иерархии там, где её
# в данных нет; роль показывается колонкой (`EmployeeOut.role`).
_LIST_EMPLOYEES_ALL_SQL = text(
"""
SELECT id, username, role, display_name, org_name, email, is_active, manager_id, created_at
FROM tradein_users
WHERE role IN ('employee', 'manager')
ORDER BY created_at DESC, id DESC
LIMIT :limit OFFSET :offset
"""
)
@router.get("/employees", response_model=list[EmployeeOut])
async def list_employees(
actor: Annotated[TeamActor, Depends(current_team_actor)],
db: Annotated[Session, Depends(get_db)],
manager_id: Annotated[int | None, Query()] = None,
limit: Annotated[int, Query(ge=1, le=200)] = 50,
offset: Annotated[int, Query(ge=0)] = 0,
) -> list[EmployeeOut]:
"""Список сотрудников. manager видит только своих; admin — всех, опц. ?manager_id=.
Квота ОДИН батч-запрос на всю страницу (`_batch_quota_status`), не N+1
(Medium2, review PR #2563: было 2N+3 SQL-запросов на N сотрудников).
"""
if actor.role == "manager":
rows = (
db.execute(
_LIST_EMPLOYEES_BY_MANAGER_SQL,
{"manager_id": actor.user_id, "limit": limit, "offset": offset},
)
.mappings()
.all()
)
elif manager_id is not None:
rows = (
db.execute(
_LIST_EMPLOYEES_BY_MANAGER_SQL,
{"manager_id": manager_id, "limit": limit, "offset": offset},
)
.mappings()
.all()
)
else:
rows = (
db.execute(_LIST_EMPLOYEES_ALL_SQL, {"limit": limit, "offset": offset}).mappings().all()
)
quota_by_username = _batch_quota_status(db, [row["username"] for row in rows])
return [_employee_out(row, quota_by_username[row["username"]]) for row in rows]
# ---------------------------------------------------------------------------
# GET /employees/{id}/history
# ---------------------------------------------------------------------------
@router.get("/employees/{employee_id}/history", response_model=list[EmployeeHistoryEntry])
async def employee_history(
employee_id: int,
actor: Annotated[TeamActor, Depends(current_team_actor)],
db: Annotated[Session, Depends(get_db)],
limit: Annotated[int, Query(ge=1, le=200)] = 50,
offset: Annotated[int, Query(ge=0)] = 0,
) -> list[EmployeeHistoryEntry]:
"""История оценок сотрудника (адрес/дата/результат) — из `user_events`,
LEFT JOIN `trade_in_estimates` за фактическим результатом.
Та же org-проверка что и в PATCH: чужой employee_id 404.
"""
row = _fetch_employee_row(db, employee_id, actor)
row = _authorize_employee(actor, row)
rows = (
db.execute(
text(
"""
SELECT
CAST(ue.estimate_id AS text) AS estimate_id,
ue.payload ->> 'address' AS address,
ue.payload ->> 'area_m2' AS area_m2,
ue.payload ->> 'rooms' AS rooms,
te.median_price,
te.confidence,
te.n_analogs,
ue.created_at
FROM user_events ue
LEFT JOIN trade_in_estimates te ON te.id = ue.estimate_id
WHERE ue.username = :username AND ue.event_type = 'estimate_request'
ORDER BY ue.created_at DESC
LIMIT :limit OFFSET :offset
"""
),
{"username": row["username"], "limit": limit, "offset": offset},
)
.mappings()
.all()
)
return [EmployeeHistoryEntry.model_validate(dict(r)) for r in rows]

View file

@ -49,11 +49,28 @@ class Settings(BaseSettings):
default="", validation_alias="TRADEIN_INTERNAL_AUTH_SECRET"
)
# Geocoder. Env var name `YANDEX_GEOCODER_API_KEY` — consistent с scripts/
# backfill_house_coords.py + audit_address_mismatch.py + main backend
# OpenRouteService_API_KEY pattern. Renamed from YANDEX_GEOCODER_KEY (PR F).
yandex_geocoder_api_key: str | None = None # 25K req/day free после регистрации
yandex_suggest_key: str | None = None # для frontend autocomplete (proxy через backend)
# ── #2550: DB-auth foundation (bcrypt password hashing + session cookie) ────
# Подготовительные поля для #2549 (эпик). Enforcement непустого session_secret
# (fail-fast при пустом значении в prod) добавится в #2552 — здесь дефолт
# намеренно пустой, чтобы прод-контейнер не падал на старте до того как
# секрет проставлен в .env.runtime. ENV: SESSION_SECRET.
session_secret: str = Field(default="", validation_alias="SESSION_SECRET")
# Имя cookie для DB-based сессии (отдельно от Caddy basic_auth / trusted-header).
session_cookie_name: str = Field(
default="tradein_session", validation_alias="SESSION_COOKIE_NAME"
)
# TTL сессии в часах. Дефолт 720ч (30 дней).
session_ttl_hours: int = Field(default=720, validation_alias="SESSION_TTL_HOURS")
# "dual" — переходный режим (Caddy trusted-header ИЛИ DB-сессия оба валидны);
# "db_only" — только DB-сессия (Caddy basic_auth убран). Переключение — #2552+.
auth_mode: Literal["dual", "db_only"] = Field(default="dual", validation_alias="AUTH_MODE")
# Rate-limit на /login: не более login_rate_limit попыток за
# login_rate_limit_window_s секунд на ключ (обычно IP или username).
login_rate_limit: int = Field(default=5, validation_alias="LOGIN_RATE_LIMIT")
login_rate_limit_window_s: int = Field(
default=300, validation_alias="LOGIN_RATE_LIMIT_WINDOW_S"
)
# для User-Agent в Nominatim (Nominatim Usage Policy)
contact_email: str = "erginrajpopxbe@outlook.com"

View file

@ -8,6 +8,7 @@ This helper:
- applies idempotent CREATE or ALTER mapping on every backend startup so
password rotation through .env.runtime is picked up after restart.
"""
from __future__ import annotations
import logging
@ -37,7 +38,7 @@ def ensure_fdw_user_mapping(db: Session) -> None:
logger.warning(
"GENDESIGN_FDW_PASSWORD not set — skipping FDW user mapping "
"(gendesign_cad_buildings queries will fail; cadastral lookups will "
"fall back to Yandex/Nominatim)"
"fall back to Nominatim)"
)
return
@ -62,16 +63,20 @@ def ensure_fdw_user_mapping(db: Session) -> None:
).first()
if exists is None:
db.execute(text(
f"CREATE USER MAPPING FOR CURRENT_USER SERVER gendesign_remote "
f"OPTIONS (user 'tradein_fdw_reader', password '{password}')"
))
db.execute(
text(
f"CREATE USER MAPPING FOR CURRENT_USER SERVER gendesign_remote "
f"OPTIONS (user 'tradein_fdw_reader', password '{password}')"
)
)
logger.info("created FDW user mapping for gendesign_remote")
else:
db.execute(text(
f"ALTER USER MAPPING FOR CURRENT_USER SERVER gendesign_remote "
f"OPTIONS (SET password '{password}')"
))
db.execute(
text(
f"ALTER USER MAPPING FOR CURRENT_USER SERVER gendesign_remote "
f"OPTIONS (SET password '{password}')"
)
)
logger.info("refreshed FDW user mapping password for gendesign_remote")
try:

View file

@ -0,0 +1,61 @@
"""Bcrypt password hashing для DB-auth (#2550 — foundation, эпик #2549).
bcrypt тихо обрезает пароли длиннее 72 байт (UTF-8) это silent-truncation
дыра (два разных пароля с общим 72-байтовым префиксом хешируются одинаково).
`hash_password` явно ловит это и падает с ValueError вместо тихого поведения.
`verify_password` на длинном пароле возвращает False (не raise) сравнение
паролей не должно ронять запрос авторизации.
"""
from __future__ import annotations
import logging
import bcrypt
logger = logging.getLogger(__name__)
_BCRYPT_MAX_BYTES = 72
_BCRYPT_ROUNDS = 12
def hash_password(plain: str) -> str:
"""Хеширует пароль через bcrypt (rounds=12).
Raises:
ValueError: пустой пароль или пароль длиннее 72 байт в UTF-8
(bcrypt тихо обрезает недопустимо, см. модульный docstring).
"""
if not plain:
raise ValueError("password must not be empty")
encoded = plain.encode("utf-8")
if len(encoded) > _BCRYPT_MAX_BYTES:
raise ValueError(
f"password too long: {len(encoded)} bytes (bcrypt max {_BCRYPT_MAX_BYTES})"
)
salt = bcrypt.gensalt(rounds=_BCRYPT_ROUNDS)
hashed = bcrypt.hashpw(encoded, salt)
return hashed.decode("utf-8")
def verify_password(plain: str, hashed: str) -> bool:
"""Сверяет пароль с bcrypt-хешем.
Пустой пароль или пароль длиннее 72 байт в UTF-8 False (не raise
verify это false/true проверка на этапе логина, а не валидация ввода).
"""
if not plain or not hashed:
return False
encoded = plain.encode("utf-8")
if len(encoded) > _BCRYPT_MAX_BYTES:
return False
try:
return bcrypt.checkpw(encoded, hashed.encode("utf-8"))
except (ValueError, TypeError) as e:
# Malformed hash (напр. не-bcrypt строка в БД) — не должно ронять login.
logger.warning("verify_password: malformed hash rejected: %s", e)
return False

View file

@ -7,12 +7,16 @@ manually". The copy drifted: it was missing the #2213
``X-Internal-Auth-Secret`` defense-in-depth check that the real guard has,
so a regression in that check would NOT have failed CI.
This module holds the real guard with no DB/lifespan/scheduler side effects
(only ``app.core.auth`` + ``app.core.config``, both side-effect-free at
import time beyond requiring ``DATABASE_URL`` in the environment for
``Settings()``). ``app/main.py`` and the test apps both import THIS module,
so tests exercise the exact production code path instead of a copy that can
silently fall out of sync.
This module holds the real guard. Historically it had "no DB/lifespan/scheduler
side effects" beyond ``app.core.auth``/``app.core.config`` (both side-effect-free
at import time). #2552 (dual-mode DB-session auth) adds a conditional per-request
DB round trip via ``app.core.db.SessionLocal`` но ТОЛЬКО когда запрос реально
несёт session-cookie (``request.cookies.get(settings.session_cookie_name)``);
без cookie (весь существующий тестовый трафик, legacy Caddy trusted-header
запросы) ветка не выполняется ноль новых DB-побочных эффектов для старых
путей. ``app/main.py`` and the test apps both import THIS module, so tests
exercise the exact production code path instead of a copy that can silently
fall out of sync.
"""
from __future__ import annotations
@ -21,12 +25,15 @@ import logging
import re
import secrets
from collections.abc import Awaitable, Callable
from typing import Any
from fastapi import Request
from fastapi.responses import JSONResponse, Response
from app.core.auth import get_role, is_path_allowed
from app.core.config import settings
from app.core.db import SessionLocal
from app.services.auth_session import get_db_role_scope, get_session_user
logger = logging.getLogger(__name__)
@ -40,7 +47,37 @@ logger = logging.getLogger(__name__)
# Public paths без auth (/health, /docs, /openapi.json) пропускаем —
# X-Authenticated-User там не приходит из Caddy.
_ADMIN_API_RE = re.compile(r"^/api/v1/admin/")
_PUBLIC_PATHS = frozenset({"/health", "/docs", "/redoc", "/openapi.json"})
# #2552: /api/v1/auth/login + /logout — по определению вызываются ДО того, как
# клиент аутентифицирован (login) или могут вызываться с уже протухшей/отсутствующей
# сессией (logout — должен уметь чистить stale cookie без валидной auth). Свой
# rate-limit у /login отдельный (app.api.v1.auth._LOGIN_LIMITER), RateLimitMiddleware
# на /api/* всё равно применяется — это ослабляет ТОЛЬКО rbac_guard'овский
# auth-required gate, не остальные защиты.
#
# Инцидент 2026-07-31: /api/v1/trade-in/support/anon/* — по той же логике. Единственным
# каналом в поддержку был чат ЗА логином, а типовая причина писать в поддержку —
# «не могу войти» (в тот день так и вышло: «Практика» билась в форму весь день и
# достучаться из продукта не могла). Ветка НЕ трогает авторизованные
# /api/v1/trade-in/support/* — те по-прежнему требуют identity; у анонимной свой,
# заведомо более узкий бюджет (per-token + per-IP, см. app.api.v1.support) и своя
# идентичность из httpOnly-куки, которая структурно не может совпасть с чьим-то
# логином.
_PUBLIC_PATHS = frozenset(
{
"/health",
"/docs",
"/redoc",
"/openapi.json",
"/api/v1/auth/login",
"/api/v1/auth/logout",
# NB: префикс — /api/v1/trade-in (app/main.py include_router), а Caddy
# срезает ВНЕШНИЙ /trade-in ещё раньше. Т.е. снаружи это
# /trade-in/api/v1/trade-in/support/anon/*, сюда приходит вот такое.
"/api/v1/trade-in/support/anon/messages",
"/api/v1/trade-in/support/anon/unread",
"/api/v1/trade-in/support/anon/read",
}
)
# #R2-H3: Caddy срезает внешний префикс /trade-in (uri strip_prefix) перед
# tradein-backend, а globs в roles.yaml — ВНЕШНИЕ (/trade-in/api/v1/**). Для
# scope-проверки восстанавливаем внешний путь.
@ -51,6 +88,77 @@ _EXTERNAL_PREFIX = "/trade-in"
_RBAC_BOOTSTRAP_EXEMPT = ("/api/v1/me", "/api/v1/brand")
def _db_glob_match(pattern: str, path: str) -> bool:
"""Мини-матчер для фиксированного набора DB-role паттернов
(``app.services.auth_session.DB_ROLE_PATHS`` только формы ``/**`` и
``<prefix>/**``, не нужна полная semantics ``app.core.auth._glob_to_regex``
тот модуль private и MIRROR'ится вручную с основным бэкендом, лишний
импорт private-символа оттуда увеличивал бы drift-риск)."""
if pattern == "/**":
return True
if pattern.endswith("/**"):
prefix = pattern[: -len("/**")]
return path == prefix or path.startswith(prefix + "/")
return path == pattern
def _db_role_path_allowed(role: str, path: str) -> bool:
paths, deny = get_db_role_scope(role)
if any(_db_glob_match(p, path) for p in deny):
return False
return any(_db_glob_match(p, path) for p in paths)
def _propagate_authenticated_user(request: Request, username: str) -> None:
"""Инжектит ``X-Authenticated-User`` в ASGI scope — ПЕРЕЗАПИСЫВАЯ, а не
только добавляя при отсутствии, чтобы ``RateLimitMiddleware``/
``RequestAuditMiddleware`` (оба читают сырой заголовок напрямую,
#2213/#2550) и downstream route-хендлеры (читающие его через FastAPI
``Header()``) видели РЕЗОЛВЛЕННОГО ИЗ СЕССИИ юзера без правок в каждом
из этих мест по отдельности (минимально инвазивный способ).
#2552 post-review fix (CRITICAL): раньше это была skip-if-present
мутация (``if request.headers.get(...): return``) сессия резолвилась
ПЕРВОЙ (см. rbac_guard), но клиент-контролируемый ``X-Authenticated-User``
(который Caddy шлёт на КАЖДЫЙ прод-запрос) выигрывал у неё для ВСЕГО
downstream-трафика: атакующий с валидной cookie юзера ``alice`` мог
подделать заголовок ``X-Authenticated-User: victim`` и получить доступ к
данным victim в ~15 роутах, читающих заголовок напрямую
(``_assert_estimate_access*``, ``account_quota``, ``/trade-in/history``,
``support.py``) работало в ОБОИХ auth_mode (dual и db_only), т.к. эти
хендлеры не знают про rbac_guard'овский ``from_session`` флаг, только про
сырой заголовок. Session-identity ДОЛЖНА быть источником истины, если
сессия резолвлена полная перезапись, не skip.
Механизм: ``request.scope`` ОДИН и тот же dict-объект, прокинутый по
ссылке через весь ASGI call chain (Starlette не копирует scope между
слоями middleware). Мутация ``scope["headers"]`` ЗДЕСЬ видна:
- downstream call_next() цепочке (ExceptionMiddleware Router
endpoint) т.к. rbac_guard мутирует scope ДО вызова call_next();
- ``RequestAuditMiddleware`` он внешний относительно rbac_guard
(см. app/main.py: последний ``add_middleware`` оборачивает
предыдущие) и читает ``request.headers`` уже ПОСЛЕ ``call_next()``
отработал весь внутренний стек, включая эту мутацию.
ASGI header-имена всегда lowercase bytes (см. ASGI spec), поэтому
фильтр по ``b"x-authenticated-user"`` ловит заголовок независимо от
регистра, в котором его прислал клиент (Starlette уже нормализует).
Известное ограничение: ``RateLimitMiddleware`` тоже внешний относительно
rbac_guard, но читает заголовок ДО вызова call_next() (до того, как этот
guard успевает отработать) для ЭТОГО конкретного запроса сессионный
юзер лимитируется по IP, а не по username (per-user множитель не
применяется). Не регрессия (IP-лимит применялся бы и раньше до
добавления session-auth такие запросы вообще были 401), просто более
строгий бюджет специфично для session-cookie-запросов; при необходимости
точного per-user квотинга для DB-юзеров переносить резолв сессии выше
RateLimit в app/main.py отдельным issue.
"""
request.scope["headers"] = [
(k, v) for k, v in request.scope.get("headers", []) if k != b"x-authenticated-user"
] + [(b"x-authenticated-user", username.encode("latin-1", "replace"))]
async def rbac_guard(
request: Request,
call_next: Callable[[Request], Awaitable[Response]],
@ -59,40 +167,74 @@ async def rbac_guard(
if path in _PUBLIC_PATHS:
return await call_next(request)
username = request.headers.get("X-Authenticated-User")
if not username:
return JSONResponse(
status_code=401,
content={"detail": "no authenticated user (Caddy basic_auth required)"},
)
username: str | None = None
role: str | None = None
from_session = False
# #2213 defense-in-depth: если общий секрет задан — запрос с X-Authenticated-User
# ОБЯЗАН нести валидный X-Internal-Auth-Secret (его добавляет Caddy из env).
# Иначе это подделка заголовка мимо Caddy (напр. изнутри gendesign_shared) → 401.
# Constant-time compare против timing-атак. Пусто = защита не активна (fail-open).
secret = settings.tradein_internal_auth_secret
if secret:
provided = request.headers.get("X-Internal-Auth-Secret", "")
if not secrets.compare_digest(provided, secret):
logger.warning(
"RBAC: X-Authenticated-User=%r без валидного X-Internal-Auth-Secret "
"на %s — возможная подделка заголовка мимо Caddy",
username,
path,
)
# #2552: session-cookie резолвится ПЕРВЫМ. Если cookie нет вообще —
# request.cookies.get() возвращает None без единого похода в БД (ноль
# side-effects для всего существующего трафика без cookie).
token = request.cookies.get(settings.session_cookie_name)
if token:
session_user: dict[str, Any] | None = None
try:
with SessionLocal() as db:
session_user = get_session_user(db, token)
except Exception:
logger.exception("RBAC: session lookup failed for %s", path)
if session_user is not None:
username = session_user["username"]
role = session_user["role"]
from_session = True
_propagate_authenticated_user(request, username)
if not from_session:
# auth_mode == "db_only" — легаси trusted-header путь ПОЛНОСТЬЮ
# отключён, даже если валидный X-Authenticated-User присутствует.
if settings.auth_mode != "dual":
return JSONResponse(
status_code=401,
content={"detail": "invalid or missing internal auth secret"},
content={"detail": "valid session required"},
)
try:
role = get_role(username)
except KeyError:
logger.warning("RBAC: unknown user %r tried %s", username, path)
return JSONResponse(
status_code=403,
content={"detail": "user not in roles config"},
)
# ---- legacy trusted-header path — BIT-FOR-BIT как было до #2552 ----
username = request.headers.get("X-Authenticated-User")
if not username:
return JSONResponse(
status_code=401,
content={"detail": "no authenticated user (Caddy basic_auth required)"},
)
# #2213 defense-in-depth: если общий секрет задан — запрос с X-Authenticated-User
# ОБЯЗАН нести валидный X-Internal-Auth-Secret (его добавляет Caddy из env).
# Иначе это подделка заголовка мимо Caddy (напр. изнутри gendesign_shared) → 401.
# Constant-time compare против timing-атак. Пусто = защита не активна (fail-open).
secret = settings.tradein_internal_auth_secret
if secret:
provided = request.headers.get("X-Internal-Auth-Secret", "")
if not secrets.compare_digest(provided, secret):
logger.warning(
"RBAC: X-Authenticated-User=%r без валидного X-Internal-Auth-Secret "
"на %s — возможная подделка заголовка мимо Caddy",
username,
path,
)
return JSONResponse(
status_code=401,
content={"detail": "invalid or missing internal auth secret"},
)
try:
role = get_role(username)
except KeyError:
logger.warning("RBAC: unknown user %r tried %s", username, path)
return JSONResponse(
status_code=403,
content={"detail": "user not in roles config"},
)
assert username is not None
assert role is not None
if _ADMIN_API_RE.match(path) and role != "admin":
logger.info("RBAC: blocked %s (role=%s) from %s", username, role, path)
@ -101,25 +243,24 @@ async def rbac_guard(
content={"detail": "admin only"},
)
# #R2-H3: энфорсим roles.yaml scope (paths/deny) для ВСЕХ non-admin путей, а не
# только /admin/*. Иначе revoked (role=expired, paths:[] deny:/**) или узко-
# скоупленный аккаунт достаёт non-admin API (напр. POST /api/v1/search —
# экспорт листингов), который roles.yaml ему запрещает. Bootstrap-пути (/me,
# /brand) исключены выше по списку. roles.yaml globs внешние → восстанавливаем
# внешний путь (Caddy срезал /trade-in). На сбой парса — fail-open + громкий
# лог: не лочим платящего pilot из-за конфиг-бага (admin-гейт выше остаётся).
# #R2-H3: энфорсим scope (paths/deny) для ВСЕХ non-admin путей, а не
# только /admin/*. Bootstrap-пути (/me, /brand) исключены — иначе revoked/
# scope-narrowed юзер не смог бы получить свою роль вовсе.
if not path.startswith(_RBAC_BOOTSTRAP_EXEMPT):
external_path = _EXTERNAL_PREFIX + path
try:
allowed = is_path_allowed(role, external_path)
except Exception:
logger.exception(
"RBAC scope-check raised for %s %s (ext=%s) — fail-open",
username,
path,
external_path,
)
allowed = True
if from_session:
allowed = _db_role_path_allowed(role, external_path)
else:
try:
allowed = is_path_allowed(role, external_path)
except Exception:
logger.exception(
"RBAC scope-check raised for %s %s (ext=%s) — fail-open",
username,
path,
external_path,
)
allowed = True
if not allowed:
logger.info(
"RBAC: scope-blocked %s (role=%s) from %s (ext=%s)",

View file

@ -23,6 +23,7 @@ from sentry_sdk.integrations.starlette import StarletteIntegration
from app.api.v1 import (
admin,
audit,
auth,
brand,
buildings,
geocode,
@ -30,6 +31,7 @@ from app.api.v1 import (
me,
search,
support,
team,
trade_in,
)
from app.core.config import settings
@ -106,6 +108,19 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
".env.runtime ОБОИХ стеков (Caddy главного стека + tradein-backend)"
)
# #2552: session_secret зарезервирован на будущее (напр. подписанные токены) —
# opaque session-токены (secrets.token_urlsafe, см. app.services.auth_session)
# НЕ требуют подписи, их валидность проверяется исключительно наличием строки
# в tradein_sessions + expires_at/is_active. Пустой session_secret НЕ должен
# ронять старт контейнера (не startup-fail) — только громкий WARNING, чтобы
# прод не остался без него незамеченно до момента, когда он реально понадобится.
if not settings.session_secret:
logger.warning(
"SESSION_SECRET пуст — не блокирует старт (opaque session-токены не "
"требуют подписи), но задай его в .env.runtime до появления фич, "
"которым подпись реально нужна"
)
# FDW bootstrap: create/refresh USER MAPPING for gendesign_remote postgres_fdw server.
# Best-effort: failure does not abort startup, just logs.
try:
@ -158,6 +173,7 @@ def health() -> dict[str, str]:
return {"status": "ok", "environment": settings.environment}
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
app.include_router(geocode.router, prefix="/api/v1/geocode", tags=["geocode"])
app.include_router(admin.router, prefix="/api/v1/admin", tags=["admin"])
app.include_router(audit.router, prefix="/api/v1/admin", tags=["admin-audit"])
@ -168,3 +184,4 @@ app.include_router(support.router, prefix="/api/v1/trade-in", tags=["trade-in-su
app.include_router(buildings.router, prefix="/api/v1/buildings", tags=["buildings"])
app.include_router(search.router, prefix="/api/v1", tags=["search"])
app.include_router(me.router, prefix="/api/v1", tags=["me"])
app.include_router(team.router, prefix="/api/v1/team", tags=["team"])

View file

@ -0,0 +1,116 @@
"""Pydantic-схемы team-management API (#2554, эпик #2549).
CRUD управляемых аккаунтов (`tradein_users.role IN ('employee','manager')`
manager'ы доступны только actor'у-admin, см. `app.api.v1.team`), квоты, история
оценок. Org-изоляция (manager видит/меняет только своих employee) реализована в
`app.api.v1.team`, эти схемы только форма запросов/ответов.
"""
from __future__ import annotations
import re
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
# ASCII-only — не-ASCII username ломает downstream identity-пропагацию
# (`app.core.rbac._propagate_authenticated_user` кодирует latin-1 с
# errors="replace"), поэтому валидация формы обязательна на границе API,
# а не только на уровне БД.
#
# `\Z`, НЕ `$` — deep-review seed #2564: в Python `$` матчит перед trailing
# newline (`re.match(r'...\$', 'admin\n')` → True), а Postgres `~` в CHECK
# tradein_users_username_ascii_ck (миграция 193) — False. С `$` строка
# "admin\n" проходила бы Pydantic-валидацию и падала уже в БД → 500 вместо
# честного 422. `\Z` — конец строки БЕЗ поблажки на trailing newline, совпадает
# с семантикой Postgres `~`.
_USERNAME_RE = re.compile(r"^[A-Za-z0-9._-]{3,64}\Z")
class QuotaStatusOut(BaseModel):
"""Статус месячной квоты оценок — вложен в `EmployeeOut`."""
model_config = ConfigDict(from_attributes=True)
limit: int
used: int
remaining: int
unlimited: bool
class EmployeeCreateRequest(BaseModel):
"""`POST /employees` — создать сотрудника. Роль всегда `employee` (не в теле)."""
username: str
password: str
display_name: str | None = None
org_name: str | None = None
email: str | None = None
monthly_limit: int | None = Field(default=None, ge=1)
# Только для actor.role == admin — опциональная привязка к конкретному manager.
# Для actor.role == manager это поле ИГНОРИРУЕТСЯ (принудительно свой id) —
# см. app.api.v1.team.create_employee.
manager_id: int | None = None
@field_validator("username")
@classmethod
def _validate_username(cls, v: str) -> str:
if not _USERNAME_RE.match(v):
raise ValueError(
"username must be 3-64 ASCII chars: letters, digits, dot, underscore, hyphen"
)
return v
class EmployeeUpdateRequest(BaseModel):
"""`PATCH /employees/{id}` — частичное обновление, все поля опциональны."""
is_active: bool | None = None
monthly_limit: int | None = Field(default=None, ge=1)
display_name: str | None = None
org_name: str | None = None
email: str | None = None
new_password: str | None = None
class EmployeeOut(BaseModel):
"""Одна строка в `GET /employees` + ответ `POST`/`PATCH /employees/{id}`."""
model_config = ConfigDict(from_attributes=True)
id: int
username: str
# 'employee' | 'manager' — admin управляет обоими, manager видит только
# employee (см. app.api.v1.team, модульный docstring). Строки role='admin'
# через этот API не отдаются никогда, поэтому в Literal их нет.
role: Literal["employee", "manager"]
display_name: str | None = None
org_name: str | None = None
email: str | None = None
is_active: bool
manager_id: int | None = None
created_at: datetime
quota: QuotaStatusOut
class EmployeeHistoryEntry(BaseModel):
"""Одна строка истории оценок сотрудника — `GET /employees/{id}/history`.
Источник `user_events` (event_type='estimate_request', паттерн
`app.api.v1.audit.account_drilldown`), LEFT JOIN на `trade_in_estimates`
за фактическим результатом (median_price/confidence/n_analogs) join
может не сматчиться (старая запись без estimate_id / оценка insufficient_data),
поэтому все result-поля nullable.
"""
model_config = ConfigDict(from_attributes=True)
estimate_id: str | None = None
address: str | None = None
area_m2: str | None = None
rooms: str | None = None
median_price: int | None = None
confidence: str | None = None
n_analogs: int | None = None
created_at: datetime

View file

@ -27,6 +27,12 @@ class TradeInEstimateInput(BaseModel):
# geocode() (который падает на DaData-формах при мёртвом Yandex-ключе).
lat: float | None = Field(default=None, ge=-90, le=90)
lon: float | None = Field(default=None, ge=-180, le=180)
# #2576: город, если известен фронту (например выбран отдельным полем UI).
# Опционально — без него geocode() внутри estimate_quality() БОЛЬШЕ НЕ
# подставляет "Екатеринбург" молча (см. app.services.geocoder), что раньше
# давало уверенно неверную цену для жителей других городов области (те же
# улица+дом существуют и в ЕКБ, и, например, в Нижнем Тагиле).
city_hint: str | None = Field(default=None, max_length=100)
# ФИАС/ГАР OBJECTGUID целевого дома, если фронт разрешил его через suggest
# (SuggestItem.fias_id у house-level кандидата). Прокидывается в матчер
# (Tier 0.5 fias_exact) ПЕРВЫМ, до fias из DaData /clean. Additive/optional —
@ -185,6 +191,12 @@ class AggregatedEstimate(BaseModel):
target_address: str | None = None # geocoded full address
target_lat: float | None = None
target_lon: float | None = None
# #2576: True если ни адрес, ни `TradeInEstimateInput.city_hint` не называли
# город явно — итоговый город (и, соответственно, набор аналогов/цена)
# определил геокодер-провайдер, а не пользователь. Честный сигнал для
# UI (снизить доверие / переспросить город), НЕ персистится в БД
# (ephemeral, только для текущего POST /estimate ответа).
target_city_ambiguous: bool = False
sources_used: list[str] = Field(default_factory=list) # ['avito', 'cian', 'rosreestr']
data_freshness_minutes: int | None = None # сколько минут назад был самый свежий парсинг
# абсолютный timestamp самого свежего парсинга аналогов

View file

@ -0,0 +1,289 @@
"""Session-сервис для DB-backed auth (#2552, эпик #2549 — auth-core).
Схема: `tradein_users` + `tradein_sessions` (migration `192_tradein_users_auth.sql`).
Опаковые (`secrets.token_urlsafe`) токены-сессии не JWT, не подписаны: валидность
проверяется исключительно наличием + `expires_at`/`is_active` строкой в БД, поэтому
`SESSION_SECRET` НЕ обязателен для работы этого модуля (зарезервирован на будущее,
см. `app.core.config.Settings.session_secret` docstring).
Все функции здесь принимают уже открытую `db: Session` сами НЕ открывают
`SessionLocal()` (вызывающая сторона решает время жизни транзакции: `rbac_guard`
и `app.core.db.get_db()`-роуты открывают её по-разному). Это делает модуль
тривиально unit-тестируемым без патчинга `SessionLocal` тесты просто передают
fake/real `Session`.
Ни одна функция не должна ронять вызывающий HTTP-запрос: DB-ошибки логируются
через `logger` вызывающей стороной (см. `app.core.rbac.rbac_guard`,
`app.api.v1.me`), сам сервис поднимает исключения как есть (это НЕ fire-and-forget
аудит-лог вроде `app.services.user_events`, а часть auth-decision сбой обязан
быть виден вызывающему, чтобы тот мог fail-closed).
"""
from __future__ import annotations
import logging
import secrets
from datetime import UTC, datetime, timedelta
from typing import Any
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import settings
logger = logging.getLogger(__name__)
# Sliding-window refresh: last_seen_at/expires_at продлеваются НЕ чаще раза в
# 5 минут — иначе каждый API-запрос авторизованного юзера бил бы в БД лишним
# UPDATE (RBAC гоняет get_session_user на КАЖДЫЙ non-public запрос).
_SLIDING_REFRESH_INTERVAL = timedelta(minutes=5)
_TOKEN_BYTES = 32 # secrets.token_urlsafe(32) — 256 бит энтропии, ~43 символа
def create_session(
db: Session,
user_id: int,
ip: str | None = None,
user_agent: str | None = None,
) -> str:
"""Создаёт новую сессию для *user_id* и возвращает opaque-токен.
`expires_at = now() + settings.session_ttl_hours`. Коммитит сам (self-contained,
как `app.services.user_events.record_event`).
"""
token = secrets.token_urlsafe(_TOKEN_BYTES)
db.execute(
text(
"""
INSERT INTO tradein_sessions (token, user_id, expires_at, ip_address, user_agent)
VALUES (
:token, :user_id,
now() + make_interval(hours => CAST(:ttl_hours AS integer)),
CAST(:ip AS inet), :user_agent
)
"""
),
{
"token": token,
"user_id": user_id,
"ttl_hours": settings.session_ttl_hours,
"ip": ip,
"user_agent": user_agent,
},
)
db.commit()
return token
def get_session_user(db: Session, token: str) -> dict[str, Any] | None:
"""Резолвит сессионный токен в данные юзера, или None если сессия
невалидна (не найдена / истекла / юзер деактивирован).
Sliding refresh: если с последнего `last_seen_at` прошло >=5 минут
продлевает `expires_at`/`last_seen_at` ОДНИМ UPDATE. Сбой refresh
(напр. read-replica) логируется и НЕ мешает вернуть валидного юзера
это best-effort продление, а не часть решения "валидна ли сессия".
"""
if not token:
return None
row = db.execute(
text(
"""
SELECT s.user_id, s.expires_at, s.last_seen_at,
u.username, u.role, u.display_name, u.org_name, u.email, u.is_active
FROM tradein_sessions s
JOIN tradein_users u ON u.id = s.user_id
WHERE s.token = :token
"""
),
{"token": token},
).fetchone()
if row is None:
return None
now = datetime.now(UTC)
if row.expires_at is None or row.expires_at <= now:
return None
if not row.is_active:
return None
if row.last_seen_at is None or (now - row.last_seen_at) >= _SLIDING_REFRESH_INTERVAL:
try:
db.execute(
text(
"""
UPDATE tradein_sessions
SET last_seen_at = now(),
expires_at = now() + make_interval(hours => CAST(:ttl_hours AS integer))
WHERE token = :token
"""
),
{"ttl_hours": settings.session_ttl_hours, "token": token},
)
db.commit()
except Exception:
logger.warning(
"auth_session: sliding refresh failed for user_id=%r", row.user_id, exc_info=True
)
db.rollback()
return {
"user_id": row.user_id,
"username": row.username,
"role": row.role,
"display_name": row.display_name,
"org_name": row.org_name,
"email": row.email,
"is_active": row.is_active,
}
def get_user_by_username(db: Session, username: str) -> dict[str, Any] | None:
"""Возвращает строку `tradein_users` по username, или None если не найден.
Используется login-флоу (`app.api.v1.auth.login`) для password-проверки.
Отдаёт `password_hash` как есть (может быть NULL переходный период,
см. migration 192 docstring) вызывающая сторона решает, что с ним делать.
"""
row = db.execute(
text(
"""
SELECT id, username, password_hash, role, is_active,
display_name, org_name, email
FROM tradein_users
WHERE username = :username
"""
),
{"username": username},
).fetchone()
if row is None:
return None
return {
"user_id": row.id,
"username": row.username,
"password_hash": row.password_hash,
"role": row.role,
"is_active": row.is_active,
"display_name": row.display_name,
"org_name": row.org_name,
"email": row.email,
}
def revoke_session(db: Session, token: str) -> None:
"""Удаляет одну сессию по токену (logout). No-op если токен не найден."""
db.execute(text("DELETE FROM tradein_sessions WHERE token = :token"), {"token": token})
db.commit()
def revoke_user_sessions(db: Session, user_id: int) -> None:
"""Удаляет ВСЕ сессии юзера (напр. смена пароля / принудительный logout всех
устройств не используется этим PR напрямую, задел для будущих admin-действий)."""
db.execute(text("DELETE FROM tradein_sessions WHERE user_id = :user_id"), {"user_id": user_id})
db.commit()
# ---------------------------------------------------------------------------
# DB-role → RBAC scope (paths/deny) — #2552 dual-mode.
# ---------------------------------------------------------------------------
#
# tradein_users.role ('admin'|'manager'|'employee', CHECK-констрейнт migration 192)
# НЕ являются ключами auth/roles.yaml (тот файл — legacy Caddy trusted-header путь,
# который этот эпик намеренно не трогает). Маппинг ниже даёт DB-ролям тот же
# paths/deny-смысл, что и legacy-ролям, БЕЗ правки roles.yaml:
# employee -> клиентский доступ: весь /trade-in/** МИНУС внутренние разделы
# (см. deny ниже — раньше было «ровно как legacy pilot»).
# manager -> employee + /api/v1/team/** (дашборд команды, #2556).
# admin -> полный доступ, как legacy admin.
#
# Почему «Доля в продаже» и «Кэш» в deny у ОБЕИХ клиентских ролей (2026-07-31,
# решение владельца продукта): это внутренние инструменты, а не продукт клиента.
# «Доля в продаже» — аналитика рынка (сколько квартир дома выставлено, срез по
# домам/ЖК), «Кэш» — состояние кэшей и скраперов. Клиентские аккаунты видеть их
# не должны; триггер — аккаунт praktika (DB-роль manager), у которого оба пункта
# висели в топбаре на /trade-in/team.
#
# Почему в deny И страницы (/trade-in/sale-share, /trade-in/cache), И их API
# (/trade-in/api/v1/buildings/**, /trade-in/api/v1/trade-in/cache-stats/**): один
# deny-список гейтит СРАЗУ ТРИ места, потому что все трое сверяются с ним через
# один и тот же матчер —
# 1) пункт меню: Topbar фильтрует NAV_ITEMS по scopePath из /me;
# 2) сама страница: RouteGuard проверяет абсолютный путь из /me;
# 3) серверные ручки: app.core.rbac.rbac_guard (deny проверяется ПЕРВЫМ,
# внешний путь реконструируется как '/trade-in' + path).
# Только страницы = пункт исчез, но прямой URL и API остались открыты; только
# API = мёртвый пункт меню с 403 на каждый фетч.
#
# Почему '/trade-in/api/v1/buildings/**' безопасно закрывать целиком: весь
# роутер app/api/v1/buildings.py обслуживает ТОЛЬКО раздел sale-share
# (/sale-share, /sale-share/summary, /{house_id}/listings). Экран оценки его не
# использует — секция «Продажи в доме» питается estimate-хендлерами
# (useEstimatePlacementHistory / useSalesVsListings), а BuildingListingsDrawer
# импортируется единственной страницей app/sale-share/page.tsx.
#
# NB (границы глоба): '<prefix>/**' компилируется в '^<prefix>(?:/.*)?$' — матчит
# сам prefix, его же с трейлинг-слэшем и подпути через '/', но НЕ соседей по
# префиксу (см. app.core.rbac._db_glob_match и app.core.auth._glob_to_regex).
# Поэтому '/trade-in/cache/**' не задевает '/trade-in/cache-stats', а
# '/trade-in/api/v1/trade-in/cache-stats/**' — не '/…/cache-statistics'.
#
# Почему у cache-stats ГЛОБ, а не «более точный» '/trade-in/api/v1/trade-in/
# cache-stats': точный паттерн — это строгое равенство, и его обходит обычный
# трейлинг-слэш (измерено: '…/cache-stats/' → allowed=True). Сегодня от этого
# спасает только Starlette redirect_slashes (307 на путь без слэша → там уже
# 403), т.е. защита держалась бы на роутере, а не на RBAC — достаточно
# выключить redirect_slashes или сменить роутер, и deny тихо перестанет
# работать. Глоб закрывает и сам путь, и слэш, и любые будущие подпути.
# НЕ «уточнять» обратно до точного пути.
#
# NB (ограничение мини-матчера — читать перед копированием паттернов):
# DB_ROLE_PATHS и pilot.deny в auth/roles.yaml — зеркала по СМЫСЛУ, но матчеры
# у них РАЗНЫЕ. app.core.rbac._db_glob_match понимает ТОЛЬКО три формы:
# '/**' | '<prefix>/**' | точный путь (строгое равенство).
# app.core.auth._glob_to_regex (roles.yaml) понимает сверх этого ещё одиночную
# '*' ('/foo/*' = один сегмент). Паттерн с одиночной '*', скопированный сюда из
# roles.yaml, станет ЛИТЕРАЛЬНОЙ строкой и МОЛЧА перестанет что-либо запрещать —
# без ошибки на импорте и без падения тестов, если на него нет прямого теста.
# Т.е. в DB_ROLE_PATHS допустимы только '/**', '<prefix>/**' и точный путь;
# одиночная '*' здесь = silent no-op.
DB_ROLE_PATHS: dict[str, tuple[list[str], list[str]]] = {
"employee": (
["/trade-in/**", "/trade-in/api/v1/**"],
[
"/admin/**",
"/api/v1/admin/**",
"/trade-in/api/v1/admin/**",
"/trade-in/sale-share/**",
"/trade-in/cache/**",
"/trade-in/api/v1/buildings/**",
"/trade-in/api/v1/trade-in/cache-stats/**",
],
),
"manager": (
["/trade-in/**", "/trade-in/api/v1/**", "/api/v1/team/**"],
[
"/admin/**",
"/api/v1/admin/**",
"/trade-in/api/v1/admin/**",
"/trade-in/sale-share/**",
"/trade-in/cache/**",
"/trade-in/api/v1/buildings/**",
"/trade-in/api/v1/trade-in/cache-stats/**",
],
),
"admin": (["/**"], []),
}
def get_db_role_scope(role: str) -> tuple[list[str], list[str]]:
"""Возвращает (allowed_paths, deny_paths) для DB-роли.
Неизвестная роль (не должно случиться CHECK-констрейнт на колонке
ограничивает role тремя значениями) -> fail-closed (пустой allow, deny всё).
"""
return DB_ROLE_PATHS.get(role, ([], ["/**"]))

View file

@ -1622,6 +1622,20 @@ def _normalize_building_key(
(корпус) схлопываются к base (тот же дом). Литеры РАЗНЫЕ дома (204г 204д).
- street_core прогоняется через _STREET_ALIAS_MAP (ткачеваткачей).
#2581: город/рНАМЕРЕННО дропается (не возвращается в ключе) — это
единственное, что делает «Ткачёва 13»-вариант с городом и без города давать
один ключ (см. test_normalize_tkachei13_all_db_variants_same_key). Городской
токен как 4-й элемент ключа НЕ добавлен: (а) часть источников (Avito
anonymous-адреса) вообще не несёт городской токен в тексте ключ с
обязательным городом сломал бы их матчинг; (б) написание города варьируется
(ЕКБ/Екатеринбург/г. Екатеринбург) ещё один normalization-слой, дающий
те же false-negative риски, которые уже решает `_CITY_TOKENS`-дропинг.
Кросс-городская коллизия (одноимённая улица+дом в разных городах области)
закрыта на SQL-уровне через ST_DWithin от subject-координат см. Tier A
в `_fetch_anchor_comps` (#2581) и Tier S в `_fetch_analogs` (#oblast-D,
f9ae6f0c) геопредикат надёжнее строкового city-токена и не ломает то,
что уже нормализуется здесь.
Returns (street_core, base_no, letter) любой элемент None если не извлёкся.
Best-effort: при пустом адресе (None, None, None).
"""
@ -1722,6 +1736,18 @@ def _anchor_comp_from_row(r: Any) -> dict[str, Any]:
}
# #2581: Tier A ("same building") ST_DWithin safety-radius. Reuses the SAME
# city-scale DEFAULT_RADIUS_M already used by Tier S's mirrored geo-bound fix
# (f9ae6f0c, #oblast-D). The address-string match (_normalize_building_key +
# _house_boundary_regex) already establishes "same street + house number" —
# this radius only needs to reject GENUINELY cross-city collisions (e.g.
# «улица Ленина» exists in both Екатеринбург AND Серов/Нижний Тагил, 150+ km
# apart) — it does not need to be building-tight like Tier C's 500m
# micro-radius (that tier's precision comes from proximity alone, without a
# street/house string match to lean on).
ANCHOR_TIER_A_RADIUS_M = DEFAULT_RADIUS_M
def _fetch_anchor_comps(
db: Session,
*,
@ -1734,9 +1760,19 @@ def _fetch_anchor_comps(
) -> tuple[list[dict[str, Any]], str | None]:
"""Тированный набор комплов для same-building якоря. Стоп на 1-м тире с ≥ min_comps.
Tier A SAME BUILDING: normalized street + base house no (+ литера если есть).
RELAXED rooms (без фильтра), БЕЗ area±15%. Не группируем по house_id_fk
один дом дробится на несколько fk (Хохрякова 48 = 7085/9878/12797).
Tier A SAME BUILDING: normalized street + base house no (+ литера если есть)
+ ST_DWithin(ANCHOR_TIER_A_RADIUS_M) от subject lat/lon (#2581 — до этого
SQL не имел ГЕО-предиката вовсе, и одноимённая улица+дом в ДРУГОМ городе
(область 368 городов, «Ленина»/«Мира»/... повторяются) молча матчила
ЕКБ-листинги для областного subject'а — ~40 191 из ~40 200 активных
листингов ЕКБ, distance неизвестна без фильтра). RELAXED rooms (без
фильтра), БЕЗ area±15%. Не группируем по house_id_fk один дом дробится
на несколько fk (Хохрякова 48 = 7085/9878/12797); ST_DWithin тот же
компромисс, что и Tier S ниже (см. _fetch_analogs), не house_id_fk.
lat/lon subject'а обязательны (гейт как у Tier C) — без них геопредикат
невозможен, и Tier A целиком пропускается (в проде geo ВСЕГДА есть
estimate_quality возвращает _empty_estimate раньше при неудачном
geocode, см. `if geo is None`).
Tier C micro-radius 500m (ST_DWithin) + вторичка-канон guard (#1186): NULL = legacy
вторичка + rooms match + area±25%. (Tier B «тот же ЖК» skip: complex_id/cian_zhk_url
ненадёжны.)
@ -1752,7 +1788,7 @@ def _fetch_anchor_comps(
# ── Tier A: same building ────────────────────────────────────────────────
street, base_no, letter = _normalize_building_key(address)
if street and base_no is not None:
if street and base_no is not None and lat is not None and lon is not None:
# ё→е в SQL для symmetry с нормализатором. psycopg v3: bind через :param,
# оператор ~. Boundary-regex вынесен в _house_boundary_regex (общий с
# Tier S radius-fallback ниже, см. _fetch_analogs).
@ -1771,11 +1807,20 @@ def _fetch_anchor_comps(
AND price_per_m2 > 0
AND lower(translate(address, 'ёЁ', 'ее')) LIKE :street_like
AND lower(translate(address, 'ёЁ', 'ее')) ~ :house_re
AND geom IS NOT NULL
AND ST_DWithin(
geom::geography,
ST_MakePoint(:lon, :lat)::geography,
:radius
)
"""
),
{
"street_like": "%" + street + "%",
"house_re": house_re,
"lon": lon,
"lat": lat,
"radius": ANCHOR_TIER_A_RADIUS_M,
},
)
.mappings()
@ -1900,12 +1945,17 @@ def _band_haircut(anchor_ppm2: float) -> float:
LOW audit #3: 0.04/0.07 (и mid из settings.asking_to_sold_haircut) —
EKB-secondary-market calibration constants, но применяются ENGINE-WIDE (нет
city-параметра ни здесь, ни у единственного вызывающего
`_compute_same_building_anchor`). Реального импакта на не-ЕКБ область пока нет
(same-building anchor pool для oblast сейчас не формируется anchor_ppm2 сюда
просто не доходит), но это доверие к отсутствию данных, а не к дизайну. Как
только oblast anchor pools появятся (см. #oblast-D fallback выше), эти пороги
нужно пересмотреть/сделать per-city не оставлять ЕКБ-калибровку по умолчанию
для другого рынка. No behavior change here (doc-only).
`_compute_same_building_anchor`). #2581 update: та формулировка была НЕВЕРНОЙ —
same-building anchor pool для oblast ВСЕГДА мог сформироваться (Tier A до
#2581 не имел гео-предиката вообще, поэтому for oblast-subject'ов он либо
молча тянул ЕКБ-листинги по одноимённой улице/дому, либо для действительно
уникальных названий честно матчил местные листинги, если они были). После
#2581 (ST_DWithin(ANCHOR_TIER_A_RADIUS_M) на Tier A) anchor_ppm2 ДЛЯ ОБЛАСТИ
доходит сюда легитимно (местные комплы того же дома в Серове/Тагиле/etc, если
они есть в БД), но всё ещё через ЕКБ-калиброванный haircut эти пороги
по-прежнему стоит пересмотреть/сделать per-city, не оставлять ЕКБ-калибровку
по умолчанию для другого рынка. No behavior change here (doc-only, кроме
исправления ложной посылки).
"""
if anchor_ppm2 >= 350_000:
return 0.04
@ -3151,12 +3201,13 @@ async def estimate_quality(
Returns:
AggregatedEstimate с estimate_id, медианой, диапазоном, аналогами.
"""
# 1. Geocode (#654: time-budgeted — Yandex/Nominatim retry chain can stack
# 1. Geocode (#654: time-budgeted — Nominatim retry chain can stack
# multiple network round-trips + 1s Nominatim rate-limit sleeps).
geo: GeocodeResult | None = None
# Variant A: trust client-provided coords (resolved by autocomplete/map) when present
# and inside the oblast bbox — skips the geocode() chain that fails on DaData-format
# addresses with the Yandex key dead. Out-of-bbox / partial → ignore, geocode normally.
# addresses (#2593: Yandex Geocoder, the previous fallback for those, removed).
# Out-of-bbox / partial → ignore, geocode normally.
# (oblast C2): was tight EKB-only bbox (60.40-60.85 / 56.65-56.95) — widened to
# geocoder.is_within_oblast66_bbox (region 66) so client-coords from oblast towns also
# get this perf fast-path instead of always paying the geocode() round-trip. Perf-only,
@ -3180,8 +3231,12 @@ async def estimate_quality(
payload.lon,
)
if geo is None and payload.address:
# #2576: city_hint прокидывается из payload — БЕЗ него geocode() больше не
# подставляет "Екатеринбург" молча (см. app.services.geocoder). Опционально:
# фронт пока (до отдельного изменения UI) его не шлёт, geo.city_ambiguous
# честно сигнализирует об этом ниже.
geo = await _with_budget(
geocode(payload.address, db),
geocode(payload.address, db, city_hint=payload.city_hint),
settings.estimate_geocode_budget_s,
label="geocode",
)
@ -3880,6 +3935,7 @@ async def estimate_quality(
target_address=geo.full_address,
target_lat=geo.lat,
target_lon=geo.lon,
target_city_ambiguous=geo.city_ambiguous,
sources_used=sources_used,
data_freshness_minutes=freshness_min,
last_scraped_at=last_scraped_at,

File diff suppressed because it is too large Load diff

View file

@ -113,6 +113,16 @@ async def _job_asking_to_sold_ratio(
await loop.run_in_executor(None, recompute_asking_to_sold_ratios, db, run_id)
# ── deal_city_price_bands_refresh — sync tier-aware re-derive в executor ──────
async def _job_deal_city_price_bands_refresh(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
from app.tasks.deal_city_price_bands_refresh import refresh_deal_city_price_bands
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, refresh_deal_city_price_bands, db, run_id)
# ── refresh_search_matview — REFRESH MATVIEW CONCURRENTLY (own connection) ────
async def _job_refresh_search_matview(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
@ -382,7 +392,7 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]:
"""Реестр НЕ-sweep продуктовых source→Handler для kit build_registry.
Kit-native sweeps (avito/yandex/cian/domclick city/full-load/newbuilding) НЕ здесь
их даёт build_registry(_default_kit_handlers). Здесь 18 именованных + 1 wildcard
их даёт build_registry(_default_kit_handlers). Здесь 19 именованных + 1 wildcard
(deactivate_stale_*), покрывающие каждый НЕ-sweep source боевого scheduler-dispatch.
`ctx` принят для симметрии контракта; сами Handler-job'ы получают ctx во время
@ -399,6 +409,9 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]:
"asking_to_sold_ratio_refresh": Handler(
_job_asking_to_sold_ratio, "asking_to_sold_ratio_refresh"
),
"deal_city_price_bands_refresh": Handler(
_job_deal_city_price_bands_refresh, "deal_city_price_bands_refresh"
),
"refresh_search_matview": Handler(_job_refresh_search_matview, "refresh_search_matview"),
"yandex_address_backfill": Handler(_job_yandex_address_backfill, "yandex_address_backfill"),
"sber_index_pull": Handler(_job_sber_index_pull, "sber_index_pull"),

View file

@ -30,6 +30,7 @@ from scraper_kit.avito_exceptions import (
AvitoRateLimitedError,
)
from scraper_kit.browser_fetcher import BrowserFetcher
from scraper_kit.orchestration.pipeline import CITY_LOCATIONS
# #2397 slice B (эпик #2277 decommission scrape_pipeline.py, Part E): раньше
# _CHROME_HEADERS/_avito_proxies() импортировались из app.services.scrape_pipeline.
@ -70,6 +71,31 @@ __all__ = [
"run_avito_detail_backfill",
]
# #2576 этап B: oblast-города (region 66, вне ЕКБ) уже дают листинги (Каменск-
# Уральский), но snapshot-SELECT ниже раньше фильтровал ЖЁСТКО '%/ekaterinburg/%' —
# у всех остальных detail_enriched_at оставался NULL навсегда (без detail-страницы
# нет lat/lon -> листинг молча выпадает из подбора аналогов по радиусу).
# CITY_LOCATIONS.avito_slug — единственный источник правды для avito URL-слага
# города (может отличаться от нашего city_slug: kamensk-uralskiy через дефис,
# verhnyaya_pyshma без "kh") -- дублировать список тут вместо импорта было бы
# risk дрейфа при добавлении новых oblast-городов.
#
# #2578 review: Postgres LIKE трактует '_' как wildcard "один любой символ" (не
# литерал) и '%' как wildcard "любая последовательность" -- два слага из пяти
# (nizhniy_tagil, verhnyaya_pyshma) содержат '_', без экранирования это латентная
# дыра: город с похожим слагом (напр. nizhniyXtagil) молча совпал бы. Сегодня
# коллизий нет (проверено на проде: raw vs escaped паттерны дают одинаковые 776
# совпадений), но экранируем сейчас, а не когда появится реальная коллизия.
# LIKE по умолчанию использует '\' как escape-символ (без явного ESCAPE) —
# подтверждено на живом Postgres 16.4 (см. коммит #2578-fixup): 'nizhniyXtagil'
# матчит неэкранированный '%/nizhniy_tagil/%' (LIKE default '_'=wildcard) и НЕ
# матчит экранированный '%/nizhniy\_tagil/%' (LIKE '\_' = литерал '_'); точный
# слаг 'nizhniy_tagil' матчит оба варианта -- позитивный кейс не сломан.
_OBLAST_AVITO_URL_PATTERNS = tuple(
"%/" + loc.avito_slug.replace("\\", "\\\\").replace("_", "\\_").replace("%", "\\%") + "/%"
for loc in CITY_LOCATIONS.values()
)
@dataclass
class AvitoDetailBackfillResult:
@ -102,7 +128,12 @@ async def run_avito_detail_backfill(
"""Backfill detail_enriched_at for legacy avito listings via mobile proxy.
Params (from default_params jsonb in scrape_schedules):
batch_size: int -- snapshot size (SELECT LIMIT), default 800.
batch_size: int -- ЕКБ snapshot size (SELECT LIMIT), default 800 (unchanged,
#2576 -- volume/order for ЕКБ stay byte-identical to pre-oblast behaviour).
oblast_batch_size: int -- ДОПОЛНИТЕЛЬНАЯ reserved-квота для листингов
области (#2576), default 100. Отдельный LIMIT, НЕ отъедает от batch_size
ЕКБ -- гарантирует области честную обработку и одновременно не даёт
всплеску свежих oblast-листингов вытеснить ЕКБ из top-N по scraped_at.
budget_sec: float -- wall-clock budget per run, default 3600s.
request_delay_sec: float -- delay between listings, default 6.0s.
max_consecutive_blocks: int -- abort threshold, default 5.
@ -111,6 +142,7 @@ async def run_avito_detail_backfill(
mark_done (incl. partial/block-abort) / mark_failed (exception only).
"""
batch_size = int(params.get("batch_size", 800))
oblast_batch_size = int(params.get("oblast_batch_size", 100))
budget_sec = float(params.get("budget_sec", 3600))
request_delay_sec = float(params.get("request_delay_sec", 6.0))
max_consecutive_blocks = int(params.get("max_consecutive_blocks", 5))
@ -189,30 +221,59 @@ async def run_avito_detail_backfill(
runs_mod.update_heartbeat(db, run_id, current_counters)
# SNAPSHOT: single SELECT at start -- NOT re-selected in loop.
# Scope (#1814): только активные ЕКБ-листинги. region_code на insert
# хардкодится в 66 (base.py) → НЕ дискриминирует legacy не-ЕКБ; реальный
# признак региона у Avito — путь URL (/ekaterinburg/ для ЕКБ; legacy
# Москва/СПб/Тюмень — /moskva//sankt-peterburg//tyumen/). browser-fetch
# на legacy не-ЕКБ спотыкается → curl-fallback → 429-бан curl-фингерпринта.
# Не тратим фетчи на мёртвые (is_active) и не-ЕКБ.
# Scope (#1814, расширено #2576): активные листинги ЕКБ + известных oblast-
# городов (region 66). region_code на insert хардкодится в 66 (base.py) →
# НЕ дискриминирует город; реальный признак города у Avito — путь URL
# (/ekaterinburg/ для ЕКБ; legacy Москва/СПб/Тюмень — /moskva//sankt-
# peterburg//tyumen/ — те по-прежнему вне scope, НЕ входят ни в ekb, ни в
# oblast CTE). browser-fetch на legacy не-ЕКБ/не-oblast спотыкается →
# curl-fallback → 429-бан curl-фингерпринта. Не тратим фетчи на мёртвые
# (is_active) и на регионы вне scope.
#
# Два CTE вместо одного WHERE ... OR ...: ekb сохраняет ТОЧНО прежний
# LIMIT/ORDER (#2576 требование "ЕКБ не деградирует") -- oblast НЕ может
# вытеснить ЕКБ из batch_size ни при каком всплеске свежих oblast-строк
# (ORDER BY ... scraped_at DESC в общем WHERE отдал бы приоритет самым
# свежим независимо от города). oblast получает отдельную честную квоту
# oblast_batch_size, добавленную ПОСЛЕ ekb-квоты (не вычтенную из неё).
snapshot = (
db.execute(
text(
"""
SELECT id, source_url
FROM listings
WHERE source = 'avito'
AND detail_enriched_at IS NULL
AND source_url IS NOT NULL
AND is_active = TRUE
AND source_url LIKE '%/ekaterinburg/%'
-- сперва листинги без координат (#1967 — detail-страница даёт
-- координаты здания), затем по свежести
ORDER BY (lat IS NULL) DESC, scraped_at DESC NULLS LAST
LIMIT CAST(:batch_size AS int)
WITH ekb AS (
SELECT id, source_url, 'ekb' AS city_scope
FROM listings
WHERE source = 'avito'
AND detail_enriched_at IS NULL
AND source_url IS NOT NULL
AND is_active = TRUE
AND source_url LIKE '%/ekaterinburg/%'
-- сперва листинги без координат (#1967 — detail-страница
-- даёт координаты здания), затем по свежести
ORDER BY (lat IS NULL) DESC, scraped_at DESC NULLS LAST
LIMIT CAST(:batch_size AS int)
),
oblast AS (
SELECT id, source_url, 'oblast' AS city_scope
FROM listings
WHERE source = 'avito'
AND detail_enriched_at IS NULL
AND source_url IS NOT NULL
AND is_active = TRUE
AND source_url LIKE ANY(CAST(:oblast_patterns AS text[]))
ORDER BY (lat IS NULL) DESC, scraped_at DESC NULLS LAST
LIMIT CAST(:oblast_batch_size AS int)
)
SELECT id, source_url, city_scope FROM ekb
UNION ALL
SELECT id, source_url, city_scope FROM oblast
"""
),
{"batch_size": batch_size},
{
"batch_size": batch_size,
"oblast_patterns": list(_OBLAST_AVITO_URL_PATTERNS),
"oblast_batch_size": oblast_batch_size,
},
)
.mappings()
.all()
@ -227,11 +288,16 @@ async def run_avito_detail_backfill(
runs_mod.mark_done(db, run_id, current_counters)
return counters
# #2576: разбивка ekb/oblast только для наблюдаемости -- .get() консервативен
# (city_scope нет в mock-снапшотах старых тестов, дефолт "ekb" их не ломает).
oblast_count = sum(1 for row in snapshot if row.get("city_scope") == "oblast")
logger.info(
"avito_detail_backfill: run_id=%d snapshot=%d (budget=%.0fs "
"delay=%.1fs max_blocks=%d mode=%s)",
"avito_detail_backfill: run_id=%d snapshot=%d (ekb=%d oblast=%d, "
"budget=%.0fs delay=%.1fs max_blocks=%d mode=%s)",
run_id,
len(snapshot),
len(snapshot) - oblast_count,
oblast_count,
budget_sec,
request_delay_sec,
max_consecutive_blocks,

View file

@ -10,6 +10,20 @@
Парсинг адреса _parse_street_house из app.services.geocoder (готовый парсер),
работающий с формами «г. Екатеринбург, ул. Малышева, 30, кв. 28».
Городской гейт (#2583, находка H3): в `listings` НЕТ отдельной колонки города — город
известен только из текста адреса. `ekb_geoportal_buildings` EKB-only реестр: улица+дом
могут буквально совпасть между Екатеринбургом и другим городом области (например,
«проспект Ленина 1» есть и в ЕКБ, и в Нижнем Тагиле). Без проверки города такой листинг
получает екатеринбургские координаты, хотя находится в другом городе. Перед вызовом
_geoportal_house_match каждый адрес проверяется через _names_non_ekb_city (та же функция,
что гейтит EKB-only тиры внутри geocoder.geocode()) адрес, явно называющий другой город
региона, пропускается (counted как skipped_non_ekb) и остаётся lat IS NULL для
geocode_missing_listings (oblast-aware Nominatim/Yandex, окно 06:00-09:00 UTC).
Прямой вызов _geoportal_house_match (а не полноценный geocode()) оставлен намеренно
это pure local-DB матч без единого внешнего HTTP-запроса; полноценный geocode() на каждый
non-EKB адрес добавил бы Nominatim/Yandex вызов на весь backlog (сотни-тысячи строк за
ночь) лишняя нагрузка на и так ограниченный Nominatim (Yandex сейчас 403, #2585).
Запуск:
python -m app.tasks.backfill_listings_coords_geoportal
python -m app.tasks.backfill_listings_coords_geoportal --limit 5000 --batch-size 200
@ -19,7 +33,17 @@ migration 171) — run_geoportal_coords_backfill(). Local exact match, ника
HTTP/rate-limit, поэтому окно ставится ПЕРЕД geocode_missing_listings (Nominatim/Yandex,
coarse city-centroid fallback): точный house-level матч должен получить шанс первым,
иначе Nominatim успевает проставить грубые coords и адрес выпадает из WHERE lat IS NULL
(#1967 — было единичным manual-прогоном #1841, здесь становится recurring).
(#1967 — было единичным manual-прогоном #1841, здесь становится recurring). С городским
гейтом (#2583) порядок окон остаётся корректным: не-ЕКБ адреса больше не матчатся здесь
вообще, поэтому «победа в гонке» больше не портит их координаты они просто ждут
geocode_missing_listings в следующем окне, как и раньше для адресов без geoportal-матча.
geo_precision: этот тир всегда даёт house-level точный матч (не city-centroid), поэтому
_update_listing_coords НЕ проставляет geo_precision он остаётся NULL, что в текущей
конвенции (089_listings_geo_precision.sql, geocode_missing.py) означает «не coarse»
(тот же смысл, что и geo_precision=None для precise-адресов в geocode_missing_listings).
Downstream-фильтры (`geo_precision IS DISTINCT FROM 'city'`) корректно НЕ исключают такие
строки исключать нужно только 'city'-fallback, а не «пока не размечено».
Идемпотентность: UPDATE применяется только к строкам с lat IS NULL (WHERE id=:id AND lat IS NULL).
Повторный прогон не затирает уже проставленные координаты.
@ -37,7 +61,7 @@ from sqlalchemy.orm import Session
from app.core.db import SessionLocal
from app.services import scrape_runs as runs_mod
from app.services.geocoder import _geoportal_house_match, _parse_street_house
from app.services.geocoder import _geoportal_house_match, _names_non_ekb_city, _parse_street_house
logger = logging.getLogger(__name__)
@ -55,6 +79,7 @@ class BackfillCoordsResult:
updated: int = 0 # реально обновлено (UPDATE rowcount)
no_address: int = 0 # listing.address IS NULL / не распарсился
no_match: int = 0 # адрес распарсился, но в реестре здания нет
skipped_non_ekb: int = 0 # адрес явно называет другой город области (#2583 гейт)
errors: int = 0 # исключения при обработке отдельной записи
duration_sec: float = field(default=0.0)
@ -65,6 +90,7 @@ class BackfillCoordsResult:
"updated": self.updated,
"no_address": self.no_address,
"no_match": self.no_match,
"skipped_non_ekb": self.skipped_non_ekb,
"errors": self.errors,
"duration_sec": int(self.duration_sec),
}
@ -184,6 +210,14 @@ def backfill_coords_from_geoportal(
res.no_address += 1
continue
# Городской гейт (#2583, H3) — ekb_geoportal_buildings EKB-only,
# улица+дом могут совпасть с другим городом области. Адрес, явно
# называющий другой город региона, пропускаем — остаётся
# lat IS NULL для oblast-aware geocode_missing_listings.
if _names_non_ekb_city(address):
res.skipped_non_ekb += 1
continue
# Парсинг адреса — переиспользуем парсер geocoder'а
parsed = _parse_street_house(address)
if parsed is None:
@ -259,12 +293,13 @@ def backfill_coords_from_geoportal(
logger.info(
"backfill_coords: DONE — candidates=%d matched=%d updated=%d "
"no_address=%d no_match=%d errors=%d duration=%.1fs",
"no_address=%d no_match=%d skipped_non_ekb=%d errors=%d duration=%.1fs",
res.candidates,
res.matched,
res.updated,
res.no_address,
res.no_match,
res.skipped_non_ekb,
res.errors,
res.duration_sec,
)
@ -314,13 +349,14 @@ def run_geoportal_coords_backfill(
runs_mod.mark_done(db, run_id, counters)
logger.info(
"run_geoportal_coords_backfill: run_id=%d DONE candidates=%d matched=%d "
"updated=%d no_address=%d no_match=%d errors=%d duration=%.1fs",
"updated=%d no_address=%d no_match=%d skipped_non_ekb=%d errors=%d duration=%.1fs",
run_id,
res.candidates,
res.matched,
res.updated,
res.no_address,
res.no_match,
res.skipped_non_ekb,
res.errors,
res.duration_sec,
)
@ -376,12 +412,13 @@ def main() -> None:
logger.info(
"Готово: кандидатов=%d сматчено=%d обновлено=%d "
"без_адреса=%d без_матча=%d ошибок=%d время=%.1fs",
"без_адреса=%d без_матча=%d не_ЕКБ=%d ошибок=%d время=%.1fs",
result.candidates,
result.matched,
result.updated,
result.no_address,
result.no_match,
result.skipped_non_ekb,
result.errors,
result.duration_sec,
)

View file

@ -0,0 +1,161 @@
"""Daily recompute of per-city ppm² plausible-deal guard-bands (#2576 Stage B).
ПРОБЛЕМА: deal_city_price_bands (migration 178, tier-схема migration 194)
засеяна ON CONFLICT DO UPDATE derivation-запросом. По мере ночного импорта новых
ДКП-сделок (rosreestr_dkp_import) города переходят между tier ('region_fallback'
N<10 'rough' N 10-29 'full' N>=30), а перцентили внутри tier дрейфуют нужен
периодический пересчёт по той же derivation.
Задача синхронная (DB-only, никаких внешних HTTP-вызовов) запускается
kit-scheduler'ом через product_handlers._job_deal_city_price_bands_refresh
(run_in_executor), по образцу asking_to_sold_ratio.py / snapshot_listing_sources.
Окно расписания 07:00-08:00 UTC ПОСЛЕ rosreestr_dkp_import (04:00-06:00 UTC) И
asking_to_sold_ratio_refresh (06:00-07:00 UTC), чтобы бэнды считались по тому же
свежему срезу deals, что и ratio-таблица того же дня.
SQL derivation ниже БАЙТ-В-БАЙТ та же логика, что seed в
data/sql/194_deal_city_price_bands_tiers.sql (region_stats / city_stats / tiered:
трёхуровневая схема full N>=30 / rough N 10-29 / region_fallback N 1-9, см.
комментарий в 194 для полного обоснования тиров и hard floor/ceiling клампов).
Нет DELETE перед re-derive (в отличие от asking_to_sold_ratio.py true-mirror
паттерна) множество городов монотонно растёт (rosreestr_dkp_import только
INSERT/ON CONFLICT DO UPDATE, никогда не удаляет сделки), поэтому merge-по-city
(ON CONFLICT DO UPDATE) достаточен: город, перешедший в другой tier, просто
перезаписывается на следующем refresh. Екатеринбург НЕ включён (WHERE city <>
'Екатеринбург') estimator.py fallback на глобальные DEAL_MIN_PPM2/DEAL_MAX_PPM2
для ЕКБ остаётся byte-identical (invariant из 178/194 сохранён).
"""
from __future__ import annotations
import logging
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.services import scrape_runs as runs_mod
logger = logging.getLogger(__name__)
# ── Derivation + re-seed (БАЙТ-В-БАЙТ из 194) ─────────────────────────────────
_REDERIVE_SQL = text(
"""
WITH region_stats AS (
SELECT GREATEST(
round(percentile_cont(0.01) WITHIN GROUP (ORDER BY price_per_m2))::int,
8000
) AS region_ppm2_min
FROM deals
WHERE source = 'rosreestr'
AND price_per_m2 IS NOT NULL
AND city IS NOT NULL
AND city <> 'Екатеринбург'
),
city_stats AS (
SELECT
city,
GREATEST(round(percentile_cont(0.01) WITHIN GROUP (ORDER BY price_per_m2))::int, 8000)
AS ppm2_p1,
LEAST(round(percentile_cont(0.99) WITHIN GROUP (ORDER BY price_per_m2))::int, 800000)
AS ppm2_p99,
count(*) AS n_deals
FROM deals
WHERE source = 'rosreestr'
AND price_per_m2 IS NOT NULL
AND city IS NOT NULL
AND city <> 'Екатеринбург'
GROUP BY city
),
tiered AS (
SELECT city, ppm2_p1 AS ppm2_min, ppm2_p99 AS ppm2_max, n_deals,
'full'::text AS tier
FROM city_stats
WHERE n_deals >= 30
AND ppm2_p99 >= 8000
UNION ALL
SELECT city, LEAST(ppm2_p1, 700000) AS ppm2_min, 800000 AS ppm2_max, n_deals,
'rough'::text AS tier
FROM city_stats
WHERE n_deals BETWEEN 10 AND 29
UNION ALL
SELECT c.city, r.region_ppm2_min AS ppm2_min, 800000 AS ppm2_max, c.n_deals,
'region_fallback'::text AS tier
FROM city_stats c
CROSS JOIN region_stats r
WHERE c.n_deals < 10
)
INSERT INTO deal_city_price_bands (city, ppm2_min, ppm2_max, n_deals, tier, refreshed_at)
SELECT city, ppm2_min, ppm2_max, n_deals, tier, now()
FROM tiered
ON CONFLICT (city) DO UPDATE
SET ppm2_min = EXCLUDED.ppm2_min,
ppm2_max = EXCLUDED.ppm2_max,
n_deals = EXCLUDED.n_deals,
tier = EXCLUDED.tier,
refreshed_at = EXCLUDED.refreshed_at
"""
)
# ── Post-insert counters ──────────────────────────────────────────────────────
_COUNTERS_SQL = text(
"""
SELECT
COUNT(*) AS rows_written,
COUNT(*) FILTER (WHERE tier = 'full') AS full_rows,
COUNT(*) FILTER (WHERE tier = 'rough') AS rough_rows,
COUNT(*) FILTER (WHERE tier = 'region_fallback') AS region_fallback_rows
FROM deal_city_price_bands
"""
)
def refresh_deal_city_price_bands(db: Session, run_id: int) -> dict[str, int]:
"""Пересчитать deal_city_price_bands (#2576 Stage B — tier-aware refresh).
Sync (вызывается scheduler-триггером в executor, как recompute_asking_to_sold_ratios).
Одна транзакция: re-derive INSERT ... ON CONFLICT DO UPDATE (нет DELETE см.
module docstring), затем counters из таблицы, commit, mark_done.
Финализирует scrape_runs (mark_done / mark_failed) и пишет counters.
Returns {"rows_written": N, "full_rows": .., "rough_rows": .., "region_fallback_rows": ..}.
"""
counters: dict[str, int] = {
"rows_written": 0,
"full_rows": 0,
"rough_rows": 0,
"region_fallback_rows": 0,
}
try:
db.execute(_REDERIVE_SQL)
row = db.execute(_COUNTERS_SQL).mappings().first()
if row is not None:
counters["rows_written"] = int(row["rows_written"] or 0)
counters["full_rows"] = int(row["full_rows"] or 0)
counters["rough_rows"] = int(row["rough_rows"] or 0)
counters["region_fallback_rows"] = int(row["region_fallback_rows"] or 0)
db.commit()
runs_mod.mark_done(db, run_id, counters)
logger.info(
"refresh_deal_city_price_bands run_id=%d done: "
"rows_written=%d full=%d rough=%d region_fallback=%d",
run_id,
counters["rows_written"],
counters["full_rows"],
counters["rough_rows"],
counters["region_fallback_rows"],
)
return counters
except Exception as exc:
logger.exception("refresh_deal_city_price_bands run_id=%d failed", run_id)
db.rollback()
runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters)
raise

View file

@ -6,7 +6,7 @@
wired into in-app scheduler, window 06:00-09:00 UTC.
Pattern: dedup по address (1 unique address 1 geocode call UPDATE all listings).
Rate limit: Nominatim 1 req/sec. Yandex 25K/day если YANDEX_GEOCODER_API_KEY set.
Rate limit: Nominatim 1 req/sec (#2593: Yandex Geocoder tier удалён из geocoder).
Отличие от /admin/geocode-missing (per-ID):
- Этот модуль группирует по address меньше API calls (dedup).

View file

@ -13,7 +13,15 @@
-- precision БЕЗ единого внешнего HTTP-запроса (в отличие от Nominatim) — но был ТОЛЬКО
-- manual script (`python -m app.tasks.backfill_listings_coords_geoportal`), ни разу не
-- запускавшийся на recurring основе. Один прошлый ручной прогон (#1841): 17241
-- кандидатов → 1008 проставлено (не-ЕКБ адреса не матчатся — корректно, EKB-only реестр).
-- кандидатов → 1008 проставлено.
--
-- ИСПРАВЛЕНО #2583 (находка H3): до фикса не-ЕКБ адреса region 66 (Нижний Тагил, Серов
-- и т.д.) НЕ отсекались — street+house парсились без учёта города и слепо матчились
-- против EKB-only реестра. Улица+дом могут буквально совпасть с ЕКБ ("проспект Ленина 1"
-- есть и в ЕКБ, и в Нижнем Тагиле) — такой листинг получал координаты Екатеринбурга.
-- Фикс: городской гейт _names_non_ekb_city перед вызовом _geoportal_house_match (тот же
-- гейт, что и в geocoder.geocode()). Не "корректно, EKB-only реестр", как было написано
-- здесь раньше — это была реальная утечка не-ЕКБ адресов в ЕКБ-координаты.
--
-- Решение: wire в in-app scheduler (source='geoportal_coords_backfill') по паттерну
-- cadastral_geo_match (migration 125) — pure internal DB op, SAFE to enable=true.

View file

@ -0,0 +1,88 @@
-- Migration 192: tradein_users + tradein_sessions — DB-backed auth (issue #2551, эпик #2549)
--
-- WHY:
-- Trade-in auth сейчас держится на legacy Caddy basic-auth fallback (см. auth/roles.yaml,
-- упомянут в 191_account_quota_unlimited_flag.sql как "хардкод username в коде"). Эпик #2549
-- переводит auth на DB-backed модель: пользователи + сессии как данные, роли admin/manager/
-- employee с иерархией manager -> employee. Эта миграция — только схема (Foundation),
-- без seed-данных (seed — отдельная задача #2557) и без Python-кода (backend wiring — отдельно).
--
-- WHAT:
-- 1. tradein_users — identity + role + org-иерархия.
-- - password_hash NULL допустим: переходный период, когда логин ещё идёт через
-- legacy Caddy fallback, а не через password verify в приложении.
-- - role CHECK ('admin','manager','employee') — три уровня доступа.
-- - manager_id — self-FK, ON DELETE SET NULL (увольнение/удаление manager'а не должно
-- каскадно сносить его employees, они просто остаются без привязки).
-- - CHECK role_manager_hierarchy: admin/manager обязаны иметь manager_id IS NULL
-- (это top-level роли, у них нет "начальника" в этой модели); employee — manager_id
-- любой, включая NULL (свободный слот employee без организации допустим).
-- 2. tradein_sessions — токен-based сессии, привязаны к user_id, ON DELETE CASCADE
-- (удалили пользователя — его сессии теряют смысл, каскадная очистка корректна).
-- last_seen_at отдельно от created_at — для idle-timeout / активности сессии.
-- 3. Индексы: expires_at (уборка протухших сессий), user_id (список сессий юзера),
-- partial на manager_id (иерархия) — WHERE manager_id IS NOT NULL, т.к. большинство
-- admin/manager строк это NULL и не участвуют в lookup "employees этого manager'а".
--
-- IDEMPOTENCY:
-- CREATE TABLE IF NOT EXISTS + CREATE INDEX IF NOT EXISTS. Повторный прогон — no-op.
-- CHECK-констрейнты добавлены inline в CREATE TABLE (не через ALTER) — при повторном
-- запуске CREATE TABLE IF NOT EXISTS не выполнится вообще, констрейнт не задублируется.
--
-- Dependencies: нет (новые таблицы, ничего существующего не меняем).
-- Deploy order: эта миграция — Foundation эпика #2549. Seed (#2557) и backend auth-код —
-- отдельные PR'ы ПОСЛЕ этой (SQL-схема первой, см. .claude/rules/sql.md "Migration order").
BEGIN;
CREATE TABLE IF NOT EXISTS tradein_users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
username text NOT NULL UNIQUE,
password_hash text NULL,
role text NOT NULL CHECK (role IN ('admin', 'manager', 'employee')),
manager_id bigint NULL REFERENCES tradein_users(id) ON DELETE SET NULL,
display_name text NULL,
org_name text NULL,
email text NULL,
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT tradein_users_role_manager_hierarchy_ck CHECK (
role NOT IN ('admin', 'manager') OR manager_id IS NULL
)
);
COMMENT ON TABLE tradein_users IS
'Trade-in DB-backed auth — пользователи (issue #2551, эпик #2549). password_hash NULL '
'допустим в переходный период (логин через legacy Caddy fallback). Seed — отдельно (#2557).';
COMMENT ON COLUMN tradein_users.password_hash IS
'NULL = логин только через legacy Caddy basic-auth fallback, не через password verify.';
COMMENT ON COLUMN tradein_users.manager_id IS
'Self-FK на tradein_users(id). NULL для admin/manager (top-level, CHECK ниже) или для '
'employee без назначенной организации.';
CREATE TABLE IF NOT EXISTS tradein_sessions (
token text PRIMARY KEY,
user_id bigint NOT NULL REFERENCES tradein_users(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
last_seen_at timestamptz NOT NULL DEFAULT now(),
ip_address inet NULL,
user_agent text NULL
);
COMMENT ON TABLE tradein_sessions IS
'Trade-in DB-backed auth — активные сессии (issue #2551, эпик #2549). '
'ON DELETE CASCADE от tradein_users: удалённый пользователь теряет все сессии.';
CREATE INDEX IF NOT EXISTS tradein_sessions_expires_at_idx
ON tradein_sessions (expires_at);
CREATE INDEX IF NOT EXISTS tradein_sessions_user_id_idx
ON tradein_sessions (user_id);
CREATE INDEX IF NOT EXISTS tradein_users_manager_id_idx
ON tradein_users (manager_id)
WHERE manager_id IS NOT NULL;
COMMIT;

View file

@ -0,0 +1,127 @@
-- Migration 193: seed существующих юзеров в tradein_users + ASCII-CHECK на username
-- (issue #2557, эпик #2549)
--
-- WHY:
-- Migration 192 создала schema (tradein_users/tradein_sessions), но без данных —
-- DB-backed auth не может заработать, пока реальные аккаунты (сейчас живущие только
-- в auth/roles.yaml + caddy/users.caddy.snippet, legacy Caddy basic-auth) не отражены
-- в таблице. Эта миграция переносит org-карту, утверждённую владельцем продукта,
-- в данные — без единого пароля (см. WHAT.2) и без Python-кода (backend wiring — #2556,
-- team-UI для проставления паролей — отдельная задача, тоже #2556).
--
-- ASCII-CHECK (deep-review #2561, обязательное требование ДО прод-данных):
-- rbac кодирует session-username через `encode("latin-1", "replace")`. Кириллические
-- логины ОДИНАКОВОЙ длины схлопываются в одну и ту же byte-строку под этой кодировкой
-- ("иванов" и "петров" оба 6 кириллических символов -> оба превращаются в одинаковую
-- строку из '?' одной длины) -> общий downstream-identity между разными людьми, общая
-- квота, взаимный IDOR (один видит сессии/данные другого). Все текущие org-логины уже
-- ASCII (admin/kopylov/praktika/userN), поэтому constraint не конфликтует с seed'ом
-- ниже; он существует, чтобы navsegda запретить будущим кириллическим логинам попасть
-- в таблицу — fail-closed на уровне схемы, а не на уровне доверия к тому, что кто-то
-- не забудет проверить в UI/API layer.
--
-- WHAT:
-- 1. ASCII-CHECK: tradein_users_username_ascii_ck CHECK (username ~ '^[A-Za-z0-9._-]{3,64}$').
-- Добавлен ДО seed-инсертов ниже для читаемости файла (CHECK — immediate constraint,
-- Postgres валидирует им и ROW-строки транзакции независимо от того, в каком месте
-- файла он объявлен относительно INSERT, так что порядок сам по себе не критичен).
-- 2. Seed — org-карта, утверждённая владельцем продукта (2026-07-30):
-- admin role=admin, manager_id=NULL, is_active=true (владелец)
-- kopylov role=manager, manager_id=NULL, is_active=true (отдельный клиент)
-- praktika role=manager, manager_id=NULL, is_active=true (ГК «Практика»)
-- user1, user3-10 role=employee, manager_id=NULL, is_active=true (свободные слоты, без org)
-- user2 role=employee, manager_id=NULL, is_active=false («Брусника», доступ
-- закрыт 2026-07-30)
-- password_hash = NULL для ВСЕХ — пароли админ проставит вручную через team-UI (#2556).
-- NULL-hash делает password-логин невозможным для этой строки, но НЕ снимает доступ:
-- в переходный период работает только legacy Caddy basic-auth fallback (dual-mode,
-- см. комментарий password_hash в 192_tradein_users_auth.sql) — никто не теряет доступ
-- из-за этой миграции.
-- display_name = 'Копылов' для kopylov (источник — auth.py::_USERNAME_PROFILE, уже
-- задокументированная фамилия). Для остальных — NULL, реальных данных нет, не выдумываем.
-- НЕ мигрируем admintest/pilottest/analysttest/expiredtest — temp QA-фикстуры
-- (auth/roles.yaml), остаются только там, в DB-backed auth не нужны.
--
-- IDEMPOTENCY:
-- - ADD CONSTRAINT через DO-блок с проверкой pg_constraint (Postgres не поддерживает
-- `ADD CONSTRAINT IF NOT EXISTS` для CHECK) — паттерн из
-- 189_account_estimate_usage_nonnegative.sql.
-- - INSERT ... ON CONFLICT (username) DO UPDATE, но НЕ безусловно: password_hash,
-- manager_id, display_name, org_name, email защищены COALESCE(текущее, EXCLUDED) —
-- если админ уже проставил пароль / назначил manager_id (team-API #2563 пишет
-- manager_id при создании сотрудника менеджером) / поменял display_name вручную
-- через team-UI (#2556) между двумя прогонами этого файла (например ручной re-apply
-- при recovery — обычный auto-apply тречит filename в _schema_migrations и не
-- запускает файл дважды на одном окружении, но scratch/staging БД такого
-- трекинга не имеют), повторный прогон НЕ должен затереть это состояние NULL-ом /
-- seed-дефолтом. Deep-review #2564 нашёл это живым багом: manager_id, назначенный
-- через #2563, тихо обнулялся повторным прогоном сида — employee выпадал из
-- `_LIST_EMPLOYEES_BY_MANAGER_SQL`, менеджер переставал видеть его в дашборде.
-- role намеренно синкается с EXCLUDED (не защищён) — это и есть источник истины
-- org-карты из этой миграции; если владелец продукта поправит эту таблицу новой
-- миграцией поверх, DO UPDATE-ветка должна донести исправление роли, а не
-- заморозить первый прогон навсегда.
-- - is_active НАМЕРЕННО отсутствует в SET (не COALESCE — колонка NOT NULL DEFAULT
-- true, COALESCE(NOT NULL, x) никогда не берёт x, это была бы мёртвая, вводящая в
-- заблуждение симметрия с password_hash/manager_id, deep-review #2564 medium).
-- Открытие/закрытие доступа (is_active) — решение владельца продукта, принимается
-- через UI (#2556), НЕ повторным прогоном этого seed-файла: после первой вставки
-- колонка сознательно «замораживается» на текущем значении в БД, seed её больше
-- не трогает.
--
-- Dependencies: 192_tradein_users_auth.sql (создаёт tradein_users, tradein_sessions).
-- Deploy order: после 192 (Foundation). Backend auth-код (login/password-verify) и
-- team-UI (#2556) — отдельные PR'ы ПОСЛЕ этой миграции (SQL-схема+данные первыми, см.
-- .claude/rules/sql.md "Migration order").
BEGIN;
-- Часть 1: ASCII-CHECK (immediate constraint — валидирует и вставляемые ниже строки).
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'tradein_users_username_ascii_ck'
) THEN
ALTER TABLE tradein_users
ADD CONSTRAINT tradein_users_username_ascii_ck
CHECK (username ~ '^[A-Za-z0-9._-]{3,64}$');
END IF;
END $$;
COMMENT ON CONSTRAINT tradein_users_username_ascii_ck ON tradein_users IS
'Fail-closed защита от кириллических/не-ASCII логинов (deep-review #2561): '
'rbac кодирует session-username через encode("latin-1","replace"), не-ASCII '
'логины одинаковой длины схлопываются в общий downstream-identity (IDOR).';
-- Часть 2: seed org-карты (владелец продукта, 2026-07-30).
INSERT INTO tradein_users
(username, password_hash, role, manager_id, display_name, org_name, email, is_active)
VALUES
('admin', NULL, 'admin', NULL, NULL, NULL, NULL, true),
('kopylov', NULL, 'manager', NULL, 'Копылов', NULL, NULL, true),
('praktika', NULL, 'manager', NULL, NULL, NULL, NULL, true),
('user1', NULL, 'employee', NULL, NULL, NULL, NULL, true),
('user2', NULL, 'employee', NULL, NULL, NULL, NULL, false),
('user3', NULL, 'employee', NULL, NULL, NULL, NULL, true),
('user4', NULL, 'employee', NULL, NULL, NULL, NULL, true),
('user5', NULL, 'employee', NULL, NULL, NULL, NULL, true),
('user6', NULL, 'employee', NULL, NULL, NULL, NULL, true),
('user7', NULL, 'employee', NULL, NULL, NULL, NULL, true),
('user8', NULL, 'employee', NULL, NULL, NULL, NULL, true),
('user9', NULL, 'employee', NULL, NULL, NULL, NULL, true),
('user10', NULL, 'employee', NULL, NULL, NULL, NULL, true)
ON CONFLICT (username) DO UPDATE SET
role = EXCLUDED.role,
-- manager_id защищён COALESCE: team-API (#2563) пишет manager_id при назначении
-- сотрудника менеджером, повторный прогон seed'а не должен тихо обнулять эту связь.
manager_id = COALESCE(tradein_users.manager_id, EXCLUDED.manager_id),
password_hash = COALESCE(tradein_users.password_hash, EXCLUDED.password_hash),
display_name = COALESCE(tradein_users.display_name, EXCLUDED.display_name),
org_name = COALESCE(tradein_users.org_name, EXCLUDED.org_name),
email = COALESCE(tradein_users.email, EXCLUDED.email),
-- is_active НЕ в SET: NOT NULL DEFAULT true колонка, COALESCE был бы мёртвым кодом
-- (см. IDEMPOTENCY выше) — open/close доступа решается через UI (#2556), не seed'ом.
updated_at = now();
COMMIT;

View file

@ -0,0 +1,150 @@
-- 194_deal_city_price_bands_tiers.sql
-- Эпик #2576 Stage B — многоуровневые ценовые бэнды по городам + честный
-- региональный фолбэк вместо ЕКБ-калиброванного порога.
--
-- ПРОБЛЕМА:
-- Миграция 178 построила deal_city_price_bands РАЗОВО, только для городов
-- с count(*) >= 30 сделок (HAVING count(*) >= 30) на момент прогона. Auto-refresh
-- не был реализован (см. комментарий в 178). Город без строки в таблице
-- попадает на глобальный DEAL_MIN_PPM2=50_000 (estimator.py) — порог,
-- откалиброванный ИСКЛЮЧИТЕЛЬНО по Екатеринбургу. Для малых городов области
-- это не anti-outlier guard, а cut-off легитимного рынка (Североуральск
-- median ≈ 21.7k ₽/м²).
--
-- Замер по прод-данным deals (2026-07-31, source='rosreestr', city IS NOT NULL,
-- city <> 'Екатеринбург', price_per_m2 IS NOT NULL — 47 253 сделки / 369 городов):
-- N>=30 сделок → 80 городов (45 988 сделок, 97.3%) — уже покрыты 178.
-- N 15-29 → 21 город ( 460 сделок) — падали на global-50k fallback.
-- N 10-14 → 21 город ( 247 сделок) — падали на global-50k fallback.
-- N 1-9 → 247 городов ( 558 сделок) — падали на global-50k fallback,
-- per-city перцентиль на такой выборке статистически бессмысленен
-- (n=1 → «перцентиль» = единственная сделка).
-- Итого 289 городов / 1265 сделок (2.7% выборки, но 78% ДОЛГОГО ХВОСТА городов)
-- получали ЕКБ-калиброванный пол вместо своей реальной цены.
--
-- РЕШЕНИЕ — трёхуровневая схема (колонка tier), вместо единого порога 30:
-- 'full' N>=30 — own p1/p99 перцентиль (BYTE-IDENTICAL 178-derivation,
-- ЕКБ и существующие 80 городов НЕ меняются).
-- 'rough' 10<=N<30 — own p1 (floor), ceiling ФИКСИРОВАН на 800000
-- (не деривится из тонкой выборки — p99 на <30 точках
-- нестабилен, одна дорогая сделка исказит потолок).
-- 'region_fallback' 1<=N<10 — own-данные города СЛИШКОМ тонкие даже для floor
-- (единичная сделка = 100% перцентиля недостоверна).
-- Используем ПУЛ по всей области (region_stats CTE,
-- p1 по 47k+ не-ЕКБ сделкам = 15 263 ₽/м² на момент
-- замера) вместо DEAL_MIN_PPM2=50000 (ЕКБ-калибровка).
-- Честнее: 15k отражает реальный низ рынка обл.66,
-- а не искусственно завышенный екб-порог.
--
-- Екатеринбург по-прежнему НЕ включён (estimator.py fallback на глобальные
-- DEAL_MIN_PPM2/DEAL_MAX_PPM2 остаётся единственным путём для ЕКБ — invariant
-- из 178 сохранён). После этой миграции ЕВСЕ 369 не-ЕКБ городов, встречающихся
-- в deals, получают строку — Python-fallback в estimator.py (COALESCE(b.ppm2_min,
-- :ppm_min)) отныне срабатывает практически только для ЕКБ (плюс узкое окно
-- между refresh-циклами для только что появившегося города).
--
-- IDEMPOTENCY: ADD COLUMN IF NOT EXISTS + DO-блок guard на CHECK constraint
-- (PG 16 не поддерживает ADD CONSTRAINT IF NOT EXISTS). INSERT ... ON CONFLICT
-- DO UPDATE — повторный прогон рефрешит бэнды под свежие сделки (та же
-- семантика, что и 178). Без DELETE — множество городов монотонно растёт
-- (rosreestr_dkp_import только INSERT/UPDATE, никогда не удаляет), поэтому
-- merge-по-ключу достаточен (см. app/tasks/deal_city_price_bands_refresh.py —
-- периодический refresh, та же derivation байт-в-байт).
--
-- Dependencies: 177_deals_city_region.sql (deals.city), 178_deal_city_price_bands.sql
-- (таблица + PK(city)).
-- Apply after: --
-- Deploy order: эта миграция ПЕРЕД деплоем backend-кода, который регистрирует
-- scheduler-source 'deal_city_price_bands_refresh' (product_handlers.py) —
-- см. 195_scrape_schedules_seed_deal_city_price_bands_refresh.sql (deploy after
-- backend-код задеплоен, тот же порядок, что 088).
BEGIN;
ALTER TABLE deal_city_price_bands
ADD COLUMN IF NOT EXISTS tier text NOT NULL DEFAULT 'full';
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'deal_city_price_bands_tier_check'
) THEN
ALTER TABLE deal_city_price_bands
ADD CONSTRAINT deal_city_price_bands_tier_check
CHECK (tier IN ('full', 'rough', 'region_fallback'));
END IF;
END $$;
COMMENT ON COLUMN deal_city_price_bands.tier IS
'full: N>=30 сделок, own p1/p99 band (миграция 178, unchanged). '
'rough: 10<=N<30, own p1 floor + фиксированный 800000 ceiling (миграция 194). '
'region_fallback: 1<=N<10, pooled Свердловская-обл. p1 floor (region_stats, '
'все не-ЕКБ сделки) + фиксированный 800000 ceiling — вместо '
'ЕКБ-калиброванного DEAL_MIN_PPM2=50000 (estimator.py).';
WITH region_stats AS (
-- Пул по ВСЕЙ области (не-ЕКБ) — честный фолбэк для городов, где own-выборка
-- (N<10) слишком тонкая для собственного перцентиля.
SELECT GREATEST(
round(percentile_cont(0.01) WITHIN GROUP (ORDER BY price_per_m2))::int,
8000
) AS region_ppm2_min
FROM deals
WHERE source = 'rosreestr'
AND price_per_m2 IS NOT NULL
AND city IS NOT NULL
AND city <> 'Екатеринбург'
),
city_stats AS (
SELECT
city,
GREATEST(round(percentile_cont(0.01) WITHIN GROUP (ORDER BY price_per_m2))::int, 8000)
AS ppm2_p1,
LEAST(round(percentile_cont(0.99) WITHIN GROUP (ORDER BY price_per_m2))::int, 800000)
AS ppm2_p99,
count(*) AS n_deals
FROM deals
WHERE source = 'rosreestr'
AND price_per_m2 IS NOT NULL
AND city IS NOT NULL
AND city <> 'Екатеринбург'
GROUP BY city
),
tiered AS (
-- full — байт-в-байт исходная 178-derivation (own p1/p99), плюс тот же
-- анти-мусорный инвариант (p99 < 8000 → город не матчил бы ни одну сделку).
SELECT city, ppm2_p1 AS ppm2_min, ppm2_p99 AS ppm2_max, n_deals,
'full'::text AS tier
FROM city_stats
WHERE n_deals >= 30
AND ppm2_p99 >= 8000
UNION ALL
-- rough — собственный p1 (floor), ceiling НЕ деривится (тонкая выборка).
SELECT city, LEAST(ppm2_p1, 700000) AS ppm2_min, 800000 AS ppm2_max, n_deals,
'rough'::text AS tier
FROM city_stats
WHERE n_deals BETWEEN 10 AND 29
UNION ALL
-- region_fallback — собственных данных недостаточно даже для floor, берём
-- пул по области целиком.
SELECT c.city, r.region_ppm2_min AS ppm2_min, 800000 AS ppm2_max, c.n_deals,
'region_fallback'::text AS tier
FROM city_stats c
CROSS JOIN region_stats r
WHERE c.n_deals < 10
)
INSERT INTO deal_city_price_bands (city, ppm2_min, ppm2_max, n_deals, tier, refreshed_at)
SELECT city, ppm2_min, ppm2_max, n_deals, tier, now()
FROM tiered
ON CONFLICT (city) DO UPDATE
SET ppm2_min = EXCLUDED.ppm2_min,
ppm2_max = EXCLUDED.ppm2_max,
n_deals = EXCLUDED.n_deals,
tier = EXCLUDED.tier,
refreshed_at = EXCLUDED.refreshed_at;
COMMIT;

View file

@ -0,0 +1,62 @@
-- 195_scrape_schedules_seed_deal_city_price_bands_refresh.sql
-- Эпик #2576 Stage B — seed scrape_schedules row для daily-рефреша
-- deal_city_price_bands (миграция 194).
--
-- ПРОБЛЕМА: 178/194 заполняют deal_city_price_bands на момент прогона миграции.
-- По мере ночного импорта новых ДКП-сделок (rosreestr_dkp_import, 04:00-06:00 UTC)
-- бэнды (own p1/p99, tier-границы N) устаревают — города переходят между tier
-- ('region_fallback' → 'rough' → 'full') по мере накопления сделок, а сами
-- перцентили внутри tier дрейфуют. Auto-refresh отсутствовал (см. follow-up
-- в 178) — эта миграция закрывает разрыв.
--
-- Задача (app/tasks/deal_city_price_bands_refresh.py, byte-identical derivation
-- 194) — pure-internal DB re-derivation, никаких внешних HTTP-вызовов. Запускается
-- kit-scheduler'ом через product_handlers._job_deal_city_price_bands_refresh
-- (run_in_executor, по образцу _job_asking_to_sold_ratio).
--
-- enabled = true — БЕЗОПАСНО включать сразу (тот же аргумент, что 082/088: pure DB,
-- без анти-бота).
-- Окно 07:00-08:00 UTC — ПОСЛЕ rosreestr_dkp_import (04:00-06:00, см. 072) И
-- asking_to_sold_ratio_refresh (06:00-07:00, см. 082), чтобы бэнды считались по
-- тому же свежему срезу deals, что и ratio-таблица того же дня.
-- next_run_at = завтрашнее наступление окна (tomorrow + 07:00 UTC) — тот же паттерн,
-- что 078/079/082/088 (иначе get_due_schedules() выстрелит сразу после деплоя).
--
-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)),
-- 194_deal_city_price_bands_tiers.sql (tier-колонка, которую переиспользует refresh).
-- Idempotent: ON CONFLICT (source) DO NOTHING — безопасно запускать повторно.
-- Deploy order: применять ПОСЛЕ деплоя backend-кода, регистрирующего
-- 'deal_city_price_bands_refresh' в product_handlers.build_product_handlers()
-- (тот же порядок, что 088 relative к scheduler.py) — иначе kit-scheduler не
-- найдёт Handler для нового source и упадёт в "unknown source" на первом due-run
-- (не раньше завтрашнего окна — не блокирует деплой).
BEGIN;
INSERT INTO scrape_schedules (
source,
enabled,
window_start_hour,
window_end_hour,
next_run_at,
default_params
)
VALUES
(
'deal_city_price_bands_refresh',
true, -- SAFE: pure internal DB, no external calls
7,
8,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 7)) AT TIME ZONE 'UTC',
'{}'::jsonb
)
ON CONFLICT (source) DO NOTHING;
COMMENT ON TABLE scrape_schedules IS
'In-app scheduler config (заменяет cron-script setup). '
'Sources: avito_city_sweep, yandex_city_sweep (dormant, #561), '
'cian_history_backfill, rosreestr_dkp_import, listing_source_snapshot (#570), '
'asking_to_sold_ratio_refresh (#648), refresh_search_matview (#769), '
'deal_city_price_bands_refresh (#2576 Stage B).';
COMMIT;

View file

@ -0,0 +1,44 @@
-- 196_listings_city.sql
-- Issue #2594 — критичный дефект: скрапер знает город в момент сбора (city_slug из
-- CITY_LOCATIONS/CITY_ANCHORS, packages/scraper-kit/.../orchestration/pipeline.py), но
-- НИКУДА его не пишет. Провайдеры (avito/cian) часто отдают адрес БЕЗ города в тексте
-- ("ул. Победы, 30" вместо "Нижний Тагил, ул. Победы, 30") — cian даже явно вырезает
-- location-часть перед записью (skip_types = {"location", "metro"}, providers/cian/serp.py).
-- Без явного города такой адрес при геокодинге считается «город не назван» → попадает
-- в EKB-only локальные реестры (ekb_geoportal_buildings/gendesign_cad_buildings) и
-- коллизирует с одноимённой екатеринбургской улицей (Ленина/Победы/Тенистая — сотни
-- совпадений) → объявление получает координаты Екатеринбурга и тянет медиану чужих цен.
--
-- Fix:
-- Add listings.city TEXT column. Проставляется НЕПОСРЕДСТВЕННО из контекста
-- развёртки (город известен вызывающему коду — city_slug/CITY_LOCATIONS для oblast,
-- "Екатеринбург" для EKB-развёрток) — НЕ парсингом текста адреса. См.
-- scraper_kit.base.save_listings(..., city=...) + scraper_kit.orchestration.pipeline
-- .resolve_city_name(). Раздельная колонка (а не дописывание города в address) —
-- исходный текст адреса не портится, downstream text-парсеры (geocoder._parse_street_house,
-- geocoder._names_non_ekb_city, estimator._parse_street_house, house-matching) продолжают
-- работать НЕИЗМЕНЁННЫМИ на исходном сыром тексте — риск регрессии на bare-form адресах
-- без street-маркера ("Дружинина, 33") исключён.
--
-- Scope (#2594): только write-path для НОВЫХ листингов (go-forward). Бэкфилл city для
-- уже накопленных строк (restore по тому, какая развёртка их когда-то принесла) —
-- отдельная задача, НЕ эта миграция.
--
-- Idempotency:
-- ALTER TABLE ... ADD COLUMN IF NOT EXISTS — safe on re-run.
-- BEGIN/COMMIT block.
--
-- Dependencies:
-- 002_core_tables.sql (listings table).
BEGIN;
ALTER TABLE listings ADD COLUMN IF NOT EXISTS city text;
COMMENT ON COLUMN listings.city IS
'Город объявления (#2594) — проставляется из контекста развёртки '
'(city_slug city-sweep / "Екатеринбург" default), НЕ парсингом address. '
'NULL — листинг записан до этой миграции ИЛИ путём, ещё не проставляющим город '
'(admin ad-hoc /admin/scrape, manual ingest-скрипты).';
COMMIT;

View file

@ -25,6 +25,7 @@ dependencies = [
"sentry-sdk>=2.0.0", # мониторинг ошибок → GlitchTip (#396)
"redis>=5.0.0", # async hot cache для /api/v1/search (Phase 3.2)
"pyyaml>=6.0.0", # RBAC roles.yaml loader (app/core/auth.py)
"bcrypt>=4.2.0", # password hashing для DB-auth (#2550)
"playwright>=1.45", # Playwright client для connect к tradein-browser (#905)
"scraper-kit", # internal workspace-package (#2137) — общие утилиты скрапперов;
# резолвится из workspace (см. [tool.uv.sources]), не с PyPI.

View file

@ -176,24 +176,18 @@ def _nom_result(address: str) -> ReverseGeocodeResult:
async def test_reverse_geocode_prefers_cadastral_over_nominatim() -> None:
"""Cadastral returns address → Yandex/Nominatim never called."""
"""Cadastral returns address → Nominatim never called."""
db = MagicMock()
with (
patch(
"app.services.geocoder._cadastral_reverse_sync_full",
return_value=("ул. Малышева, 30, Екатеринбург", 56.8385, 60.6052),
) as mock_cad,
patch("app.services.geocoder.settings") as mock_settings,
patch(
"app.services.geocoder._yandex_reverse",
new_callable=AsyncMock,
) as mock_yandex,
patch(
"app.services.geocoder._nominatim_reverse",
new_callable=AsyncMock,
) as mock_nom,
):
mock_settings.yandex_geocoder_api_key = "fake-key"
result = await reverse_geocode(56.838, 60.605, db=db)
assert result is not None
@ -203,41 +197,6 @@ async def test_reverse_geocode_prefers_cadastral_over_nominatim() -> None:
assert result.precision == "number"
assert result.provider == "cadastral"
mock_cad.assert_called_once_with(db, 56.838, 60.605)
mock_yandex.assert_not_called()
mock_nom.assert_not_called()
async def test_reverse_geocode_uses_yandex_when_cadastral_empty() -> None:
"""Cadastral None + Yandex key set → Yandex called, Nominatim skipped."""
db = MagicMock()
yandex_result = ReverseGeocodeResult(
address="улица Малышева, 51, Екатеринбург",
snapped_lat=56.838004,
snapped_lon=60.586155,
precision="exact",
provider="yandex",
)
with (
patch("app.services.geocoder._cadastral_reverse_sync_full", return_value=None),
patch("app.services.geocoder.settings") as mock_settings,
patch(
"app.services.geocoder._yandex_reverse",
new_callable=AsyncMock,
return_value=yandex_result,
) as mock_yandex,
patch(
"app.services.geocoder._nominatim_reverse",
new_callable=AsyncMock,
) as mock_nom,
):
mock_settings.yandex_geocoder_api_key = "fake-key"
result = await reverse_geocode(56.838, 60.586, db=db)
assert result is not None
assert result.provider == "yandex"
assert result.precision == "exact"
assert result.snapped_lat == 56.838004
mock_yandex.assert_called_once()
mock_nom.assert_not_called()
@ -263,23 +222,16 @@ async def test_reverse_geocode_falls_back_to_nominatim_when_cadastral_none() ->
async def test_reverse_geocode_returns_none_when_all_providers_fail() -> None:
"""Cadastral + Yandex + Nominatim — все падают → returns None, no exception."""
"""Cadastral + Nominatim — все падают → returns None, no exception."""
db = MagicMock()
with (
patch("app.services.geocoder._cadastral_reverse_sync_full", return_value=None),
patch("app.services.geocoder.settings") as mock_settings,
patch(
"app.services.geocoder._yandex_reverse",
new_callable=AsyncMock,
side_effect=RuntimeError("Yandex 429 rate-limited"),
),
patch(
"app.services.geocoder._nominatim_reverse",
new_callable=AsyncMock,
side_effect=RuntimeError("Nominatim 403 Forbidden — IP banned"),
),
):
mock_settings.yandex_geocoder_api_key = "fake-key"
result = await reverse_geocode(56.838, 60.605, db=db)
# Must return None, not raise
@ -287,19 +239,17 @@ async def test_reverse_geocode_returns_none_when_all_providers_fail() -> None:
async def test_reverse_geocode_without_db_skips_cadastral() -> None:
"""When db=None, cadastral tier is skipped entirely → goes straight to Yandex/Nominatim."""
"""When db=None, cadastral tier is skipped entirely → goes straight to Nominatim."""
with (
patch(
"app.services.geocoder._cadastral_reverse_sync_full",
) as mock_cad,
patch("app.services.geocoder.settings") as mock_settings,
patch(
"app.services.geocoder._nominatim_reverse",
new_callable=AsyncMock,
return_value=_nom_result("ул. Ленина, 1, Екатеринбург"),
),
):
mock_settings.yandex_geocoder_api_key = None
result = await reverse_geocode(56.838, 60.605, db=None)
mock_cad.assert_not_called()
@ -307,36 +257,11 @@ async def test_reverse_geocode_without_db_skips_cadastral() -> None:
assert result.address == "ул. Ленина, 1, Екатеринбург"
async def test_reverse_geocode_falls_through_yandex_to_nominatim_on_exception() -> None:
"""Yandex raises → Nominatim still tried."""
db = MagicMock()
with (
patch("app.services.geocoder._cadastral_reverse_sync_full", return_value=None),
patch("app.services.geocoder.settings") as mock_settings,
patch(
"app.services.geocoder._yandex_reverse",
new_callable=AsyncMock,
side_effect=RuntimeError("Yandex 500"),
),
patch(
"app.services.geocoder._nominatim_reverse",
new_callable=AsyncMock,
return_value=_nom_result("ул. Дублёр, 1"),
) as mock_nom,
):
mock_settings.yandex_geocoder_api_key = "fake-key"
result = await reverse_geocode(56.838, 60.605, db=db)
assert result is not None
assert result.provider == "nominatim"
mock_nom.assert_called_once()
# ── geocode: cadastral as first tier ─────────────────────────────────────────
async def test_geocode_uses_cadastral_before_yandex() -> None:
"""Cadastral FDW returns suggestion → geocode returns it without hitting Yandex."""
async def test_geocode_uses_cadastral_before_nominatim() -> None:
"""Cadastral FDW returns suggestion → geocode returns it without hitting Nominatim."""
db = MagicMock()
# Cache miss
db.execute.side_effect = [
@ -362,7 +287,7 @@ async def test_geocode_uses_cadastral_before_yandex() -> None:
return_value=[cad_suggestion],
) as mock_cad,
patch("app.services.geocoder._cache_put"),
patch("app.services.geocoder._yandex_lookup", new_callable=AsyncMock) as mock_yandex,
patch("app.services.geocoder._nominatim_lookup", new_callable=AsyncMock) as mock_nominatim,
):
result = await geocode("Хохрякова 81", db)
@ -371,14 +296,14 @@ async def test_geocode_uses_cadastral_before_yandex() -> None:
assert result.lon == 60.610
assert result.confidence == "exact"
mock_cad.assert_called_once()
mock_yandex.assert_not_called()
mock_nominatim.assert_not_called()
# ── suggest: cadastral as first tier ─────────────────────────────────────────
async def test_suggest_uses_cadastral_before_yandex() -> None:
"""Cadastral FDW returns results → suggest returns them without hitting Yandex."""
async def test_suggest_uses_cadastral_before_nominatim() -> None:
"""Cadastral FDW returns results → suggest returns them without hitting Nominatim."""
db = MagicMock()
cad_suggestions = [
GeocodeSuggestion(
@ -396,53 +321,20 @@ async def test_suggest_uses_cadastral_before_yandex() -> None:
return_value=cad_suggestions,
) as mock_cad,
patch(
"app.services.geocoder._yandex_suggest",
"app.services.geocoder._nominatim_suggest",
new_callable=AsyncMock,
) as mock_yandex,
) as mock_nominatim,
):
results = await suggest("Малышева", db=db, limit=8)
assert len(results) == 1
assert results[0].full_address == "ул. Малышева, 30, Екатеринбург"
mock_cad.assert_called_once()
mock_yandex.assert_not_called()
mock_nominatim.assert_not_called()
async def test_suggest_falls_back_to_yandex_when_cadastral_empty() -> None:
"""Cadastral returns [] → DaData not configured → falls through to Yandex."""
db = MagicMock()
yandex_result = GeocodeSuggestion(
label="Малышева · Октябрьский",
full_address="улица Малышева, Екатеринбург",
lat=56.838,
lon=60.605,
kind="street",
)
with (
patch("app.services.geocoder._cadastral_forward_sync", return_value=[]),
patch("app.services.geocoder.settings") as mock_settings,
patch(
"app.services.geocoder._dadata_suggest",
new_callable=AsyncMock,
return_value=[], # DaData empty → falls through
),
patch(
"app.services.geocoder._yandex_suggest",
new_callable=AsyncMock,
return_value=[yandex_result],
) as mock_yandex,
):
mock_settings.dadata_api_token = "fake-dadata-token" # gate to call _dadata_suggest
mock_settings.yandex_geocoder_api_key = "fake-key"
results = await suggest("Малышева", db=db, limit=8)
assert len(results) == 1
mock_yandex.assert_called_once()
async def test_suggest_uses_dadata_before_yandex_when_cadastral_empty() -> None:
"""Cadastral returns [] → DaData tier returns results → Yandex never called (PR Q2)."""
async def test_suggest_uses_dadata_before_nominatim_when_cadastral_empty() -> None:
"""Cadastral returns [] → DaData tier returns results → Nominatim never called (PR Q2)."""
db = MagicMock()
dadata_result = GeocodeSuggestion(
label="г Екатеринбург, ул Малышева, д 30",
@ -461,18 +353,17 @@ async def test_suggest_uses_dadata_before_yandex_when_cadastral_empty() -> None:
return_value=[dadata_result],
) as mock_dadata,
patch(
"app.services.geocoder._yandex_suggest",
"app.services.geocoder._nominatim_suggest",
new_callable=AsyncMock,
) as mock_yandex,
) as mock_nominatim,
):
mock_settings.dadata_api_token = "fake-dadata-token"
mock_settings.yandex_geocoder_api_key = "fake-yandex-key"
results = await suggest("Малышева", db=db, limit=8)
assert len(results) == 1
assert results[0].lat == 56.838011
mock_dadata.assert_called_once()
mock_yandex.assert_not_called()
mock_nominatim.assert_not_called()
# ── ensure_fdw_user_mapping: SQL injection / whitelist guards ─────────────────

View file

@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import fnmatch
import os
import sys
from unittest.mock import AsyncMock, MagicMock, patch
@ -14,6 +15,7 @@ import pytest # noqa: E402
from app.core import shutdown as _sd # noqa: E402
from app.tasks.avito_detail_backfill import ( # noqa: E402
_OBLAST_AVITO_URL_PATTERNS,
AvitoDetailBackfillResult,
run_avito_detail_backfill,
)
@ -353,11 +355,14 @@ async def test_backfill_rotate_ip_called_on_each_block() -> None:
@pytest.mark.asyncio
async def test_backfill_snapshot_filters_ekb_active_only() -> None:
"""Снапшот-SELECT (#1814) фильтрует только активные ЕКБ-листинги.
"""Снапшот-SELECT (#1814, расширено #2576) фильтрует активные ЕКБ- И
известные oblast-листинги (region 66), НЕ всё подряд.
Проверяем, что текст запроса содержит `is_active = TRUE` и
`LIKE '%/ekaterinburg/%'` legacy не-ЕКБ (moskva/spb/tyumen) и мёртвые
листинги не попадают в фетч, иначе browser спотыкается curl-бан 429.
Проверяем, что текст запроса содержит `is_active = TRUE`, `LIKE
'%/ekaterinburg/%'` (ekb CTE, LIMIT batch_size НЕ сокращён) и `LIKE ANY(...)`
по oblast-паттернам (oblast CTE, отдельный LIMIT oblast_batch_size) legacy
не-ЕКБ/не-область (moskva/spb/tyumen) и мёртвые листинги не попадают в фетч,
иначе browser спотыкается curl-бан 429.
"""
db = _mock_db([])
runs = MagicMock()
@ -376,8 +381,127 @@ async def test_backfill_snapshot_filters_ekb_active_only() -> None:
sql_text = str(snapshot_call.args[0])
assert "is_active = TRUE" in sql_text
assert "/ekaterinburg/" in sql_text
assert "LIKE ANY(CAST(:oblast_patterns AS text[]))" in sql_text
assert "detail_enriched_at IS NULL" in sql_text
assert "(lat IS NULL) DESC" in sql_text
assert "CAST(:batch_size AS int)" in sql_text
assert "CAST(:oblast_batch_size AS int)" in sql_text
# ekb-квота передаётся batch_size БЕЗ урезания (#2576 требование "ЕКБ не
# деградирует") — oblast получает отдельный (не вычтенный) bind-параметр.
bind_params = snapshot_call.args[1]
assert bind_params["batch_size"] == 10
assert bind_params["oblast_batch_size"] == 100 # default
assert set(bind_params["oblast_patterns"]) == set(_OBLAST_AVITO_URL_PATTERNS)
def test_oblast_avito_url_patterns_cover_region66_cities() -> None:
"""#2576: _OBLAST_AVITO_URL_PATTERNS строится из CITY_LOCATIONS.avito_slug —
список должен покрывать реальные Avito-слаги oblast-городов (в т.ч. те, что
ОТЛИЧАЮТСЯ от нашего city_slug: kamensk-uralskiy через дефис, а не
kamensk_uralskiy).
#2578 review: '_' в слаге -- LIKE wildcard, экранируем при построении паттерна
('_' -> '\\_') -- nizhniy_tagil/verhnyaya_pyshma здесь ожидаются С обратным
слэшем перед '_', НЕ голым подчёркиванием."""
assert "%/kamensk-uralskiy/%" in _OBLAST_AVITO_URL_PATTERNS
assert "%/nizhniy\\_tagil/%" in _OBLAST_AVITO_URL_PATTERNS
assert "%/pervouralsk/%" in _OBLAST_AVITO_URL_PATTERNS
assert "%/verhnyaya\\_pyshma/%" in _OBLAST_AVITO_URL_PATTERNS
assert "%/serov/%" in _OBLAST_AVITO_URL_PATTERNS
# ЕКБ обрабатывается отдельным жёстко закодированным паттерном (ekb CTE),
# НЕ через этот oblast-список — не должен в него затесаться.
assert not any("ekaterinburg" in p for p in _OBLAST_AVITO_URL_PATTERNS)
def _like_pattern_to_fnmatch(pattern: str) -> str:
"""Точный перевод семантики Postgres `LIKE` (default `ESCAPE '\\'`) в fnmatch-
паттерн -- посимвольно, а НЕ наивным `.replace()`.
LIKE: `%` = любая последовательность символов, `_` = РОВНО один любой символ,
`\\%`/`\\_`/`\\\\` = литералы (экранирование). fnmatch: `*` = любая
последовательность, `?` = один любой символ; голые `_`/`%` в fnmatch не
специальны (можно вставлять как литерал без экранирования).
#2578 review: наивный `pat.replace("%", "*")` (как было раньше) НЕ отражал бы
семантику `_` вообще -- fnmatch трактует `_` как литерал, LIKE -- как wildcard.
Из-за этого расхождения прежний тест не поймал бы латентный баг (нет
экранирования `_` в продовых паттернах). Посимвольный разбор здесь корректно
различает голый `_` (-> `?` wildcard) и экранированный `\\_` (-> литерал `_`).
"""
out: list[str] = []
i = 0
n = len(pattern)
while i < n:
ch = pattern[i]
if ch == "\\" and i + 1 < n and pattern[i + 1] in ("%", "_", "\\"):
out.append(pattern[i + 1]) # экранированный символ -> литерал as-is
i += 2
continue
if ch == "%":
out.append("*")
elif ch == "_":
out.append("?")
else:
out.append(ch)
i += 1
return "".join(out)
def _in_oblast_or_ekb_scope(source_url: str) -> bool:
"""Локальная реплика WHERE-условия snapshot-запроса (ekb CTE OR oblast CTE)
через корректную LIKE-эмуляцию -- без поднятия БД."""
if fnmatch.fnmatchcase(source_url, _like_pattern_to_fnmatch("%/ekaterinburg/%")):
return True
return any(
fnmatch.fnmatchcase(source_url, _like_pattern_to_fnmatch(pat))
for pat in _OBLAST_AVITO_URL_PATTERNS
)
def test_oblast_avito_url_patterns_include_oblast_and_ekb_exclude_foreign_region() -> None:
"""#2576 DoD: листинг города области и екатеринбургский листинг проходят
scope-фильтр; листинг чужого региона (Москва/СПб) нет.
Использует корректную LIKE-эмуляцию (_like_pattern_to_fnmatch), а не наивный
`%` -> `*` replace (#2578 review — тот не различал бы `_`-семантику)."""
# Область (Каменск-Уральский, #2576 — реальный кейс из тикета) -- проходит.
assert _in_oblast_or_ekb_scope("https://www.avito.ru/kamensk-uralskiy/kvartiry/prodam_123")
# ЕКБ — по-прежнему проходит (не деградировал).
assert _in_oblast_or_ekb_scope("https://www.avito.ru/ekaterinburg/kvartiry/prodam_456")
# Чужой регион — НЕ проходит (иначе поехали бы Москва/СПб/Тюмень legacy-строки).
assert not _in_oblast_or_ekb_scope("https://www.avito.ru/moskva/kvartiry/prodam_789")
assert not _in_oblast_or_ekb_scope("https://www.avito.ru/sankt-peterburg/kvartiry/prodam_000")
def test_like_underscore_wildcard_regression_caught_by_escaped_patterns() -> None:
"""#2578 deep-review latent bug: Postgres `LIKE` трактует `_` как wildcard
"ровно один любой символ", а НЕ литерал. Два слага из пяти (nizhniy_tagil,
verhnyaya_pyshma) содержат `_` -- БЕЗ экранирования 'nizhniy_tagil' молча
совпал бы с 'nizhniyXtagil' (X = любой символ), т.е. коллизия слагов при
появлении похожего города. Сегодня коллизий нет (проверено на проде: raw vs
escaped паттерны дают одинаковые 776 совпадений), но дыра латентная.
Этот тест ДОЛЖЕН падать на RAW (неэкранированном) варианте паттерна -- именно
так выглядели продовые паттерны ДО фикса #2578 (`%/nizhniy_tagil/%`, без
`\\`). Экранированный прод-паттерн (_OBLAST_AVITO_URL_PATTERNS, ПОСЛЕ фикса)
коллизию отклоняет, точный слаг по-прежнему матчит (позитивный кейс жив).
"""
raw_pattern = "%/nizhniy_tagil/%" # как было бы БЕЗ фикса #2578 (голый '_')
escaped_pattern = next(p for p in _OBLAST_AVITO_URL_PATTERNS if "nizhniy" in p)
# Сам факт экранирования: прод-паттерн ДОЛЖЕН отличаться от raw ('_' -> '\_').
assert escaped_pattern != raw_pattern, "фикс #2578 должен экранировать '_' в avito_slug"
collision_url = "https://www.avito.ru/nizhniyXtagil/kvartiry/prodam_1"
exact_url = "https://www.avito.ru/nizhniy_tagil/kvartiry/prodam_1"
# RAW: '_' -- wildcard -> ложное совпадение с ЛЮБЫМ символом на его месте.
assert fnmatch.fnmatchcase(collision_url, _like_pattern_to_fnmatch(raw_pattern))
# Экранированный прод-паттерн (после фикса): '_' -- литерал -> коллизия отклонена.
assert not fnmatch.fnmatchcase(collision_url, _like_pattern_to_fnmatch(escaped_pattern))
# Позитивный кейс не сломан: точный слаг матчит ОБА варианта паттерна.
assert fnmatch.fnmatchcase(exact_url, _like_pattern_to_fnmatch(raw_pattern))
assert fnmatch.fnmatchcase(exact_url, _like_pattern_to_fnmatch(escaped_pattern))
@pytest.mark.asyncio

View file

@ -30,7 +30,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:
_wp_mock = MagicMock()
sys.modules.setdefault("weasyprint", _wp_mock)
from app.services.geocoder import GeocodeSuggestion # noqa: E402
from app.services.geocoder import GeocodeSuggestion, _names_non_ekb_city # noqa: E402
from app.tasks import backfill_listings_coords_geoportal as bcg # noqa: E402
from app.tasks.backfill_listings_coords_geoportal import ( # noqa: E402
BackfillCoordsResult,
@ -203,6 +203,76 @@ def test_unparseable_address_counted_as_no_address() -> None:
mock_geo.assert_not_called()
# ── городской гейт (#2583, H3) ─────────────────────────────────────────────────
def test_non_ekb_address_skipped_not_matched_against_ekb_registry() -> None:
"""Адрес другого города области (Нижний Тагил) НЕ должен матчиться против
EKB-only ekb_geoportal_buildings, даже если street+house буквально совпадают
с ЕКБ-домом («проспект Ленина 1» есть и в ЕКБ, и в Нижнем Тагиле, #2583 H3).
_names_non_ekb_city РЕАЛЬНАЯ функция (не мок), чтобы проверить настоящий гейт,
а не заглушку. _geoportal_house_match мокнут так, будто он ложно нашёл ЕКБ-дом
тест доказывает, что до этого вызова дело не доходит вообще.
"""
rows = [{"id": 100, "address": "г. Нижний Тагил, проспект Ленина, 1"}]
db = _make_db([rows, []])
# Sanity: реальная функция действительно считает этот адрес не-ЕКБ.
assert _names_non_ekb_city("г. Нижний Тагил, проспект Ленина, 1") is True
with (
patch(
"app.tasks.backfill_listings_coords_geoportal._geoportal_house_match",
return_value=_HIT, # ложное совпадение по street+house, если бы гейт не сработал
) as mock_geo,
patch("app.tasks.backfill_listings_coords_geoportal._parse_street_house") as mock_parse,
):
res = backfill_coords_from_geoportal(db, batch_size=500)
assert res.candidates == 1
assert res.skipped_non_ekb == 1
assert res.matched == 0
assert res.updated == 0
assert res.no_match == 0
assert res.no_address == 0
# Ни парсер, ни geoportal-матчер не должны были вызываться — гейт стоит раньше.
mock_parse.assert_not_called()
mock_geo.assert_not_called()
update_calls = [c for c in db.execute.call_args_list if "UPDATE" in str(c.args[0])]
assert len(update_calls) == 0
def test_ekb_address_still_matched_with_real_gate() -> None:
"""Явно ЕКБ-адрес по-прежнему проходит гейт и матчится как раньше (не деградация).
_names_non_ekb_city реальная функция: "Екатеринбург" в адресе имеет приоритет
над любым омонимом другого города области (см. docstring _names_non_ekb_city).
"""
rows = [{"id": 101, "address": "г. Екатеринбург, проспект Ленина, 1"}]
db = _make_db([rows, []])
assert _names_non_ekb_city("г. Екатеринбург, проспект Ленина, 1") is False
with (
patch(
"app.tasks.backfill_listings_coords_geoportal._geoportal_house_match",
return_value=_HIT,
) as mock_geo,
patch(
"app.tasks.backfill_listings_coords_geoportal._parse_street_house",
return_value=("проспект ленина", "1"),
),
):
res = backfill_coords_from_geoportal(db, batch_size=500)
assert res.candidates == 1
assert res.skipped_non_ekb == 0
assert res.matched == 1
assert res.updated == 1
mock_geo.assert_called_once_with(db, "проспект ленина", "1")
# ── idempotency ───────────────────────────────────────────────────────────────

View file

@ -0,0 +1,613 @@
"""Integration tests for #2552 auth-core: POST /login, /logout, dual-mode GET /me,
and rbac_guard session-cookie resolution.
Uses the REAL `rbac_guard` (app.core.rbac) + REAL `auth.router` / `me.router` wired
into an isolated FastAPI test app (same pattern as tests/test_rbac.py), with an
in-memory fake DB standing in for `tradein_users`/`tradein_sessions`:
- `app.core.rbac.SessionLocal` is monkeypatched (rbac_guard opens its own session,
it's middleware — no FastAPI DI available there).
- `app.core.db.get_db` is overridden via `app.dependency_overrides` (auth.py /
me.py use `Depends(get_db)`, the idiomatic FastAPI-testable path).
Both point at the SAME `_Store` instance per test, so a session created by POST
/login is immediately visible to rbac_guard's own DB round trip on the next request.
"""
from __future__ import annotations
import os
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from typing import Annotated, Any
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import pytest
from fastapi import FastAPI, Header
from fastapi.testclient import TestClient
from app.api.v1 import auth as auth_router
from app.api.v1 import me as me_router
from app.core import auth as auth_mod
from app.core import config
from app.core.db import get_db
from app.core.password import hash_password
from app.core.rbac import rbac_guard
# ---------------------------------------------------------------------------
# Fake DB backing tradein_users / tradein_sessions
# ---------------------------------------------------------------------------
class _Store:
def __init__(self) -> None:
self.users: dict[str, dict[str, Any]] = {}
self.sessions: dict[str, dict[str, Any]] = {}
self._next_id = 1
def add_user(
self,
username: str,
password_hash: str | None,
*,
role: str = "employee",
is_active: bool = True,
display_name: str | None = "Alice A.",
org_name: str | None = "Org LLC",
email: str | None = "alice@example.com",
) -> int:
uid = self._next_id
self._next_id += 1
self.users[username] = {
"id": uid,
"username": username,
"password_hash": password_hash,
"role": role,
"is_active": is_active,
"display_name": display_name,
"org_name": org_name,
"email": email,
}
return uid
def user_by_id(self, uid: int) -> dict[str, Any] | None:
for u in self.users.values():
if u["id"] == uid:
return u
return None
def add_expired_session(self, token: str, user_id: int) -> None:
now = datetime.now(UTC)
self.sessions[token] = {
"user_id": user_id,
"expires_at": now - timedelta(minutes=1),
"last_seen_at": now - timedelta(minutes=1),
}
class _FakeDB:
"""Minimal Session stand-in dispatching on SQL text — see module docstring."""
def __init__(self, store: _Store) -> None:
self.store = store
def __enter__(self) -> _FakeDB:
return self
def __exit__(self, *exc: object) -> bool:
return False
def close(self) -> None:
pass
def commit(self) -> None:
pass
def rollback(self) -> None:
pass
def execute(self, stmt: object, params: dict[str, Any] | None = None) -> SimpleNamespace:
sql = str(stmt)
p = params or {}
if "INSERT INTO tradein_sessions" in sql:
now = datetime.now(UTC)
self.store.sessions[p["token"]] = {
"user_id": p["user_id"],
"expires_at": now + timedelta(hours=p["ttl_hours"]),
"last_seen_at": now,
}
return SimpleNamespace(fetchone=lambda: None)
if "UPDATE tradein_sessions" in sql and "SET last_seen_at" in sql:
sess = self.store.sessions.get(p["token"])
if sess is not None:
now = datetime.now(UTC)
sess["last_seen_at"] = now
sess["expires_at"] = now + timedelta(hours=p["ttl_hours"])
return SimpleNamespace(fetchone=lambda: None)
if "DELETE FROM tradein_sessions WHERE token" in sql:
self.store.sessions.pop(p["token"], None)
return SimpleNamespace(fetchone=lambda: None)
if "DELETE FROM tradein_sessions WHERE user_id" in sql:
uid = p["user_id"]
for tok in [t for t, s in self.store.sessions.items() if s["user_id"] == uid]:
del self.store.sessions[tok]
return SimpleNamespace(fetchone=lambda: None)
if "FROM tradein_sessions s" in sql and "JOIN tradein_users u" in sql:
sess = self.store.sessions.get(p["token"])
if sess is None:
return SimpleNamespace(fetchone=lambda: None)
user = self.store.user_by_id(sess["user_id"])
if user is None:
return SimpleNamespace(fetchone=lambda: None)
row = SimpleNamespace(
user_id=sess["user_id"],
expires_at=sess["expires_at"],
last_seen_at=sess["last_seen_at"],
username=user["username"],
role=user["role"],
display_name=user["display_name"],
org_name=user["org_name"],
email=user["email"],
is_active=user["is_active"],
)
return SimpleNamespace(fetchone=lambda: row)
if "FROM tradein_users" in sql:
user = self.store.users.get(p["username"])
if user is None:
return SimpleNamespace(fetchone=lambda: None)
row = SimpleNamespace(**user)
return SimpleNamespace(fetchone=lambda: row)
raise AssertionError(f"unhandled fake SQL in test_auth_api: {sql!r}")
# ---------------------------------------------------------------------------
# Test app
# ---------------------------------------------------------------------------
def _build_test_app(store: _Store) -> FastAPI:
app = FastAPI()
app.middleware("http")(rbac_guard)
app.include_router(auth_router.router, prefix="/api/v1/auth", tags=["auth"])
app.include_router(me_router.router, prefix="/api/v1", tags=["me"])
@app.get("/api/v1/trade-in/dummy")
async def tradein_dummy() -> dict:
return {"ok": True}
@app.get("/api/v1/trade-in/whoami")
async def tradein_whoami(
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
) -> dict:
"""Echoes the X-Authenticated-User header exactly as a downstream handler
(`_assert_estimate_access*`, `account_quota`, etc.) would see it used to
assert session-identity wins over a client-forged header (#2552 spoof fix)."""
return {"user": x_authenticated_user}
# Внутренние инструменты, закрытые от клиентских DB-ролей 2026-07-31
# («Доля в продаже» / «Кэш»). Ручки настоящие (не заглушки rbac_guard'а),
# чтобы 403 приходил именно от scope-чека, а не от отсутствия роута.
@app.get("/api/v1/buildings/sale-share")
async def buildings_sale_share() -> dict:
return {"ok": True}
@app.get("/api/v1/trade-in/cache-stats")
async def tradein_cache_stats() -> dict:
return {"ok": True}
def _override_get_db(): # generator dependency — matches app.core.db.get_db shape
yield _FakeDB(store)
app.dependency_overrides[get_db] = _override_get_db
return app
@pytest.fixture(autouse=True)
def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
auth_mod.reset_cache_for_tests()
auth_router._LOGIN_LIMITER._hits.clear()
monkeypatch.setattr(config.settings, "auth_mode", "dual")
@pytest.fixture
def store() -> _Store:
return _Store()
@pytest.fixture
def client(store: _Store, monkeypatch: pytest.MonkeyPatch) -> TestClient:
monkeypatch.setattr("app.core.rbac.SessionLocal", lambda: _FakeDB(store))
# base_url=https:// — login sets the session cookie with Secure=True (real prod
# behaviour, not weakened for tests); httpx's cookie jar silently drops Secure
# cookies on a plain-http connection, so a plain http://testserver client would
# never resend the cookie on subsequent requests within the same test.
return TestClient(_build_test_app(store), base_url="https://testserver")
# ---------------------------------------------------------------------------
# POST /login
# ---------------------------------------------------------------------------
def test_login_happy_path_sets_cookie(client: TestClient, store: _Store) -> None:
store.add_user("alice", hash_password("Secret123!"), role="employee")
resp = client.post("/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"})
assert resp.status_code == 200, resp.text
assert resp.json() == {"ok": True}
cookie_name = config.settings.session_cookie_name
assert cookie_name in resp.cookies
assert resp.cookies[cookie_name]
# Сессия реально создана в сторе под этим токеном.
assert resp.cookies[cookie_name] in store.sessions
def test_login_wrong_password_401_and_logs_failed_event(
client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch
) -> None:
store.add_user("alice", hash_password("Secret123!"), role="employee")
events: list[dict[str, Any]] = []
monkeypatch.setattr(auth_router, "schedule_event", lambda **kw: events.append(kw))
resp = client.post("/api/v1/auth/login", json={"username": "alice", "password": "wrong"})
assert resp.status_code == 401
assert "detail" in resp.json()
assert config.settings.session_cookie_name not in resp.cookies
assert any(e["event_type"] == "login_failed" for e in events)
failed = next(e for e in events if e["event_type"] == "login_failed")
assert failed["username"] == "alice"
# Raw-пароль никогда не попадает в событие.
assert "wrong" not in str(failed)
def test_login_unknown_username_401_generic_message(client: TestClient) -> None:
resp = client.post("/api/v1/auth/login", json={"username": "ghost", "password": "whatever"})
assert resp.status_code == 401
# НЕ раскрываем, что юзера не существует — то же сообщение, что и wrong-password.
body_ghost = resp.json()["detail"]
resp2 = client.post("/api/v1/auth/login", json={"username": "ghost2", "password": "x"})
assert resp2.json()["detail"] == body_ghost
def test_login_inactive_user_401(client: TestClient, store: _Store) -> None:
store.add_user("bob", hash_password("Secret123!"), role="employee", is_active=False)
resp = client.post("/api/v1/auth/login", json={"username": "bob", "password": "Secret123!"})
assert resp.status_code == 401
def test_login_null_password_hash_401(client: TestClient, store: _Store) -> None:
store.add_user("carol", None, role="employee")
resp = client.post("/api/v1/auth/login", json={"username": "carol", "password": "anything"})
assert resp.status_code == 401
def test_login_always_calls_verify_password_timing_oracle_guard(
client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch
) -> None:
"""#2552 post-review Medium 2: `verify_password` должен выполняться ровно
один раз на КАЖДУЮ попытку логина включая неизвестный username и NULL
password_hash иначе короткое замыкание даёт наблюдаемый timing-oracle
для user-enumeration. Тест не измеряет тайминг (флейки в CI), а проверяет
сам факт + аргумент вызова через monkeypatch-счётчик."""
store.add_user("alice", hash_password("Secret123!"), role="employee")
store.add_user("nullhash", None, role="employee")
calls: list[str] = []
real_verify = auth_router.verify_password
def _counting_verify(plain: str, hashed: str) -> bool:
calls.append(hashed)
return real_verify(plain, hashed)
monkeypatch.setattr(auth_router, "verify_password", _counting_verify)
resp_unknown = client.post("/api/v1/auth/login", json={"username": "ghost", "password": "x"})
assert resp_unknown.status_code == 401
resp_null_hash = client.post(
"/api/v1/auth/login", json={"username": "nullhash", "password": "x"}
)
assert resp_null_hash.status_code == 401
resp_wrong_pw = client.post(
"/api/v1/auth/login", json={"username": "alice", "password": "wrong"}
)
assert resp_wrong_pw.status_code == 401
assert len(calls) == 3
# Unknown user / NULL hash — сверяется против dummy-хеша, не против NULL.
assert calls[0] == auth_router._DUMMY_PASSWORD_HASH
assert calls[1] == auth_router._DUMMY_PASSWORD_HASH
# Реальный юзер с реальным hash — НЕ dummy.
assert calls[2] != auth_router._DUMMY_PASSWORD_HASH
def test_login_rate_limit_429(client: TestClient, store: _Store) -> None:
store.add_user("dave", hash_password("Secret123!"), role="employee")
limit = config.settings.login_rate_limit
for _ in range(limit):
resp = client.post("/api/v1/auth/login", json={"username": "dave", "password": "wrong"})
assert resp.status_code == 401
resp = client.post("/api/v1/auth/login", json={"username": "dave", "password": "wrong"})
assert resp.status_code == 429
assert "Retry-After" in resp.headers
# ---------------------------------------------------------------------------
# POST /logout
# ---------------------------------------------------------------------------
def test_logout_revokes_session_and_clears_cookie(client: TestClient, store: _Store) -> None:
store.add_user("alice", hash_password("Secret123!"), role="employee")
login_resp = client.post(
"/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"}
)
token = login_resp.cookies[config.settings.session_cookie_name]
assert token in store.sessions
logout_resp = client.post("/api/v1/auth/logout")
assert logout_resp.status_code == 200
assert logout_resp.json() == {"ok": True}
assert token not in store.sessions
# ---------------------------------------------------------------------------
# GET /me — session-first + dual-mode legacy fallback
# ---------------------------------------------------------------------------
def test_me_with_session_cookie_returns_db_role(client: TestClient, store: _Store) -> None:
store.add_user(
"alice",
hash_password("Secret123!"),
role="employee",
display_name="Алиса",
org_name="ООО Ромашка",
email="alice@romashka.ru",
)
client.post("/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"})
resp = client.get("/api/v1/me")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["username"] == "alice"
assert body["role"] == "employee"
assert "/trade-in/**" in body["allowed_paths"]
assert "/admin/**" in body["deny_paths"]
# Пункты меню «Доля в продаже» / «Кэш» прячет Topbar, фильтруя NAV_ITEMS по
# deny_paths ИЗ /me — т.е. видимость держится на ЭТОМ выводе, а не только на
# DB_ROLE_PATHS. Сборка dict-а в app/api/v1/me.py может регрессировать
# независимо от get_db_role_scope, поэтому пиним её здесь.
assert "/trade-in/sale-share/**" in body["deny_paths"]
assert "/trade-in/cache/**" in body["deny_paths"]
assert body["display_name"] == "Алиса"
assert body["org"] == "ООО Ромашка"
assert body["email"] == "alice@romashka.ru"
def test_me_manager_role_gets_team_path(client: TestClient, store: _Store) -> None:
store.add_user("mgr", hash_password("Secret123!"), role="manager")
client.post("/api/v1/auth/login", json={"username": "mgr", "password": "Secret123!"})
resp = client.get("/api/v1/me")
assert resp.status_code == 200
body = resp.json()
assert "/api/v1/team/**" in body["allowed_paths"]
# Тот же пин, что и для employee: manager (роль praktika) не должен получать
# из /me deny-список без внутренних разделов — иначе пункты вернутся в топбар.
assert "/trade-in/sale-share/**" in body["deny_paths"]
assert "/trade-in/cache/**" in body["deny_paths"]
def test_me_without_cookie_dual_mode_legacy_still_works(client: TestClient) -> None:
"""Regression guard: без сессии, auth_mode=dual — legacy X-Authenticated-User
путь через roles.yaml работает БЕЗ ИЗМЕНЕНИЙ."""
resp = client.get("/api/v1/me", headers={"X-Authenticated-User": "admin"})
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["username"] == "admin"
assert body["role"] == "admin"
assert body["allowed_paths"] == ["/**"]
def test_me_no_cookie_no_header_401(client: TestClient) -> None:
resp = client.get("/api/v1/me")
assert resp.status_code == 401
def test_expired_session_falls_back_to_no_auth(client: TestClient, store: _Store) -> None:
"""Истёкшая сессия трактуется как отсутствие cookie: без legacy-заголовка — 401."""
uid = store.add_user("alice", hash_password("Secret123!"), role="employee")
store.add_expired_session("expired-tok", uid)
client.cookies.set(config.settings.session_cookie_name, "expired-tok")
resp = client.get("/api/v1/trade-in/dummy")
assert resp.status_code == 401
def test_expired_session_falls_back_to_legacy_header_in_dual_mode(
client: TestClient, store: _Store
) -> None:
"""Истёкшая сессия + валидный legacy header в dual-mode — header отрабатывает."""
uid = store.add_user("alice", hash_password("Secret123!"), role="employee")
store.add_expired_session("expired-tok", uid)
client.cookies.set(config.settings.session_cookie_name, "expired-tok")
resp = client.get(
"/api/v1/trade-in/dummy",
headers={"X-Authenticated-User": "kopylov"},
)
assert resp.status_code == 200
assert resp.json() == {"ok": True}
# ---------------------------------------------------------------------------
# rbac_guard dual vs db_only
# ---------------------------------------------------------------------------
def test_db_only_mode_rejects_legacy_header_without_session(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(config.settings, "auth_mode", "db_only")
resp = client.get(
"/api/v1/trade-in/dummy",
headers={"X-Authenticated-User": "admin"},
)
assert resp.status_code == 401
assert "session" in resp.json()["detail"].lower()
def test_db_only_mode_accepts_valid_session(
client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch
) -> None:
store.add_user("alice", hash_password("Secret123!"), role="employee")
client.post("/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"})
monkeypatch.setattr(config.settings, "auth_mode", "db_only")
resp = client.get("/api/v1/trade-in/dummy")
assert resp.status_code == 200
assert resp.json() == {"ok": True}
def test_session_user_can_reach_tradein_but_not_admin(client: TestClient, store: _Store) -> None:
store.add_user("alice", hash_password("Secret123!"), role="employee")
client.post("/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"})
ok = client.get("/api/v1/trade-in/dummy")
assert ok.status_code == 200
denied = client.get("/api/v1/admin/dummy")
# rbac_guard's admin-gate matches the path regex BEFORE routing even happens
# (route isn't registered on this test app) — role=employee != admin -> 403,
# never a 404 (a bare "any non-2xx" assertion would mask a rbac_guard typo).
assert denied.status_code == 403
# ---------------------------------------------------------------------------
# 2026-07-31: внутренние разделы («Доля в продаже» / «Кэш») закрыты от клиентов —
# СКВОЗЬ РЕАЛЬНЫЙ rbac_guard по SESSION-ветке (from_session=True).
#
# Тесты в tests/test_rbac.py проверяют матчеры напрямую + guard по ЛЕГАСИ
# trusted-header ветке (is_path_allowed / roles.yaml). Но в проде клиентские
# аккаунты (praktika и прочие DB-юзеры) ходят именно session-cookie'ой, где
# scope считает ДРУГАЯ ветка — `_db_role_path_allowed(role, external_path)`.
# Без тестов ниже её можно было сломать, не уронив ни одного теста.
#
# Пути тут — ВНУТРЕННИЕ (Caddy срезает внешний /trade-in), rbac_guard
# восстанавливает внешний как '/trade-in' + path.
# ---------------------------------------------------------------------------
_INTERNAL_TOOL_API = ("/api/v1/buildings/sale-share", "/api/v1/trade-in/cache-stats")
def test_session_manager_denied_on_internal_tool_api(client: TestClient, store: _Store) -> None:
store.add_user("mgr", hash_password("Secret123!"), role="manager")
client.post("/api/v1/auth/login", json={"username": "mgr", "password": "Secret123!"})
for path in _INTERNAL_TOOL_API:
resp = client.get(path)
assert resp.status_code == 403, f"manager {path}: {resp.status_code} {resp.text}"
assert "forbidden for role" in resp.json()["detail"].lower()
# ...и при этом основной продукт для той же сессии открыт (иначе тест выше
# проходил бы и на «сломали scope целиком»).
ok = client.get("/api/v1/trade-in/dummy")
assert ok.status_code == 200, ok.text
def test_session_employee_denied_on_internal_tool_api(client: TestClient, store: _Store) -> None:
store.add_user("emp", hash_password("Secret123!"), role="employee")
client.post("/api/v1/auth/login", json={"username": "emp", "password": "Secret123!"})
for path in _INTERNAL_TOOL_API:
resp = client.get(path)
assert resp.status_code == 403, f"employee {path}: {resp.status_code} {resp.text}"
assert "forbidden for role" in resp.json()["detail"].lower()
ok = client.get("/api/v1/trade-in/dummy")
assert ok.status_code == 200, ok.text
def test_session_admin_keeps_internal_tool_api(client: TestClient, store: _Store) -> None:
"""Контрольная группа: DB-роль admin ('/**') разделы по-прежнему видит."""
store.add_user("root", hash_password("Secret123!"), role="admin")
client.post("/api/v1/auth/login", json={"username": "root", "password": "Secret123!"})
for path in _INTERNAL_TOOL_API:
resp = client.get(path)
assert resp.status_code == 200, f"admin {path}: {resp.text}"
# ---------------------------------------------------------------------------
# #2552 post-review CRITICAL fix: session identity must win over a spoofed
# client-sent X-Authenticated-User header (was a skip-if-present bug — the
# forged header used to override the session for every downstream reader of
# the raw header: _assert_estimate_access*, account_quota, /trade-in/history,
# support.py — in BOTH auth_mode=dual and db_only).
# ---------------------------------------------------------------------------
def test_session_identity_wins_over_spoofed_header_dual_mode(
client: TestClient, store: _Store
) -> None:
store.add_user("alice", hash_password("Secret123!"), role="employee")
store.add_user("victim", hash_password("Secret123!"), role="employee")
client.post("/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"})
resp = client.get(
"/api/v1/trade-in/whoami",
headers={"X-Authenticated-User": "victim"},
)
assert resp.status_code == 200
assert resp.json()["user"] == "alice"
def test_session_identity_wins_over_spoofed_header_db_only_mode(
client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch
) -> None:
store.add_user("alice", hash_password("Secret123!"), role="employee")
store.add_user("victim", hash_password("Secret123!"), role="employee")
client.post("/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"})
monkeypatch.setattr(config.settings, "auth_mode", "db_only")
resp = client.get(
"/api/v1/trade-in/whoami",
headers={"X-Authenticated-User": "victim"},
)
assert resp.status_code == 200
assert resp.json()["user"] == "alice"
def test_cyrillic_username_session_propagation_does_not_500(
client: TestClient, store: _Store
) -> None:
"""#2552 post-review Medium 1: `.encode("latin-1")` без errors="replace" на
кириллическом username крашил бы КАЖДЫЙ запрос такого юзера с 500."""
store.add_user("алиса", hash_password("Secret123!"), role="employee")
login_resp = client.post(
"/api/v1/auth/login", json={"username": "алиса", "password": "Secret123!"}
)
assert login_resp.status_code == 200, login_resp.text
resp = client.get("/api/v1/trade-in/whoami")
assert resp.status_code == 200, resp.text
# latin-1 "replace" гарантированно не крашит — точное значение (что именно
# получится из non-latin1 байт) не является контрактом, важно отсутствие 500.
assert resp.json()["user"] is not None

View file

@ -0,0 +1,327 @@
"""Tests for app.services.auth_session — session CRUD + DB-role scope mapping (#2552).
Coverage:
- create_session: INSERT with CAST(...) (never `:x::type`), commit, unique tokens.
- get_session_user: valid/expired/inactive/missing-row + sliding refresh (only when
last_seen_at is stale, best-effort a refresh failure still returns the user).
- get_user_by_username: found/not-found.
- revoke_session / revoke_user_sessions: DELETE + commit.
- get_db_role_scope: employee/manager/admin/unknown mapping.
All functions here take `db: Session` as a plain argument (no SessionLocal() opened
internally) unit tests just pass a hand-rolled fake, mirroring the `_FakeSession`
pattern from tests/test_user_events.py but adapted for `.fetchone()`-based reads.
"""
from __future__ import annotations
import os
import re
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from typing import Any
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.services import auth_session as svc
# ---------------------------------------------------------------------------
# Fake DB session
# ---------------------------------------------------------------------------
class _FakeDB:
"""Minimal `Session` stand-in: queued `.fetchone()` results per `execute()` call,
in call order. `execute()` beyond the queue returns a result with `fetchone()
-> None`. Set `.raise_on_call = N` to make the Nth `execute()` (1-indexed) raise.
"""
def __init__(self, rows: list[Any] | None = None) -> None:
self._rows: list[Any] = list(rows or [])
self.executed: list[tuple[str, dict[str, Any] | None]] = []
self.committed = 0
self.rolled_back = 0
self.raise_on_call: int | None = None
def execute(self, stmt: object, params: dict[str, Any] | None = None) -> SimpleNamespace:
call_no = len(self.executed) + 1
self.executed.append((str(stmt), params))
if self.raise_on_call == call_no:
raise RuntimeError("simulated DB failure")
row = self._rows.pop(0) if self._rows else None
return SimpleNamespace(fetchone=lambda: row)
def commit(self) -> None:
self.committed += 1
def rollback(self) -> None:
self.rolled_back += 1
def _session_row(
*,
user_id: int = 1,
expires_at: datetime | None = None,
last_seen_at: datetime | None = None,
username: str = "alice",
role: str = "employee",
is_active: bool = True,
) -> SimpleNamespace:
now = datetime.now(UTC)
return SimpleNamespace(
user_id=user_id,
expires_at=expires_at if expires_at is not None else now + timedelta(hours=1),
last_seen_at=last_seen_at if last_seen_at is not None else now,
username=username,
role=role,
display_name="Alice A.",
org_name="Org LLC",
email="alice@example.com",
is_active=is_active,
)
def _user_row(
*,
user_id: int = 1,
username: str = "alice",
password_hash: str | None = "hash",
role: str = "employee",
is_active: bool = True,
) -> SimpleNamespace:
return SimpleNamespace(
id=user_id,
username=username,
password_hash=password_hash,
role=role,
is_active=is_active,
display_name="Alice A.",
org_name="Org LLC",
email="alice@example.com",
)
# ---------------------------------------------------------------------------
# create_session
# ---------------------------------------------------------------------------
def test_create_session_inserts_and_commits() -> None:
db = _FakeDB()
token = svc.create_session(db, user_id=42, ip="1.2.3.4", user_agent="pytest")
assert db.committed == 1
assert len(db.executed) == 1
sql, params = db.executed[0]
assert "INSERT INTO tradein_sessions" in sql
assert params is not None
assert params["user_id"] == 42
assert params["ip"] == "1.2.3.4"
assert params["user_agent"] == "pytest"
assert params["token"] == token
assert isinstance(token, str)
assert len(token) >= 32
def test_create_session_cast_not_doublecolon() -> None:
db = _FakeDB()
svc.create_session(db, user_id=1)
sql, _ = db.executed[0]
assert not re.search(r":\w+::\w", sql)
assert "CAST(:ttl_hours AS integer)" in sql
assert "CAST(:ip AS inet)" in sql
def test_create_session_tokens_are_unique() -> None:
db = _FakeDB()
t1 = svc.create_session(db, user_id=1)
t2 = svc.create_session(db, user_id=1)
assert t1 != t2
# ---------------------------------------------------------------------------
# get_session_user
# ---------------------------------------------------------------------------
def test_get_session_user_no_token_returns_none() -> None:
db = _FakeDB()
assert svc.get_session_user(db, "") is None
assert db.executed == []
def test_get_session_user_missing_row_returns_none() -> None:
db = _FakeDB(rows=[None])
assert svc.get_session_user(db, "tok") is None
sql, params = db.executed[0]
assert "FROM tradein_sessions s" in sql
assert "JOIN tradein_users u" in sql
assert params == {"token": "tok"}
def test_get_session_user_expired_returns_none() -> None:
now = datetime.now(UTC)
db = _FakeDB(rows=[_session_row(expires_at=now - timedelta(minutes=1))])
assert svc.get_session_user(db, "tok") is None
# Никакого sliding-refresh UPDATE для невалидной сессии.
assert len(db.executed) == 1
def test_get_session_user_inactive_returns_none() -> None:
db = _FakeDB(rows=[_session_row(is_active=False)])
assert svc.get_session_user(db, "tok") is None
assert len(db.executed) == 1
def test_get_session_user_valid_recent_no_refresh() -> None:
"""last_seen_at свежий (<5 мин) — sliding refresh НЕ триггерится."""
now = datetime.now(UTC)
db = _FakeDB(rows=[_session_row(last_seen_at=now - timedelta(minutes=1))])
result = svc.get_session_user(db, "tok")
assert result is not None
assert result["username"] == "alice"
assert result["role"] == "employee"
assert result["display_name"] == "Alice A."
assert result["org_name"] == "Org LLC"
assert result["email"] == "alice@example.com"
assert result["user_id"] == 1
# Только 1 execute (SELECT) — никакого UPDATE.
assert len(db.executed) == 1
assert db.committed == 0
def test_get_session_user_stale_last_seen_triggers_refresh() -> None:
"""last_seen_at старше 5 минут — один UPDATE (sliding refresh) + commit."""
now = datetime.now(UTC)
db = _FakeDB(rows=[_session_row(last_seen_at=now - timedelta(minutes=10))])
result = svc.get_session_user(db, "tok")
assert result is not None
assert len(db.executed) == 2
update_sql, update_params = db.executed[1]
assert "UPDATE tradein_sessions" in update_sql
assert "SET last_seen_at" in update_sql
assert not re.search(r":\w+::\w", update_sql)
assert "CAST(:ttl_hours AS integer)" in update_sql
assert update_params == {"ttl_hours": 720, "token": "tok"}
assert db.committed == 1
def test_get_session_user_refresh_failure_is_swallowed() -> None:
"""Sliding-refresh UPDATE падает — всё равно возвращаем валидного юзера
(best-effort refresh, не часть решения "валидна ли сессия")."""
now = datetime.now(UTC)
db = _FakeDB(rows=[_session_row(last_seen_at=now - timedelta(minutes=10))])
db.raise_on_call = 2
result = svc.get_session_user(db, "tok")
assert result is not None
assert result["username"] == "alice"
assert db.rolled_back == 1
assert db.committed == 0
# ---------------------------------------------------------------------------
# get_user_by_username
# ---------------------------------------------------------------------------
def test_get_user_by_username_found() -> None:
db = _FakeDB(rows=[_user_row()])
user = svc.get_user_by_username(db, "alice")
assert user is not None
assert user["username"] == "alice"
assert user["password_hash"] == "hash"
assert user["role"] == "employee"
assert user["is_active"] is True
sql, params = db.executed[0]
assert "FROM tradein_users" in sql
assert params == {"username": "alice"}
def test_get_user_by_username_not_found() -> None:
db = _FakeDB(rows=[None])
assert svc.get_user_by_username(db, "ghost") is None
# ---------------------------------------------------------------------------
# revoke_session / revoke_user_sessions
# ---------------------------------------------------------------------------
def test_revoke_session_deletes_and_commits() -> None:
db = _FakeDB()
svc.revoke_session(db, "tok")
assert db.committed == 1
sql, params = db.executed[0]
assert "DELETE FROM tradein_sessions" in sql
assert "token" in sql
assert params == {"token": "tok"}
def test_revoke_user_sessions_deletes_and_commits() -> None:
db = _FakeDB()
svc.revoke_user_sessions(db, 7)
assert db.committed == 1
sql, params = db.executed[0]
assert "DELETE FROM tradein_sessions" in sql
assert "user_id" in sql
assert params == {"user_id": 7}
# ---------------------------------------------------------------------------
# get_db_role_scope
# ---------------------------------------------------------------------------
def test_get_db_role_scope_employee_matches_legacy_pilot() -> None:
paths, deny = svc.get_db_role_scope("employee")
assert "/trade-in/**" in paths
assert "/trade-in/api/v1/**" in paths
assert "/admin/**" in deny
assert "/api/v1/admin/**" in deny
def test_get_db_role_scope_manager_adds_team_path() -> None:
paths, deny = svc.get_db_role_scope("manager")
assert "/trade-in/**" in paths
assert "/api/v1/team/**" in paths
assert "/admin/**" in deny
# «Доля в продаже» и «Кэш» — внутренние инструменты (аналитика рынка / состояние
# кэшей и скраперов), клиентские роли их не видят (решение владельца 2026-07-31).
# В deny И страницы, И их API: один список гейтит пункт меню (Topbar через /me),
# страницу (RouteGuard) и серверные ручки (rbac_guard).
_INTERNAL_TOOL_DENY = (
"/trade-in/sale-share/**",
"/trade-in/cache/**",
"/trade-in/api/v1/buildings/**",
# Глоб, а не точный путь: точный обходится трейлинг-слэшем (см. NB в
# app.services.auth_session над DB_ROLE_PATHS).
"/trade-in/api/v1/trade-in/cache-stats/**",
)
def test_get_db_role_scope_client_roles_deny_internal_tools() -> None:
for role in ("manager", "employee"):
_, deny = svc.get_db_role_scope(role)
for pattern in _INTERNAL_TOOL_DENY:
assert pattern in deny, f"{role} deny missing {pattern}"
def test_get_db_role_scope_admin_full_access() -> None:
paths, deny = svc.get_db_role_scope("admin")
assert paths == ["/**"]
assert deny == []
def test_get_db_role_scope_unknown_role_denies_all() -> None:
paths, deny = svc.get_db_role_scope("ghost")
assert paths == []
assert deny == ["/**"]

View file

@ -27,6 +27,41 @@ def test_ekb_anchors_count() -> None:
assert isinstance(name, str) and name
# ── resolve_city_name (#2594) ────────────────────────────────────────────────
def test_resolve_city_name_known_oblast_slugs() -> None:
"""Каждый city_slug из CITY_LOCATIONS резолвится в человекочитаемое имя."""
from scraper_kit.orchestration.pipeline import CITY_LOCATIONS, resolve_city_name
expected = {
"nizhniy_tagil": "Нижний Тагил",
"kamensk_uralskiy": "Каменск-Уральский",
"pervouralsk": "Первоуральск",
"verkhnyaya_pyshma": "Верхняя Пышма",
"serov": "Серов",
}
# CITY_DISPLAY_NAMES обязан покрывать ровно те же slug'и, что CITY_LOCATIONS
# (иначе oblast-город бы тихо получил ЕКБ-дефолт вместо своего имени).
assert set(expected) == set(CITY_LOCATIONS)
for slug, name in expected.items():
assert resolve_city_name(slug) == name
def test_resolve_city_name_none_defaults_to_ekaterinburg() -> None:
"""city_slug=None — ЕКБ-развёртка той же функции, НЕ «город неизвестен» (#2594 симметрия)."""
from scraper_kit.orchestration.pipeline import EKATERINBURG_CITY_NAME, resolve_city_name
assert resolve_city_name(None) == EKATERINBURG_CITY_NAME == "Екатеринбург"
def test_resolve_city_name_unknown_slug_defaults_to_ekaterinburg() -> None:
"""Неизвестный slug — тот же ЕКБ-дефолт, что и get_city_location/get_city_anchors."""
from scraper_kit.orchestration.pipeline import resolve_city_name
assert resolve_city_name("nonexistent_city") == "Екатеринбург"
# ── CitySweepCounters ───────────────────────────────────────────────────────

View file

@ -260,7 +260,6 @@ async def test_geocode_uses_geoportal_first() -> None:
patch("app.services.geocoder._cadastral_house_match") as mock_cad,
patch("app.services.geocoder._cadastral_forward_sync") as mock_forward,
patch("app.services.geocoder._cache_put"),
patch("app.services.geocoder._yandex_lookup", new_callable=AsyncMock) as mock_yandex,
):
result = await geocode("Космонавтов 7б", db)
@ -273,7 +272,6 @@ async def test_geocode_uses_geoportal_first() -> None:
# geoportal hit short-circuits everything downstream
mock_cad.assert_not_called()
mock_forward.assert_not_called()
mock_yandex.assert_not_called()
async def test_geocode_falls_through_to_cadastral_when_geoportal_misses() -> None:
@ -295,7 +293,6 @@ async def test_geocode_falls_through_to_cadastral_when_geoportal_misses() -> Non
patch("app.services.geocoder._cadastral_house_match", return_value=cad_hit) as mock_cad,
patch("app.services.geocoder._cadastral_forward_sync") as mock_forward,
patch("app.services.geocoder._cache_put"),
patch("app.services.geocoder._yandex_lookup", new_callable=AsyncMock) as mock_yandex,
):
result = await geocode("Серова 27", db)
@ -304,7 +301,6 @@ async def test_geocode_falls_through_to_cadastral_when_geoportal_misses() -> Non
mock_geo.assert_called_once()
mock_cad.assert_called_once()
mock_forward.assert_not_called()
mock_yandex.assert_not_called()
async def test_geocode_skips_geoportal_when_parse_fails() -> None:
@ -319,14 +315,12 @@ async def test_geocode_skips_geoportal_when_parse_fails() -> None:
patch("app.services.geocoder._cadastral_house_match") as mock_cad,
patch("app.services.geocoder._cadastral_forward_sync", return_value=[]) as mock_forward,
patch("app.services.geocoder._cache_put"),
patch("app.services.geocoder.settings") as mock_settings,
patch(
"app.services.geocoder._nominatim_lookup",
new_callable=AsyncMock,
return_value=None,
),
):
mock_settings.yandex_geocoder_api_key = None
result = await geocode("полный мусор без дома", db)
assert result is None

View file

@ -17,6 +17,7 @@ DB-facing helpers only) — the same pattern as test_estimator_radius_floor.py.
from __future__ import annotations
import math
import os
from datetime import UTC, datetime
from typing import Any
@ -100,6 +101,24 @@ def _run_estimate(
# what the always-executed final fallback tier returns.
return_value=(list(analogs), False, "W"),
),
# #2581: explicit, not accidental. Before this fix, `db = MagicMock()`
# was left UNCONFIGURED for `_fetch_anchor_comps` — since it's not
# patched here, it ran for REAL against the mock session, and
# `db.execute(...).mappings().all()` on a bare MagicMock silently
# returns `[]` (MagicMock's default `__iter__` == `iter([])`), so
# `_fetch_anchor_comps` ALWAYS returned `([], None)` regardless of
# what the real SQL would do. That made this whole test file blind
# to the #2581 anchor cross-city leak: `anchor_tier` could never
# observe becoming 'A' here, so a regression that makes Tier A
# wrongly match an EKB listing for a Серов/Тагил subject (which
# would then BLOCK this very deals-headline-fallback via the
# `anchor_tier is None` gate, see _price_from_inputs #oblast-D) was
# invisible. Patched explicitly now so the assumption is documented
# and intentional. The behavioral regression test itself lives in
# test_non_ekb_anchor_not_leaked_from_ekb_street_collision below,
# which does NOT patch `_fetch_anchor_comps` — it exercises the
# real SQL/geo-bound logic instead.
patch("app.services.estimator._fetch_anchor_comps", return_value=([], None)),
patch("app.services.estimator._fetch_deals", return_value=[]),
patch(
"app.services.estimator._get_or_fetch_imv_cached",
@ -236,3 +255,258 @@ def test_ekb_with_dense_listings_ignores_deals_fallback() -> None:
# n_analogs must reflect the real listing count (deals-fallback never ran).
assert est.n_analogs == len(analogs)
assert 140_000 <= est.median_price_per_m2 <= 150_000
# ── #2581 regression: Tier A anchor must not leak cross-city street collisions ─
#
# Unlike the tests above (which explicitly patch `_fetch_anchor_comps` — see
# the comment on that patch in `_run_estimate`), the tests below do NOT patch
# it: they exercise the real Tier A SQL/geo-bound logic against a hand-rolled
# `db.execute` fake that computes genuine haversine distance, mirroring what
# Postgres' ST_DWithin would decide. This is what actually catches a #2581-class
# regression; the tests above only prove the deals-fallback logic given
# anchor_tier=None as an already-resolved input.
def _haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Real great-circle distance — stands in for what Postgres ST_DWithin computes."""
r = 6_371_000.0
p1, p2 = math.radians(lat1), math.radians(lat2)
dphi = math.radians(lat2 - lat1)
dlmb = math.radians(lon2 - lon1)
a = math.sin(dphi / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dlmb / 2) ** 2
return 2 * r * math.asin(math.sqrt(a))
# A real EKB listing on "ул. Ленина" — SAME normalized street+house-number key
# as the Серов subject below (_normalize_building_key drops the city token),
# ~280 km away. Pre-#2581 the Tier A SQL had NO geo predicate at all, so an
# address-string match against this row would have been returned
# unconditionally — the actual reported bug (ЕКБ "Ленина 5" leaking into a
# Серов estimate at ~150-190k ₽/м² vs. the real ~30k deal corridor).
_EKB_LENINA_LISTING = {
"price_per_m2": 186_000.0,
"area_m2": 45.0,
"rooms": 2,
"floor": 5,
"total_floors": 16,
"address": "Екатеринбург, ул. Ленина, 5",
"source": "cian",
"source_url": "https://cian.ru/sale/flat/leak1/",
"price_rub": 186_000.0 * 45.0,
"listing_date": None,
"days_on_market": 12,
"photo_urls": [],
"lat": 56.838,
"lon": 60.595,
"listing_segment": "vtorichka",
"source_id": "leak1",
}
def _ekb_lenina_pool(prices_per_m2: list[float]) -> list[dict[str, Any]]:
"""N distinct EKB "ул. Ленина" comps (>= estimate_sb_min_comps=4 needed for
Tier A to actually FIRE see _fetch_anchor_comps `len(comps) >= min_comps`).
Distinct floor/source_id/price_rub per row so `_dedup_cross_source` (#2265,
street+floor+area+price physical key) treats them as distinct units, not
cross-posted duplicates of the same lot.
"""
return [
{
**_EKB_LENINA_LISTING,
"source_id": f"leak{i}",
"source_url": f"https://cian.ru/sale/flat/leak{i}/",
"floor": 3 + 2 * i,
"price_per_m2": ppm2,
"price_rub": ppm2 * _EKB_LENINA_LISTING["area_m2"],
}
for i, ppm2 in enumerate(prices_per_m2)
]
def _serov_geo() -> Any:
from app.services.geocoder import GeocodeResult
return GeocodeResult(
lat=59.6047,
lon=60.5876,
full_address="Свердловская обл., Серов, ул. Ленина, 5",
provider="nominatim",
)
def _serov_payload() -> Any:
from app.schemas.trade_in import TradeInEstimateInput
return TradeInEstimateInput(
address="Серов, ул. Ленина, 5", area_m2=45.0, rooms=2, floor=5, total_floors=9
)
def _fake_anchor_sql_execute(
row_pool: list[dict[str, Any]], subject_lat: float, subject_lon: float
) -> Any:
"""`db.execute` side_effect faking real ST_DWithin filtering for the Tier A
anchor SQL only. Identifies that query by its distinctive bound params
(`house_re` + `street_like`, unique to Tier A in the whole module).
If the query ALSO binds `lat`/`lon`/`radius` (post-#2581), computes the
real haversine distance and returns rows from `row_pool` ONLY if within
`radius` of the bound subject `lat`/`lon` exactly what Postgres'
ST_DWithin would decide. If those params are ABSENT (pre-#2581 — the SQL
had no geo predicate at all), returns `row_pool` UNCONDITIONALLY this is
the faithful old-code behaviour (matched by address string alone,
regardless of distance), NOT an error: the fake must reproduce the actual
bug for the "prove it fails on old code" check to be meaningful, rather
than accidentally passing via an unrelated KeyError caught by
`_fetch_anchor_comps`'s own try/except.
Everything else (Tier C anchor, IMV anchor, etc.) degrades to the same
empty-result default a bare, unconfigured `MagicMock()` gives.
"""
def _side_effect(query: Any, params: dict[str, Any] | None = None) -> MagicMock:
result = MagicMock()
params = params or {}
if "house_re" in params and "street_like" in params:
if "lat" in params and "lon" in params and "radius" in params:
dist = _haversine_m(params["lat"], params["lon"], subject_lat, subject_lon)
rows = row_pool if dist <= params["radius"] else []
else:
rows = row_pool
result.mappings.return_value.all.return_value = rows
else:
result.mappings.return_value.all.return_value = []
return result
return _side_effect
def test_non_ekb_anchor_not_leaked_from_ekb_street_collision() -> None:
"""#2581: Серов «ул. Ленина, 5» must NOT anchor on an EKB «ул. Ленина, 5» listing.
Pre-fix this would have returned tier='A' from the EKB listing (~186k
/м²) and, critically, the `anchor_tier is None` gate on the
deals-headline-fallback (_price_from_inputs, #oblast-D) would then have
BLOCKED the honest deal-corridor headline too Серов would surface the
EKB-leaked ~186k figure, never even reaching the deals-fallback path.
"""
dkp_raw = {
"count": 12,
"low_ppm2": 25_000,
"median_ppm2": 30_000,
"high_ppm2": 38_000,
"period_months": 12,
}
db = MagicMock()
# >= estimate_sb_min_comps EKB comps — realistic (40 191 of ~40 200 active
# listings are EKB) and necessary for Tier A to actually fire pre-fix.
db.execute.side_effect = _fake_anchor_sql_execute(
_ekb_lenina_pool([178_000.0, 186_000.0, 190_000.0, 184_000.0]),
subject_lat=56.838,
subject_lon=60.595,
)
geo = _serov_geo()
payload = _serov_payload()
async def _run() -> Any:
from app.services.estimator import estimate_quality
with (
patch("app.services.estimator.geocode", new=AsyncMock(return_value=geo)),
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
patch("app.services.estimator.match_house_readonly", return_value=None),
patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
patch("app.services.estimator._fetch_analogs", return_value=([], False, "W")),
patch("app.services.estimator._fetch_deals", return_value=[]),
patch(
"app.services.estimator._get_or_fetch_imv_cached",
new=AsyncMock(return_value=None),
),
patch(
"app.services.estimator._get_or_fetch_yandex_valuation_cached",
new=AsyncMock(return_value=None),
),
patch(
"app.services.estimator.estimate_via_cian_valuation",
new=AsyncMock(return_value=None),
),
patch("app.services.estimator._fetch_dkp_corridor", return_value=dkp_raw),
patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)),
):
return await estimate_quality(payload, db)
est = anyio.run(_run)
assert est.median_price_per_m2 == 30_000, (
f"headline={est.median_price_per_m2} must come from the honest deal "
"corridor (30_000), not an EKB-leaked Tier A anchor (~186k) — the "
"cross-city street-name collision must be rejected by ST_DWithin"
)
assert est.median_price_per_m2 < 100_000, "must NOT be EKB-leaked (~186k)"
assert est.n_analogs == 0
assert est.confidence == "low"
def test_ekb_anchor_still_works_with_real_same_city_comps() -> None:
"""#2581 control: EKB same-building anchor must keep working post-fix.
Multiple EKB listings on the subject's own street/house, all within the
ST_DWithin radius of the subject's own coordinates, must still form a
Tier A anchor proving the geo-bound only rejects genuinely distant
(cross-city) collisions, not legitimate same-building EKB matches.
"""
from app.schemas.trade_in import TradeInEstimateInput
from app.services.geocoder import GeocodeResult
subject_lat, subject_lon = 56.838, 60.595
comps = _ekb_lenina_pool([140_000.0, 145_000.0, 150_000.0, 148_000.0])
db = MagicMock()
db.execute.side_effect = _fake_anchor_sql_execute(
comps, subject_lat=subject_lat, subject_lon=subject_lon
)
geo = GeocodeResult(
lat=subject_lat,
lon=subject_lon,
full_address="Свердловская обл., Екатеринбург, ул. Ленина, 5",
provider="nominatim",
)
payload = TradeInEstimateInput(
address="Екатеринбург, ул. Ленина, 5", area_m2=45.0, rooms=2, floor=5, total_floors=16
)
async def _run() -> Any:
from app.services.estimator import estimate_quality
with (
patch("app.services.estimator.geocode", new=AsyncMock(return_value=geo)),
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
patch("app.services.estimator.match_house_readonly", return_value=None),
patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
patch("app.services.estimator._fetch_analogs", return_value=([], False, "W")),
patch("app.services.estimator._fetch_deals", return_value=[]),
patch(
"app.services.estimator._get_or_fetch_imv_cached",
new=AsyncMock(return_value=None),
),
patch(
"app.services.estimator._get_or_fetch_yandex_valuation_cached",
new=AsyncMock(return_value=None),
),
patch(
"app.services.estimator.estimate_via_cian_valuation",
new=AsyncMock(return_value=None),
),
patch("app.services.estimator._fetch_dkp_corridor", return_value=None),
patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)),
):
return await estimate_quality(payload, db)
est = anyio.run(_run)
# Same-building anchor engaged (EKB doesn't degrade): headline built from
# the 4 same-building comps, not left n/a / not routed through deals.
assert est.n_analogs == 4
assert 138_000 <= est.median_price_per_m2 <= 152_000

View file

@ -1,9 +1,10 @@
"""Tests for _fetch_analogs per-address cap and per-source quota (source starvation fix).
Regression: Монтёрская 8/2 91 Avito listings с distance=0 выдавливали
Cian/Yandex/N1 из топ-50, т.к. pure-distance sort + LIMIT 50.
Cian/Yandex из топ-50, т.к. pure-distance sort + LIMIT 50.
Fix: MAX_ANALOGS_PER_ADDRESS cap в SQL + MIN_ANALOGS_PER_SOURCE quota в Python.
"""
import os
# Settings requires DATABASE_URL at init time. Set dummy DSN before any app import.
@ -15,6 +16,7 @@ from unittest.mock import MagicMock
# ── Helpers ───────────────────────────────────────────────────────────────────
def _make_listing(
*,
source: str,
@ -58,6 +60,7 @@ def _make_db_mock(rows: list[dict[str, Any]]) -> MagicMock:
# ── Test 1: per-address cap ───────────────────────────────────────────────────
def test_address_cap_limits_per_address_listings() -> None:
"""_fetch_analogs caps at MAX_ANALOGS_PER_ADDRESS listings from a single address.
@ -91,6 +94,7 @@ def test_address_cap_limits_per_address_listings() -> None:
# ── Test 2: source quota (regression for Cian starvation) ────────────────────
def test_source_quota_prevents_cian_starvation() -> None:
"""MIN_ANALOGS_PER_SOURCE guarantees Cian is represented despite Avito dominance.
@ -103,22 +107,28 @@ def test_source_quota_prevents_cian_starvation() -> None:
# SQL already applied address cap. Simulate SQL result after cap:
# 5 avito (cap applied to large block), 8 cian (different address, 200m away).
avito_rows = [
_make_listing(source="avito", address="ул. Монтёрская, 8/2", distance_m=0.0,
relevance_score=float(i) * 0.01)
_make_listing(
source="avito",
address="ул. Монтёрская, 8/2",
distance_m=0.0,
relevance_score=float(i) * 0.01,
)
for i in range(5)
]
cian_rows = [
_make_listing(source="cian", address="ул. Монтёрская, 1", distance_m=200.0,
relevance_score=0.2 + float(i) * 0.01)
_make_listing(
source="cian",
address="ул. Монтёрская, 1",
distance_m=200.0,
relevance_score=0.2 + float(i) * 0.01,
)
for i in range(8)
]
# SQL returns avito first (better relevance), then cian
sql_rows = avito_rows + cian_rows
db = _make_db_mock(sql_rows)
result, _, _ = _fetch_analogs(
db, lat=56.838, lon=60.595, rooms=1, area=38.0, radius_m=1000
)
result, _, _ = _fetch_analogs(db, lat=56.838, lon=60.595, rooms=1, area=38.0, radius_m=1000)
cian_count = sum(1 for r in result if r["source"] == "cian")
expected_min = min(8, MIN_ANALOGS_PER_SOURCE)
@ -130,6 +140,7 @@ def test_source_quota_prevents_cian_starvation() -> None:
# ── Test 3: no source starvation when quota > supply ─────────────────────────
def test_source_quota_includes_all_when_supply_below_min() -> None:
"""When a source has fewer listings than MIN_ANALOGS_PER_SOURCE, all are included.
@ -138,31 +149,38 @@ def test_source_quota_includes_all_when_supply_below_min() -> None:
from app.services.estimator import _fetch_analogs
avito_rows = [
_make_listing(source="avito", address="ул. Монтёрская, 8/2", distance_m=0.0,
relevance_score=float(i) * 0.01)
_make_listing(
source="avito",
address="ул. Монтёрская, 8/2",
distance_m=0.0,
relevance_score=float(i) * 0.01,
)
for i in range(5)
]
cian_rows = [
_make_listing(source="cian", address="ул. Монтёрская, 3", distance_m=300.0,
relevance_score=0.3 + float(i) * 0.01)
_make_listing(
source="cian",
address="ул. Монтёрская, 3",
distance_m=300.0,
relevance_score=0.3 + float(i) * 0.01,
)
for i in range(3)
]
sql_rows = avito_rows + cian_rows
db = _make_db_mock(sql_rows)
result, _, _ = _fetch_analogs(
db, lat=56.838, lon=60.595, rooms=1, area=38.0, radius_m=1000
)
result, _, _ = _fetch_analogs(db, lat=56.838, lon=60.595, rooms=1, area=38.0, radius_m=1000)
cian_count = sum(1 for r in result if r["source"] == "cian")
assert cian_count == 3, (
f"All 3 Cian listings (below MIN quota) must be included, got {cian_count}"
)
assert (
cian_count == 3
), f"All 3 Cian listings (below MIN quota) must be included, got {cian_count}"
assert len(result) == 8 # 5 avito + 3 cian
# ── Test 4: fallback signal preserved ────────────────────────────────────────
def test_fallback_signal_reflects_radius() -> None:
"""_fetch_analogs returns correct fallback_used boolean based on radius_m.
@ -172,8 +190,9 @@ def test_fallback_signal_reflects_radius() -> None:
from app.services.estimator import DEFAULT_RADIUS_M, FALLBACK_RADIUS_M, _fetch_analogs
rows = [
_make_listing(source="avito", address="ул. Ленина, 1", distance_m=100.0,
relevance_score=0.1),
_make_listing(
source="avito", address="ул. Ленина, 1", distance_m=100.0, relevance_score=0.1
),
]
db_default = _make_db_mock(rows)

View file

@ -6,7 +6,7 @@ Bug context (issue #582 Phase 5):
оставался где user кликнул иногда в проезде / дворе.
Fix: backend теперь возвращает snapped_lat/snapped_lon (центр matched здания
от Yandex/Nominatim/cadastral) + precision, фронт двигает marker если precision
от Nominatim/cadastral) + precision, фронт двигает marker если precision
in (exact, number, cadastral).
"""
@ -46,6 +46,7 @@ def app() -> FastAPI:
# ── Endpoint response shape ──────────────────────────────────────────────────
def test_reverse_endpoint_returns_snapped_fields(app: FastAPI) -> None:
"""Endpoint should include address + lat/lon (echo) + snapped_lat/snapped_lon + precision."""
client = TestClient(app)
@ -54,7 +55,7 @@ def test_reverse_endpoint_returns_snapped_fields(app: FastAPI) -> None:
snapped_lat=56.838004,
snapped_lon=60.586155,
precision="exact",
provider="yandex",
provider="cadastral",
)
with patch(
"app.api.v1.geocode.reverse_geocode",
@ -73,7 +74,7 @@ def test_reverse_endpoint_returns_snapped_fields(app: FastAPI) -> None:
assert body["snapped_lat"] == 56.838004
assert body["snapped_lon"] == 60.586155
assert body["precision"] == "exact"
assert body["provider"] == "yandex"
assert body["provider"] == "cadastral"
def test_reverse_endpoint_404_when_no_address(app: FastAPI) -> None:
@ -113,6 +114,7 @@ def test_reverse_endpoint_street_precision_does_not_lose_snap_fields(app: FastAP
# ── snap_precision_useful helper ─────────────────────────────────────────────
def test_snap_precision_useful_exact_and_number() -> None:
assert snap_precision_useful("exact") is True
assert snap_precision_useful("number") is True
@ -125,104 +127,3 @@ def test_snap_precision_useful_rejects_street_and_other() -> None:
assert snap_precision_useful("locality") is False
assert snap_precision_useful("other") is False
assert snap_precision_useful("") is False
# ── Yandex reverse parsing ───────────────────────────────────────────────────
async def test_yandex_reverse_parses_snapped_point_and_precision() -> None:
"""`_yandex_reverse` извлекает Point.pos (lon lat) и precision из metaDataProperty."""
import httpx
from app.services.geocoder import _yandex_reverse
sample = {
"response": {
"GeoObjectCollection": {
"featureMember": [
{
"GeoObject": {
"metaDataProperty": {
"GeocoderMetaData": {
"precision": "exact",
"text": (
"Россия, Свердловская область, "
"Екатеринбург, улица Малышева, 51"
),
"kind": "house",
}
},
"name": "улица Малышева, 51",
"Point": {"pos": "60.586155 56.838004"},
}
}
]
}
}
}
class _FakeResp:
status_code = 200
def raise_for_status(self) -> None:
return None
def json(self) -> dict:
return sample
class _FakeClient:
async def __aenter__(self) -> _FakeClient:
return self
async def __aexit__(self, *_: object) -> None:
return None
async def get(self, *_: object, **__: object) -> _FakeResp:
return _FakeResp()
with patch.object(httpx, "AsyncClient", lambda *a, **kw: _FakeClient()):
result = await _yandex_reverse(56.8381, 60.5860, api_key="fake")
assert result is not None
# Yandex pos формат: "lon lat" → snapped_lat=56.838004, snapped_lon=60.586155
assert abs(result.snapped_lat - 56.838004) < 1e-6
assert abs(result.snapped_lon - 60.586155) < 1e-6
assert result.precision == "exact"
assert result.provider == "yandex"
# Address text должен быть очищен от "Россия, Свердловская область"
assert "Россия" not in result.address
assert "Свердловская область" not in result.address
assert "Малышева" in result.address
assert "51" in result.address
async def test_yandex_reverse_returns_none_on_empty_results() -> None:
"""Empty featureMember → None."""
import httpx
from app.services.geocoder import _yandex_reverse
sample = {"response": {"GeoObjectCollection": {"featureMember": []}}}
class _FakeResp:
status_code = 200
def raise_for_status(self) -> None:
return None
def json(self) -> dict:
return sample
class _FakeClient:
async def __aenter__(self) -> _FakeClient:
return self
async def __aexit__(self, *_: object) -> None:
return None
async def get(self, *_: object, **__: object) -> _FakeResp:
return _FakeResp()
with patch.object(httpx, "AsyncClient", lambda *a, **kw: _FakeClient()):
result = await _yandex_reverse(56.0, 60.0, api_key="fake")
assert result is None

View file

@ -8,23 +8,23 @@
инвариант OBLAST66 TIGHT, дальние города области, известное ограничение (Тюмень
внутри bbox координатно компенсируется region cross-check на accept-сайтах);
- _has_oblast_marker word-boundary matching (не substring);
- _nominatim_query / _yandex_lookup accept-сайты two-pass tie-break (tight ЕКБ
приоритетнее) + region cross-check (отсекает Тюмень и т.п. даже внутри bbox).
- _nominatim_query accept-сайт two-pass tie-break (tight ЕКБ приоритетнее) +
region cross-check (отсекает Тюмень и т.п. даже внутри bbox).
"""
from unittest.mock import patch
import httpx
import pytest
from app.services.geocoder import (
_LOCALITY_MARKER_RE,
EKB_BBOX_TIGHT,
EKB_BBOX_WIDE,
OBLAST66_BBOX,
_ekb_local_tiers_allowed,
_has_oblast_marker,
_names_non_ekb_city,
_names_unrecognized_locality,
_nominatim_query,
_yandex_lookup,
is_within_ekb_bbox,
is_within_ekb_bbox_wide,
is_within_oblast66_bbox,
@ -142,9 +142,9 @@ def test_oblast66_bbox_admits_tyumen_by_design() -> None:
Это ЗНАЕМОЕ и намеренное ограничение самого bbox-хелпера (дешевле
false-positive у границы, чем false-negative на корректном адресе области).
Реальное отсечение Тюмени происходит на accept-сайтах через region
cross-check см. test_nominatim_query_rejects_out_of_region_only_candidate /
test_yandex_lookup_rejects_out_of_region_only_candidate ниже.
Реальное отсечение Тюмени происходит на accept-сайте через region
cross-check см. test_nominatim_query_rejects_out_of_region_only_candidate
ниже.
"""
assert is_within_oblast66_bbox(57.1530, 65.5343) is True
@ -202,6 +202,162 @@ def test_names_non_ekb_city(address: str, expected: bool) -> None:
assert _names_non_ekb_city(address) is expected
# ── _names_unrecognized_locality / _ekb_local_tiers_allowed (#2582) ─────────
# Fail-closed гейт: EKB-only локальные тиры (geoportal/cad_buildings) больше
# НЕ держатся на списке из 37 городов ("нет в списке → считаем ЕКБ"). Живые
# прод-случаи из geocode_cache (все получили координаты ЕКБ по ошибке до
# фикса): Ялта (другой регион), Трёхгорный (другой регион), Лёвиха/Реж
# (реальные города Свердловской области ВНЕ списка 37).
@pytest.mark.parametrize(
"address,expected",
[
# Живые прод-случаи (#2582) — региональный/settlement маркер есть,
# но это не Екатеринбург и не город из известного списка:
("республика крым, ялта, ул. репина, 18с2", True),
("челябинская обл., трёхгорный, ул. ленина, 15", True),
("свердловская обл., кировградский р-н, пос. лёвиха, ул. карла маркса, 8", True),
("свердловская обл., реж, ул. ленина, 5", True),
("ульяновская обл., димитровград, ул. менделеева, 11", True),
# известный город списка ловится отдельно (_names_non_ekb_city),
# эта функция на него не обязана реагировать без маркера:
("нижний тагил, ленина 10", False),
# города/региона вообще не названо — основной трафик, не топоним:
("малышева 30", False),
("серова 27", False), # word-boundary — не город "серов"
("проспект ленина, 1", False),
# явный Екатеринбург побеждает даже при наличии маркера "г":
("г. екатеринбург, ул. малышева, 30", False),
("г екатеринбург, ленинский р-н, ул серова, д 35", False),
# ── Изолирующие кейсы (#2589 M3) — параметризация выше зелёная и на
# маркер, и на «обл./республика/край» одновременно (все реальные
# прод-адреса их несут вместе); ниже — маркер БЕЗ регионального
# префикса, чтобы поймать регрессию именно сигнала-маркера (это и
# проехало у H1: мёртвый «с\.» был бы незамечен без таких кейсов).
(
"с. новоалексеевское, ул. 8 марта,37",
True,
), # живой инцидент (#2589 H1) — «с.» БЕЗ доп. маркера
("с. кашино, ул. ленина, 5", True), # изолированный «с.» (M3)
("д. родина, ул. мира, 1", True), # изолированный «д.» (M3, lookahead-фикс)
("пгт. шаля, ул. советская, 2", True), # изолированный «пгт.» (M3)
(
"пос. мичуринский, широкореченская ул., 45",
True,
), # пригородный посёлок ЕКБ — маркер закрывает гейт (M3, честно
# закреплено: даже для формально «своих» посёлков без «Екатеринбург»
# в тексте фолбэк на oblast-aware внешние провайдеры — не регрессия
# координат, просто не через fast-path geoportal)
("реж, ленина 5", False), # ИЗВЕСТНАЯ ДЫРА (#2589 M3): голый город вне
# списка 37 БЕЗ маркера — не ловится, уходит в ЕКБ-дефолт. Честно
# зафиксировано, не «фиксим» здесь (см. docstring — architectural
# follow-up отдельно, не через список городов).
# house-маркер НЕ должен матчить как топоним (регрессия #2582→#2589):
("ул. ленина, д. 5", False),
("д. 5", False),
("д. 5 стр. 2", False),
],
)
def test_names_unrecognized_locality(address: str, expected: bool) -> None:
assert _names_unrecognized_locality(address) is expected
@pytest.mark.parametrize(
"text,expected",
[
# Ровно набор из deep-review (#2589 H1) — «Проверь сам на живом Python».
("д. 5", False),
("д. 5 стр. 2", False),
("ул. ленина, д. 5", False),
("с. кашино", True),
("д. родина", True),
("пгт. шаля", True),
],
)
def test_locality_marker_re_isolated(text: str, expected: bool) -> None:
"""Regex-уровень, изолированно от `_names_unrecognized_locality`'s
Екатеринбург-early-return прямая проверка `_LOCALITY_MARKER_RE` (#2589 M3/H1)."""
assert bool(_LOCALITY_MARKER_RE.search(text)) is expected
@pytest.mark.parametrize(
"address,expected",
[
# Живые прод-случаи (#2582) — гейт обязан закрыться:
("республика крым, ялта, ул. репина, 18с2", False),
("челябинская обл., трёхгорный, ул. ленина, 15", False),
("свердловская обл., кировградский р-н, пос. лёвиха, ул. карла маркса, 8", False),
("свердловская обл., реж, ул. ленина, 5", False),
# известный город региона 66 (список) — тоже закрыт:
("Нижний Тагил, проспект Ленина, 1", False),
("Каменск-Уральский, ул. Ленина 1", False),
# ЕКБ явно назван — гейт открыт (regardless маркера "г"):
("г. Екатеринбург, ул. Малышева, 30", True),
("620144, Свердловская обл, г Екатеринбург, Ленинский р-н, ул Серова, д 35", True),
# города/региона вообще не названо — ОСНОВНОЙ ТРАФИК (форма оценки без
# города) — дефолт ЕКБ должен остаться рабочим, иначе деградация #1:
("Малышева 30", True),
("Серова 27", True),
("Космонавтов 7б", True),
("проспект Ленина, 1", True),
# мкр-исключение (район внутри ЕКБ, не ЗАТО Заречный) — гейт открыт:
("мкр Заречный, ул. Ленина 5", True),
# изолирующий кейс (#2589 H1/M3) — «с.»-маркер БЕЗ доп. «обл.»/«республика»:
("с. Новоалексеевское, ул. 8 Марта,37", False),
# house-маркер «д.» НЕ закрывает гейт (регрессия H1-фикса):
("ул. Ленина, д. 5", True),
],
)
def test_ekb_local_tiers_allowed(address: str, expected: bool) -> None:
assert _ekb_local_tiers_allowed(address) is expected
# ── _ekb_local_tiers_allowed × city_hint (#2580 merge, #2582, #2589 M2) ─────
# Обе логики независимо закрывают гейт (OR трёх сигналов) — проверяем, что
# ни одна из веток не стала недостижимой после слияния #2580/#2582/#2589-M2:
# A. city_hint называет известный город региона 66 (не ЕКБ) → закрыто.
# A2. city_hint — ЛЮБАЯ непустая строка, отличная от "Екатеринбург" (в т.ч.
# НЕ из списка 37 — #2589 M2, было главной дырой: `city_hint="Реж"`
# раньше держал гейт открытым, geoportal отдавал точный ЕКБ-дом с
# `city_ambiguous=False` — уверенно-неверный результат) → закрыто.
# B. незнакомый топоним в ТЕКСТЕ адреса (маркер) → закрыто.
# C. ни того ни другого → открыто (дефолт ЕКБ).
# D. явный city_hint="Екатеринбург" (без конфликта в тексте) → открыто.
@pytest.mark.parametrize(
"address,city_hint,expected,label",
[
# A: hint называет известный не-ЕКБ город региона 66 → закрыто, даже
# если сам текст адреса города вообще не называет (#2580 C1).
("Ленина, 1", "Нижний Тагил", False, "A: hint=известный не-ЕКБ город"),
("Серова 27", "Каменск-Уральский", False, "A: hint=известный не-ЕКБ город (2)"),
# A2 (#2589 M2 — ключевой регрессионный кейс фикса): hint называет
# город, КОТОРОГО НЕТ в списке 37 (произвольный текст через API, не
# UI-selector) → тоже закрыто. До фикса `_names_non_ekb_city(city_hint)`
# давал False (Реж не в списке) → гейт был открыт.
("Ленина, 1", "Реж", False, "A2: hint=не-ЕКБ город ВНЕ списка 37"),
("Серова 27", "Ялта", False, "A2: hint=город другого региона"),
# B: незнакомый топоним в тексте (маркер) закрывает гейт независимо
# от hint (#2582) — hint здесь не задан, чистая проверка B.
("свердловская обл., реж, ул. ленина, 5", None, False, "B: маркер в тексте"),
# C: ни маркера в тексте, ни (не-ЕКБ) hint'а → открыто, основной трафик.
("Малышева 30", None, True, "C: ничего не названо"),
("Серова 27", "", True, "C: пустой hint == отсутствию hint'а"),
# D: явный city_hint="Екатеринбург" без конфликта в тексте → открыто —
# ключевая проверка, что #2580 C1 / #2589 M2 не сломали дефолтный
# ЕКБ-путь через hint.
("Ленина, 1", "Екатеринбург", True, "D: hint=Екатеринбург"),
("Серова 27", "екатеринбург", True, "D: hint=екатеринбург (регистр)"),
],
)
def test_ekb_local_tiers_allowed_with_city_hint(
address: str, city_hint: str | None, expected: bool, label: str
) -> None:
assert _ekb_local_tiers_allowed(address, city_hint) is expected, label
# ── Accept-site two-pass tie-break + region cross-check ─────────────────────
@ -260,66 +416,3 @@ async def test_nominatim_query_accepts_oblast_wide_when_region_absent() -> None:
_REAL_ASYNC_CLIENT = httpx.AsyncClient
def _yandex_client_factory(transport: httpx.MockTransport):
"""Drop-in replacement для httpx.AsyncClient внутри geocoder._yandex_lookup."""
def factory(*_: object, **__: object) -> httpx.AsyncClient:
return _REAL_ASYNC_CLIENT(transport=transport)
return factory
def _yandex_geo_object(lat: str, lon: str, admin_area: str | None) -> dict:
meta: dict = {"precision": "exact", "text": "тест"}
if admin_area is not None:
meta["AddressDetails"] = {
"Country": {"AdministrativeArea": {"AdministrativeAreaName": admin_area}}
}
return {
"GeoObject": {
"Point": {"pos": f"{lon} {lat}"},
"metaDataProperty": {"GeocoderMetaData": meta},
}
}
def _yandex_payload(members: list[dict]) -> dict:
return {"response": {"GeoObjectCollection": {"featureMember": members}}}
async def test_yandex_lookup_prefers_tight_ekb_over_oblast_rank0() -> None:
"""Тот же tie-break сценарий, что и для Nominatim, но для Yandex top-5."""
members = [
_yandex_geo_object("59.6297", "60.5541", "Свердловская область"), # Серов, rank0
_yandex_geo_object("56.838", "60.605", "Свердловская область"), # ЕКБ tight, rank1
]
payload = _yandex_payload(members)
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=payload)
transport = httpx.MockTransport(handler)
with patch("app.services.geocoder.httpx.AsyncClient", _yandex_client_factory(transport)):
result = await _yandex_lookup("Серова 27", "fake-key")
assert result is not None
assert result.lat == pytest.approx(56.838)
assert result.lon == pytest.approx(60.605)
async def test_yandex_lookup_rejects_out_of_region_only_candidate() -> None:
"""Единственный кандидат — Тюмень (bbox admits координатно, регион другой) →
region cross-check отсекает даже в ultimate as-is fallback None."""
members = [_yandex_geo_object("57.1522", "65.5272", "Тюменская область")]
payload = _yandex_payload(members)
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=payload)
transport = httpx.MockTransport(handler)
with patch("app.services.geocoder.httpx.AsyncClient", _yandex_client_factory(transport)):
result = await _yandex_lookup("какой-то мусорный адрес", "fake-key")
assert result is None

View file

@ -188,7 +188,6 @@ async def test_geocode_uses_house_match_before_legacy_forward() -> None:
"app.services.geocoder._cadastral_forward_sync",
) as mock_forward,
patch("app.services.geocoder._cache_put"),
patch("app.services.geocoder._yandex_lookup", new_callable=AsyncMock) as mock_yandex,
):
result = await geocode("Серова 27", db)
@ -199,7 +198,6 @@ async def test_geocode_uses_house_match_before_legacy_forward() -> None:
mock_house.assert_called_once()
# house-match hit → legacy raw-ILIKE forward never invoked
mock_forward.assert_not_called()
mock_yandex.assert_not_called()
async def test_geocode_falls_back_to_legacy_forward_when_house_match_misses() -> None:
@ -225,7 +223,6 @@ async def test_geocode_falls_back_to_legacy_forward_when_house_match_misses() ->
return_value=[legacy_hit],
) as mock_forward,
patch("app.services.geocoder._cache_put"),
patch("app.services.geocoder._yandex_lookup", new_callable=AsyncMock) as mock_yandex,
):
result = await geocode("Серова 27", db)
@ -233,7 +230,6 @@ async def test_geocode_falls_back_to_legacy_forward_when_house_match_misses() ->
assert result.lat == 56.84
mock_house.assert_called_once()
mock_forward.assert_called_once()
mock_yandex.assert_not_called()
async def test_geocode_skips_house_match_when_parse_fails() -> None:
@ -250,14 +246,12 @@ async def test_geocode_skips_house_match_when_parse_fails() -> None:
return_value=[],
) as mock_forward,
patch("app.services.geocoder._cache_put"),
patch("app.services.geocoder.settings") as mock_settings,
patch(
"app.services.geocoder._nominatim_lookup",
new_callable=AsyncMock,
return_value=None,
),
):
mock_settings.yandex_geocoder_api_key = None
result = await geocode("полный мусор без дома", db)
assert result is None
@ -290,14 +284,12 @@ async def test_geocode_skips_ekb_local_tiers_for_non_ekb_city() -> None:
patch("app.services.geocoder._cadastral_house_match") as mock_house,
patch("app.services.geocoder._cadastral_forward_sync") as mock_forward,
patch("app.services.geocoder._cache_put"),
patch("app.services.geocoder.settings") as mock_settings,
patch(
"app.services.geocoder._nominatim_lookup",
new_callable=AsyncMock,
return_value=nominatim_result,
) as mock_nominatim,
):
mock_settings.yandex_geocoder_api_key = None
result = await geocode("Нижний Тагил, проспект Ленина, 1", db)
assert result is not None
@ -309,6 +301,172 @@ async def test_geocode_skips_ekb_local_tiers_for_non_ekb_city() -> None:
mock_nominatim.assert_called_once()
# ── geocode() fail-closed gate for UNRECOGNIZED locality (#2582) ────────────
# Живые прод-случаи из geocode_cache: адрес называет город/регион ВНЕ списка
# из 37 (или другой регион РФ целиком) — до фикса `_names_non_ekb_city`
# молчаливо считал это «город не назван» → EKB-only тиры отвечали первыми и
# снапали улицу+дом в координаты ЕКБ.
@pytest.mark.parametrize(
("address", "label"),
[
("республика крым, ялта, ул. репина, 18с2", "Ялта (другой регион)"),
("челябинская обл., трёхгорный, ул. ленина, 15", "Трёхгорный (другой регион)"),
(
"свердловская обл., кировградский р-н, пос. лёвиха, ул. карла маркса, 8",
"Лёвиха (город области ВНЕ списка 37)",
),
("свердловская обл., реж, ул. ленина, 5", "Реж (город области ВНЕ списка 37)"),
(
"с. Новоалексеевское, ул. 8 Марта,37",
"Новоалексеевское (живой инцидент #2589 H1 — «с.»-маркер без доп. «обл.»)",
),
],
)
async def test_geocode_skips_ekb_local_tiers_for_unrecognized_locality(
address: str, label: str
) -> None:
"""EKB-only локальные тиры (geoportal/cadastral) НЕ вызываются для адреса,
называющего незнакомый топоним идём сразу к внешнему oblast/nation-aware
провайдеру (#2582)."""
db = MagicMock()
nominatim_result = GeocodeResult(
lat=45.0,
lon=34.0,
full_address=address,
provider="nominatim",
confidence="approximate",
)
with (
patch("app.services.geocoder._cache_get", return_value=None),
patch("app.services.geocoder._geoportal_house_match") as mock_geoportal,
patch("app.services.geocoder._cadastral_house_match") as mock_house,
patch("app.services.geocoder._cadastral_forward_sync") as mock_forward,
patch("app.services.geocoder._cache_put"),
patch(
"app.services.geocoder._nominatim_lookup",
new_callable=AsyncMock,
return_value=nominatim_result,
) as mock_nominatim,
):
result = await geocode(address, db)
assert result is not None, label
mock_geoportal.assert_not_called()
mock_house.assert_not_called()
mock_forward.assert_not_called()
mock_nominatim.assert_called_once()
@pytest.mark.parametrize(
("address", "label"),
[
("Серова 27", "ЕКБ без города (основной трафик формы оценки)"),
("Малышева 30", "ЕКБ без города (основной трафик формы оценки)"),
("г. Екатеринбург, ул. Серова, д. 27", "ЕКБ с явным городом"),
("ул. Серова, д. 27", "house-маркер «д.» не закрывает гейт (#2589 H1 regression)"),
],
)
async def test_geocode_still_uses_ekb_local_tiers_for_real_ekb_address(
address: str, label: str
) -> None:
"""Регрессия: реальный ЕКБ-адрес (с городом и без) по-прежнему резолвится
через geoportal (fast-path) fail-closed гейт не должен деградировать
основной трафик (#2582)."""
db = MagicMock()
hit = GeocodeSuggestion(
label="ул. Серова, д. 27, Екатеринбург",
full_address="ул. Серова, д. 27, Екатеринбург",
lat=56.81188,
lon=60.59739,
kind="house",
)
with (
patch("app.services.geocoder._cache_get", return_value=None),
patch("app.services.geocoder._geoportal_house_match", return_value=hit) as mock_geoportal,
patch("app.services.geocoder._cache_put"),
):
result = await geocode(address, db)
assert result is not None, label
assert result.lat == pytest.approx(56.81188), label
mock_geoportal.assert_called_once()
async def test_geocode_explicit_ekaterinburg_city_hint_still_uses_local_tiers() -> None:
"""Слияние #2580/#2582: явный `city_hint="Екатеринбург"` (без города в самом
тексте адреса, без конфликта) по-прежнему открывает ЕКБ-only локальные
тиры ветка D из объединённого гейта `_ekb_local_tiers_allowed` не должна
была стать недостижимой после merge."""
db = MagicMock()
hit = GeocodeSuggestion(
label="ул. Серова, д. 27, Екатеринбург",
full_address="ул. Серова, д. 27, Екатеринбург",
lat=56.81188,
lon=60.59739,
kind="house",
)
with (
patch("app.services.geocoder._cache_get", return_value=None),
patch("app.services.geocoder._geoportal_house_match", return_value=hit) as mock_geoportal,
patch("app.services.geocoder._cache_put"),
):
result = await geocode("Серова 27", db, city_hint="Екатеринбург")
assert result is not None
assert result.lat == pytest.approx(56.81188)
mock_geoportal.assert_called_once()
async def test_geocode_city_hint_unknown_city_skips_local_ekb_tiers() -> None:
"""#2589 M2 (deep-review fix): `city_hint="Реж"` — город ВНЕ списка 37 и
без структурного маркера в самом тексте адреса обязан закрыть ЕКБ-only
локальные тиры так же, как известный `city_hint`. До фикса
`hint_names_non_ekb = _names_non_ekb_city(city_hint)` был list-based и
держал гейт открытым для ЛЮБОГО города вне списка 37 (Реж, седьмой город
после расширения UI-selector'а, произвольный текст через API) — geoportal
отдавал точный ЕКБ-хит, а `city_ambiguous=False` (хинт ведь БЫЛ) делало
это уверенно-неверным результатом (хуже, чем честный `city_ambiguous=True`
без хинта вообще)."""
db = MagicMock()
ekb_hit = GeocodeSuggestion(
label="Ленина, 1, Екатеринбург",
full_address="Ленина, 1, Екатеринбург",
lat=56.83788,
lon=60.58018,
kind="house",
)
rezh_result = GeocodeResult(
lat=57.371, lon=61.386, full_address="Ленина, 1, Реж", provider="nominatim"
)
with (
patch("app.services.geocoder._cache_get", return_value=None),
patch("app.services.geocoder._cache_put"),
patch(
"app.services.geocoder._geoportal_house_match", return_value=ekb_hit
) as mock_geoportal,
patch("app.services.geocoder._cadastral_house_match", return_value=ekb_hit) as mock_house,
patch("app.services.geocoder._cadastral_forward_sync", return_value=[]),
patch(
"app.services.geocoder._nominatim_lookup",
new_callable=AsyncMock,
return_value=rezh_result,
),
):
result = await geocode("Ленина, 1", db, city_hint="Реж")
mock_geoportal.assert_not_called()
mock_house.assert_not_called()
assert result is not None
assert result.lat == pytest.approx(57.371) # Реж, НЕ подставленный ЕКБ-хит (56.83788)
assert result.lat != pytest.approx(56.83788)
# ── suggest() wiring ─────────────────────────────────────────────────────────
@ -331,7 +489,6 @@ async def test_suggest_uses_house_match_before_legacy_forward() -> None:
patch(
"app.services.geocoder._cadastral_forward_sync",
) as mock_forward,
patch("app.services.geocoder._yandex_suggest", new_callable=AsyncMock) as mock_yandex,
):
results = await suggest("Серова 27", db=db, limit=8)
@ -339,7 +496,6 @@ async def test_suggest_uses_house_match_before_legacy_forward() -> None:
assert results[0].lat == 56.81188
mock_house.assert_called_once()
mock_forward.assert_not_called()
mock_yandex.assert_not_called()
async def test_suggest_falls_back_to_legacy_forward_when_house_match_misses() -> None:
@ -364,7 +520,6 @@ async def test_suggest_falls_back_to_legacy_forward_when_house_match_misses() ->
"app.services.geocoder._cadastral_forward_sync",
return_value=legacy,
) as mock_forward,
patch("app.services.geocoder._yandex_suggest", new_callable=AsyncMock) as mock_yandex,
):
results = await suggest("Серова 27", db=db, limit=8)
@ -372,7 +527,6 @@ async def test_suggest_falls_back_to_legacy_forward_when_house_match_misses() ->
assert results[0].lat == 56.84
mock_house.assert_called_once()
mock_forward.assert_called_once()
mock_yandex.assert_not_called()
async def test_suggest_skips_ekb_local_tier_for_non_ekb_city() -> None:
@ -401,7 +555,6 @@ async def test_suggest_skips_ekb_local_tier_for_non_ekb_city() -> None:
) as mock_nominatim,
):
mock_settings.dadata_api_token = None
mock_settings.yandex_geocoder_api_key = None
results = await suggest("Нижний Тагил, проспект Ленина, 1", db=db, limit=8)
assert len(results) == 1
@ -409,3 +562,45 @@ async def test_suggest_skips_ekb_local_tier_for_non_ekb_city() -> None:
mock_house.assert_not_called()
mock_forward.assert_not_called()
mock_nominatim.assert_called_once()
@pytest.mark.parametrize(
("query", "label"),
[
("республика крым, ялта, ул. репина, 18с2", "Ялта (другой регион)"),
("свердловская обл., реж, ул. ленина, 5", "Реж (город области ВНЕ списка 37)"),
],
)
async def test_suggest_skips_ekb_local_tier_for_unrecognized_locality(
query: str, label: str
) -> None:
"""Незнакомый топоним (не в списке 37, не Екатеринбург) → EKB-only
cadastral Tier 1 не вызывается; falls through к внешним тирам (#2582)."""
db = MagicMock()
nominatim_hit = [
GeocodeSuggestion(
label="ул. Репина, 18с2",
full_address=query,
lat=44.5,
lon=34.16,
kind="house",
)
]
with (
patch("app.services.geocoder._cadastral_house_match") as mock_house,
patch("app.services.geocoder._cadastral_forward_sync") as mock_forward,
patch("app.services.geocoder.settings") as mock_settings,
patch(
"app.services.geocoder._nominatim_suggest",
new_callable=AsyncMock,
return_value=nominatim_hit,
) as mock_nominatim,
):
mock_settings.dadata_api_token = None
results = await suggest(query, db=db, limit=8)
assert len(results) == 1, label
mock_house.assert_not_called()
mock_forward.assert_not_called()
mock_nominatim.assert_called_once()

View file

@ -0,0 +1,433 @@
"""Тесты #2576 — geocoder больше НЕ подставляет "Екатеринбург" молча.
Проблема (issue #2576 / эпик расширения на область): `_nominatim_lookup`,
`_nominatim_suggest` при отсутствии маркера города/области в самом адресе
всегда молча подставляли "Екатеринбург" житель Нижнего Тагила, вводя
«Ленина, 1», получал уверенно неверную цену по екатеринбургской улице Ленина
(обе улицы называются одинаково).
#2593: Yandex Geocoder (`_yandex_lookup`/`_yandex_suggest`) удалён из geocoder —
их тесты здесь удалены вместе с ним; покрытие тех же сценариев (bias/prefix
без молчаливого "Екатеринбург") осталось на `_nominatim_lookup`.
Покрывают:
- `_resolve_city_for_geocode` приоритет: маркер в адресе > `city_hint` > None.
- `_nominatim_suggest` без города НЕ форсит city-суффикс молчаливо (dual-query);
с `city_hint` суффикс из hint'а; с явным "Екатеринбург" в адресе — поведение
НЕ изменилось (как раньше).
- `geocode()` `city_ambiguous=True` когда город не указан ни в адресе, ни в
`city_hint`; `False` когда указан явно (текстом или через `city_hint`).
- Cache-ключ (`_cache_key`) разные `city_hint` для одного текста адреса НЕ
делят одну запись кэша (regression test на cache poisoning).
"""
from __future__ import annotations
import contextlib
import os
from unittest.mock import AsyncMock, MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost/test_db")
import pytest
from app.services.geocoder import (
GeocodeResult,
GeocodeSuggestion,
_cache_key,
_nominatim_suggest,
_resolve_city_for_geocode,
geocode,
suggest,
)
# ── _resolve_city_for_geocode ────────────────────────────────────────────────
@pytest.mark.parametrize(
"address,city_hint,expected",
[
# Ни маркер, ни hint — раньше здесь молча подставлялся "Екатеринбург".
("Ленина, 1", None, (None, False)),
("Ленина, 1", "", (None, False)),
("Ленина, 1", " ", (None, False)),
# city_hint передан явно вызывающим кодом.
("Ленина, 1", "Нижний Тагил", ("Нижний Тагил", True)),
# Маркер уже в адресе — hint игнорируется (marker имеет приоритет).
("Нижний Тагил, Ленина, 1", "Серов", (None, True)),
("Екатеринбург, Малышева 30", None, (None, True)),
("Екатеринбург, Малышева 30", "Серов", (None, True)),
],
)
def test_resolve_city_for_geocode(
address: str, city_hint: str | None, expected: tuple[str | None, bool]
) -> None:
assert _resolve_city_for_geocode(address, city_hint) == expected
# ── _cache_key — cache poisoning между городами ──────────────────────────────
def test_cache_key_without_hint_unchanged() -> None:
"""Без city_hint формат ключа НЕ меняется — backward-compat с накопленным кэшем."""
assert _cache_key("ленина, 1", None) == "ленина, 1"
assert _cache_key("ленина, 1", "") == "ленина, 1"
def test_cache_key_different_cities_do_not_collide() -> None:
"""#2576: разные города для одного текста адреса — разные cache-ключи."""
key_tagil = _cache_key("ленина, 1", "Нижний Тагил")
key_ekb = _cache_key("ленина, 1", "Екатеринбург")
key_none = _cache_key("ленина, 1", None)
assert key_tagil != key_ekb
assert key_tagil != key_none
assert key_ekb != key_none
def test_cache_key_hint_normalized() -> None:
"""city_hint нормализуется (case/whitespace) — не создаёт лишних ключей."""
assert _cache_key("ленина, 1", "Нижний Тагил") == _cache_key("ленина, 1", "нижний тагил ")
# ── _nominatim_suggest ───────────────────────────────────────────────────────
async def test_nominatim_suggest_no_city_dual_query_both_variants_sent() -> None:
"""#2580 (C2): без города — Nominatim получает ОБА запроса: bare (честный,
без города) И ЕКБ-suffixed (majority-трафик). Не подмена одним вариантом
объединение (см. `test_nominatim_suggest_pobedy20_ekb_result_not_lost` ниже
чистое удаление суффикса теряло реальные ЕКБ-адреса).
"""
calls: list[str] = []
async def fake_query_multi(client, query, limit):
calls.append(query)
return []
with (
patch(
"app.services.geocoder._nominatim_query_multi",
new=AsyncMock(side_effect=fake_query_multi),
),
patch("app.services.geocoder.asyncio.sleep", new=AsyncMock()),
):
result = await _nominatim_suggest("Ленина, 1")
assert result == []
assert "Ленина, 1" in calls # bare — честный, без города
assert "Ленина, 1, Екатеринбург" in calls # ЕКБ-вариант — не потерян
async def test_nominatim_suggest_city_hint_suffix() -> None:
calls: list[str] = []
async def fake_query_multi(client, query, limit):
calls.append(query)
return []
with patch(
"app.services.geocoder._nominatim_query_multi", new=AsyncMock(side_effect=fake_query_multi)
):
await _nominatim_suggest("Ленина, 1", city_hint="Нижний Тагил")
assert calls[0] == "Ленина, 1, Нижний Тагил"
async def test_nominatim_suggest_explicit_ekb_unchanged() -> None:
calls: list[str] = []
async def fake_query_multi(client, query, limit):
calls.append(query)
return []
with patch(
"app.services.geocoder._nominatim_query_multi", new=AsyncMock(side_effect=fake_query_multi)
):
await _nominatim_suggest("Екатеринбург, Малышева 30")
assert calls[0] == "Екатеринбург, Малышева 30"
async def test_nominatim_suggest_pobedy20_ekb_result_not_lost() -> None:
"""#2580 (C2) regression — "Победы 20" (реальный кейс с прода, подтверждён
ревьюером): без города ЕКБ-кандидат ('Парк Победы' ЖК, Екатеринбург) должен
остаться в подсказках, НЕ потеряться в пользу похожего street-level матча
в Верхней Пышме.
Симулирует реальные координаты:
'Победы 20, Екатеринбург' 56.899, 60.579 (ЖК "Парк Победы", Екатеринбург)
'Победы 20' 56.964, 60.610 (ул. Победы, Верхняя Пышма)
"""
ekb_item = {
"place_id": 1001,
"lat": "56.899",
"lon": "60.579",
"display_name": 'ЖК "Парк Победы", Орджоникидзевский район, Екатеринбург',
"address": {"road": "Победы", "house_number": "20", "suburb": "Орджоникидзевский район"},
}
pyshma_item = {
"place_id": 1002,
"lat": "56.964",
"lon": "60.610",
"display_name": "улица Победы, 20, Верхняя Пышма",
"address": {"road": "улица Победы", "house_number": "20"},
}
async def fake_query_multi(client, query, limit):
if query.endswith(", Екатеринбург"):
return [ekb_item]
return [pyshma_item]
with (
patch(
"app.services.geocoder._nominatim_query_multi",
new=AsyncMock(side_effect=fake_query_multi),
),
patch("app.services.geocoder.asyncio.sleep", new=AsyncMock()),
):
result = await _nominatim_suggest("Победы 20")
assert result, "ожидались подсказки"
ekb_hits = [r for r in result if r.lat == pytest.approx(56.899)]
assert ekb_hits, "ЕКБ-кандидат ('Парк Победы') должен остаться в подсказках, не потеряться"
# ЕКБ-кандидат идёт первым (majority-трафик — привычный порядок для основных пользователей).
assert result[0].lat == pytest.approx(56.899)
# Верхняя Пышма тоже осталась в списке — honest alternative, не подменена.
pyshma_hits = [r for r in result if r.lat == pytest.approx(56.964)]
assert pyshma_hits, "не-ЕКБ кандидат тоже должен остаться (объединение, не замена)"
async def test_nominatim_suggest_dedupe_across_dual_query() -> None:
"""Если bare и ЕКБ-suffixed запросы возвращают ОДИН и тот же item (по place_id)
он не дублируется в итоговом списке подсказок."""
same_item = {
"place_id": 42,
"lat": "56.838",
"lon": "60.605",
"display_name": "ул. Малышева, 30, Екатеринбург",
"address": {"road": "ул. Малышева", "house_number": "30"},
}
async def fake_query_multi(client, query, limit):
return [same_item]
with (
patch(
"app.services.geocoder._nominatim_query_multi",
new=AsyncMock(side_effect=fake_query_multi),
),
patch("app.services.geocoder.asyncio.sleep", new=AsyncMock()),
):
result = await _nominatim_suggest("Малышева 30")
assert len(result) == 1, "одинаковый place_id из обоих запросов не должен дублироваться"
# ── geocode() — city_ambiguous flag ──────────────────────────────────────────
def _geocode_patches(nominatim_result: GeocodeResult | None):
return (
patch("app.services.geocoder._cache_get", return_value=None),
patch("app.services.geocoder._cache_put"),
patch("app.services.geocoder._geoportal_house_match", return_value=None),
patch("app.services.geocoder._cadastral_house_match", return_value=None),
patch("app.services.geocoder._cadastral_forward_sync", return_value=[]),
patch(
"app.services.geocoder._nominatim_lookup", new=AsyncMock(return_value=nominatim_result)
),
)
# ── C1 (#2580) — city_hint должен доходить до локальных ЕКБ-only тиров ──────
async def test_geocode_city_hint_non_ekb_skips_local_ekb_tiers() -> None:
"""#2580 (C1): city_hint="Нижний Тагил" должен ЗАПРЕТИТЬ geoportal/cadastral
(ЕКБ-only базы) иначе они возвращают "точный" ЕКБ-хит для улицы, которая
совпадает по названию, а `city_ambiguous=False` (хинт был!) делает такой
неверный результат ложно-уверенным. Мок geoportal нарочно возвращает ЕКБ-хит
(как в проде) фикс должен НЕ дать ему сработать вообще.
"""
db = MagicMock()
ekb_hit = GeocodeSuggestion(
label="Ленина, 1, Екатеринбург",
full_address="Ленина, 1, Екатеринбург",
lat=56.83788,
lon=60.58018,
kind="house",
)
tagil_result = GeocodeResult(
lat=57.905, lon=59.950, full_address="Ленина, 1, Нижний Тагил", provider="nominatim"
)
with contextlib.ExitStack() as stack:
stack.enter_context(patch("app.services.geocoder._cache_get", return_value=None))
stack.enter_context(patch("app.services.geocoder._cache_put"))
geoportal_mock = stack.enter_context(
patch("app.services.geocoder._geoportal_house_match", return_value=ekb_hit)
)
cadastral_mock = stack.enter_context(
patch("app.services.geocoder._cadastral_house_match", return_value=ekb_hit)
)
stack.enter_context(patch("app.services.geocoder._cadastral_forward_sync", return_value=[]))
stack.enter_context(
patch(
"app.services.geocoder._nominatim_lookup",
new=AsyncMock(return_value=tagil_result),
)
)
result = await geocode("Ленина, 1", db, city_hint="Нижний Тагил")
geoportal_mock.assert_not_called()
cadastral_mock.assert_not_called()
assert result is not None
assert result.lat == pytest.approx(57.905) # Тагил, НЕ подставленный ЕКБ-хит (56.838)
assert result.lat != pytest.approx(56.83788)
async def test_geocode_real_ekb_address_still_uses_local_tiers() -> None:
"""Сквозной кейс: реальный ЕКБ-адрес БЕЗ city_hint по-прежнему резолвится через
локальный geoportal-тир (ЕКБ-путь не деградировал после C1-фикса)."""
db = MagicMock()
ekb_hit = GeocodeSuggestion(
label="Малышева, 30, Екатеринбург",
full_address="Малышева, 30, Екатеринбург",
lat=56.8389,
lon=60.6057,
kind="house",
)
with contextlib.ExitStack() as stack:
stack.enter_context(patch("app.services.geocoder._cache_get", return_value=None))
stack.enter_context(patch("app.services.geocoder._cache_put"))
geoportal_mock = stack.enter_context(
patch("app.services.geocoder._geoportal_house_match", return_value=ekb_hit)
)
result = await geocode("Малышева, 30", db)
geoportal_mock.assert_called_once()
assert result is not None
assert result.lat == pytest.approx(56.8389)
assert result.confidence == "exact"
assert result.city_ambiguous is True # город не указан — честный флаг
async def test_suggest_city_hint_non_ekb_skips_cadastral_tier1() -> None:
"""#2580 (C1): suggest(city_hint="Нижний Тагил") — Tier 1 (кадастр ЕКБ) НЕ должен
вызываться (раньше был мёртвым параметром для этого тира)."""
db = MagicMock()
with contextlib.ExitStack() as stack:
house_mock = stack.enter_context(patch("app.services.geocoder._cadastral_house_match"))
forward_mock = stack.enter_context(patch("app.services.geocoder._cadastral_forward_sync"))
mock_settings = stack.enter_context(patch("app.services.geocoder.settings"))
mock_settings.dadata_api_token = None
stack.enter_context(
patch("app.services.geocoder._nominatim_suggest", new=AsyncMock(return_value=[]))
)
await suggest("Ленина, 1", db=db, city_hint="Нижний Тагил")
house_mock.assert_not_called()
forward_mock.assert_not_called()
async def test_geocode_city_ambiguous_true_when_no_city_known() -> None:
"""Ни адрес, ни city_hint не называют город → city_ambiguous=True."""
db = MagicMock()
nominatim_result = GeocodeResult(
lat=56.838, lon=60.605, full_address="что-то", provider="nominatim"
)
with contextlib.ExitStack() as stack:
for cm in _geocode_patches(nominatim_result):
stack.enter_context(cm)
result = await geocode("Малышева, 30", db)
assert result is not None
assert result.city_ambiguous is True
async def test_geocode_city_ambiguous_false_when_marker_present() -> None:
"""Явный "Екатеринбург" в адресе → город указан пользователем → city_ambiguous=False."""
db = MagicMock()
nominatim_result = GeocodeResult(
lat=56.838, lon=60.605, full_address="Екатеринбург, Малышева, 30", provider="nominatim"
)
with contextlib.ExitStack() as stack:
for cm in _geocode_patches(nominatim_result):
stack.enter_context(cm)
result = await geocode("Екатеринбург, Малышева, 30", db)
assert result is not None
assert result.city_ambiguous is False
async def test_geocode_city_ambiguous_false_when_city_hint_given() -> None:
"""city_hint передан вызывающим кодом → город указан → city_ambiguous=False."""
db = MagicMock()
nominatim_result = GeocodeResult(
lat=57.905, lon=59.950, full_address="Нижний Тагил, Ленина, 1", provider="nominatim"
)
with contextlib.ExitStack() as stack:
for cm in _geocode_patches(nominatim_result):
stack.enter_context(cm)
result = await geocode("Ленина, 1", db, city_hint="Нижний Тагил")
assert result is not None
assert result.city_ambiguous is False
# ── geocode() — cache не смешивает города ────────────────────────────────────
async def test_geocode_cache_does_not_mix_cities() -> None:
"""#2576 regression: два города для одного текста адреса не делят cache-запись.
Без city_hint-aware ключа второй вызов (Тагил) читал бы уже закэшированный
(первым вызовом, ЕКБ) результат координаты ЕКБ вместо Тагила.
"""
store: dict[str, GeocodeResult] = {}
def fake_cache_get(db, addr_norm):
return store.get(addr_norm)
def fake_cache_put(db, addr_norm, result):
store[addr_norm] = result
async def fake_nominatim_lookup(address, city_hint=None):
if city_hint == "Нижний Тагил":
return GeocodeResult(
lat=57.905, lon=59.950, full_address="Нижний Тагил, Ленина, 1", provider="nominatim"
)
return GeocodeResult(
lat=56.838, lon=60.605, full_address="Екатеринбург, Ленина, 1", provider="nominatim"
)
db = MagicMock()
with contextlib.ExitStack() as stack:
stack.enter_context(patch("app.services.geocoder._cache_get", side_effect=fake_cache_get))
stack.enter_context(patch("app.services.geocoder._cache_put", side_effect=fake_cache_put))
stack.enter_context(
patch("app.services.geocoder._geoportal_house_match", return_value=None)
)
stack.enter_context(
patch("app.services.geocoder._cadastral_house_match", return_value=None)
)
stack.enter_context(patch("app.services.geocoder._cadastral_forward_sync", return_value=[]))
stack.enter_context(
patch(
"app.services.geocoder._nominatim_lookup",
new=AsyncMock(side_effect=fake_nominatim_lookup),
)
)
r_ekb = await geocode("Ленина, 1", db, city_hint="Екатеринбург")
r_tagil = await geocode("Ленина, 1", db, city_hint="Нижний Тагил")
# Повторный запрос ЕКБ — должен снова попасть в СВОЙ кэш (не Тагила).
r_ekb_again = await geocode("Ленина, 1", db, city_hint="Екатеринбург")
assert r_ekb is not None and r_tagil is not None and r_ekb_again is not None
assert r_ekb.lat == pytest.approx(56.838)
assert r_tagil.lat == pytest.approx(57.905)
assert r_ekb_again.lat == pytest.approx(56.838)
assert r_ekb.lat != r_tagil.lat
# Два разных ключа реально осели в fake-store (не перезаписали друг друга).
assert len(store) == 2

View file

@ -0,0 +1,232 @@
"""#2594: listings.city проставляется из контекста развёртки, не парсингом адреса.
Критичный дефект: скрапер ЗНАЕТ город в момент сбора (city_slug из
scraper_kit.orchestration.pipeline.CITY_LOCATIONS/CITY_ANCHORS), но раньше нигде его
не записывал. Провайдеры (avito/cian) часто отдают адрес БЕЗ города в тексте
("ул. Победы, 30" вместо "Нижний Тагил, ул. Победы, 30" cian даже явно вырезает
location-часть, providers/cian/serp.py `_format_address` skip_types={"location",...}).
Без города такой адрес при геокодинге считался «город не назван» и коллизировал с
одноимённой ЕКБ-улицей (Ленина/Победы/Тенистая сотни совпадений в ЕКБ-реестре).
Fix: отдельная колонка `listings.city`, проставляется из sweep-контекста (НЕ парсингом
address) через `scraper_kit.base.save_listings(..., city=...)` +
`scraper_kit.orchestration.pipeline.resolve_city_name(city_slug)`. Тесты здесь проверяют
write-path (save_listings SQL) и pure resolve_city_name; orchestration-level проверки
(save_listings вызывается с правильным city= из каждого sweep) в
test_scraper_kit_pipeline_parity.py / test_scraper_kit_pipeline_parity2.py.
Границы (#2594): бэкфилл уже накопленных строк — НЕ в этой задаче.
"""
from __future__ import annotations
import os
import re
from contextlib import contextmanager
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
from scraper_kit.base import ScrapedLot as KitLot
from scraper_kit.base import save_listings as kit_save_listings
@contextmanager
def _nested_ctx() -> Any:
yield MagicMock()
def _mock_db_insert_path(listing_id: int = 42) -> MagicMock:
"""Session mock для fresh INSERT path (xmax = 0 → inserted)."""
insert_row = MagicMock()
insert_row.id = listing_id
insert_row.inserted = True
db = MagicMock()
def _execute(sql: Any, params: dict[str, Any] | None = None) -> MagicMock:
s = str(sql)
res = MagicMock()
if "SELECT card_hash" in s and "WHERE dedup_hash" in s:
res.fetchone.return_value = None
elif "FROM listings_snapshots" in s:
res.fetchone.return_value = None
elif "INSERT INTO listings (" in s:
res.fetchone.return_value = insert_row
else:
res.fetchone.return_value = None
return res
db.execute.side_effect = _execute
db.begin_nested.side_effect = _nested_ctx
return db
def _find_call(db: MagicMock, needle: str) -> tuple[str, dict[str, Any]]:
for call in db.execute.call_args_list:
sql = str(call.args[0])
if needle in sql:
params = call.args[1] if len(call.args) > 1 else {}
return sql, params
raise AssertionError(f"SQL containing {needle!r} not found")
def _kit_matcher() -> MagicMock:
matcher = MagicMock()
matcher.match_or_create_house.return_value = (101, 1.0, "new")
matcher.upsert_listing_source.return_value = None
return matcher
def _lot(source: str = "avito", source_id: str = "1", address: str | None = None) -> KitLot:
return KitLot(
source=source,
source_url=f"https://www.{source}.ru/item/{source_id}",
source_id=source_id,
address=address,
price_rub=3_000_000,
)
# ── save_listings(..., city=...) — INSERT path ────────────────────────────────
def test_save_listings_writes_city_into_insert_sql() -> None:
"""city="Нижний Тагил" передаётся в SQL params И колонка есть в INSERT-списке."""
db = _mock_db_insert_path()
lot = _lot(address="ул. Победы, 30")
with patch("scraper_kit.base.upsert_listing_snapshot", return_value=None):
kit_save_listings(db, [lot], matcher=_kit_matcher(), region_code=66, city="Нижний Тагил")
sql, params = _find_call(db, "INSERT INTO listings (")
assert "city" in sql, "city column must be in INSERT column list"
assert params["city"] == "Нижний Тагил"
# address НЕ тронут — критичное требование #2594 (раздельная колонка, а не
# дописывание города в текст адреса, чтобы не сломать downstream text-парсеры).
assert params["address"] == "ул. Победы, 30"
def test_save_listings_city_defaults_to_none_backward_compat() -> None:
"""Caller без city= (старые/ad-hoc пути) — колонка остаётся NULL, backward-compatible."""
db = _mock_db_insert_path()
lot = _lot(address="ул. Малышева, 30")
with patch("scraper_kit.base.upsert_listing_snapshot", return_value=None):
kit_save_listings(db, [lot], matcher=_kit_matcher(), region_code=66)
_sql, params = _find_call(db, "INSERT INTO listings (")
assert params["city"] is None
def test_save_listings_ekaterinburg_city_written_unchanged_address() -> None:
"""ЕКБ-развёртка (city="Екатеринбург") — тот же путь, address не деградирует."""
db = _mock_db_insert_path()
lot = _lot(address="ул. Малышева, 30")
with patch("scraper_kit.base.upsert_listing_snapshot", return_value=None):
kit_save_listings(db, [lot], matcher=_kit_matcher(), region_code=66, city="Екатеринбург")
_sql, params = _find_call(db, "INSERT INTO listings (")
assert params["city"] == "Екатеринбург"
assert params["address"] == "ул. Малышева, 30"
# ── ON CONFLICT DO UPDATE / reconcile UPDATE — COALESCE не затирает known city ──
def test_save_listings_on_conflict_coalesces_city() -> None:
"""ON CONFLICT DO UPDATE — city = COALESCE(EXCLUDED.city, listings.city), не blind overwrite."""
db = _mock_db_insert_path()
lot = _lot(address="ул. Победы, 30")
with patch("scraper_kit.base.upsert_listing_snapshot", return_value=None):
kit_save_listings(db, [lot], matcher=_kit_matcher(), region_code=66, city="Нижний Тагил")
sql, _params = _find_call(db, "INSERT INTO listings (")
assert "city = COALESCE(EXCLUDED.city, listings.city)" in sql
def test_save_listings_reconcile_update_coalesces_city() -> None:
"""dedup_hash-drift reconcile UPDATE path — тоже COALESCE(:city, city), не blind overwrite."""
import psycopg.errors
from sqlalchemy.exc import IntegrityError
uv_orig = psycopg.errors.UniqueViolation()
integrity_err = IntegrityError("INSERT INTO listings ...", {}, uv_orig)
rec_row = MagicMock()
rec_row.id = 88
db = MagicMock()
def _execute(sql: Any, params: dict[str, Any] | None = None) -> MagicMock:
s = str(sql)
res = MagicMock()
if "SELECT card_hash" in s and "WHERE dedup_hash" in s:
res.fetchone.return_value = None
elif "FROM listings_snapshots" in s:
res.fetchone.return_value = None
elif "INSERT INTO listings (" in s:
raise integrity_err
elif "UPDATE listings" in s and "SET dedup_hash" in s:
res.fetchone.return_value = rec_row
else:
res.fetchone.return_value = None
return res
db.execute.side_effect = _execute
@contextmanager
def _nested() -> Any:
try:
yield MagicMock()
except IntegrityError:
raise
db.begin_nested.side_effect = _nested
lot = _lot(source="avito", source_id="7960764619", address="ул. Тенистая, 17")
with patch("scraper_kit.base.upsert_listing_snapshot", return_value=None):
kit_save_listings(db, [lot], matcher=_kit_matcher(), region_code=66, city="Серов")
sql, params = _find_call(db, "SET dedup_hash")
assert "city = COALESCE(:city, city)" in sql
assert params["city"] == "Серов"
# ── Migration 196: listings.city column ────────────────────────────────────────
_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql"
_MIGRATION_196 = _SQL_DIR / "196_listings_city.sql"
def test_migration_196_exists() -> None:
assert _MIGRATION_196.is_file(), f"missing migration: {_MIGRATION_196}"
def test_migration_196_is_transactional() -> None:
sql = _MIGRATION_196.read_text("utf-8")
assert "BEGIN;" in sql
assert "COMMIT;" in sql
def test_migration_196_idempotent_add_column() -> None:
sql = _MIGRATION_196.read_text("utf-8")
assert "ADD COLUMN IF NOT EXISTS city" in sql
def test_migration_196_no_psycopg_cast_trap() -> None:
"""psycopg v3: никаких :param::type (не применимо тут — чистый DDL — но проверяем
на будущее, если файл когда-нибудь обрастёт bind-параметрами)."""
sql = _MIGRATION_196.read_text("utf-8")
assert not re.search(r":\w+::", sql)
def test_migration_196_non_destructive() -> None:
sql = _MIGRATION_196.read_text("utf-8")
assert "DROP" not in sql.upper()
assert "DELETE" not in sql.upper()
assert "TRUNCATE" not in sql.upper()

View file

@ -0,0 +1,79 @@
"""Тесты для app/core/password.py — bcrypt hash/verify (#2550)."""
from __future__ import annotations
import pytest
from app.core.password import hash_password, verify_password
def test_roundtrip() -> None:
"""hash_password → verify_password с тем же паролем возвращает True."""
hashed = hash_password("correct horse battery staple")
assert verify_password("correct horse battery staple", hashed) is True
def test_wrong_password_returns_false() -> None:
"""Неверный пароль против валидного хеша → False."""
hashed = hash_password("correct horse battery staple")
assert verify_password("wrong password", hashed) is False
def test_hash_too_long_raises_value_error() -> None:
"""Пароль >72 байт в UTF-8 → ValueError в hash_password (нет silent truncation)."""
long_password = "a" * 73
with pytest.raises(ValueError):
hash_password(long_password)
def test_hash_exactly_72_bytes_ok() -> None:
"""Ровно 72 байта — граничное значение, ещё допустимо."""
password = "a" * 72
hashed = hash_password(password)
assert verify_password(password, hashed) is True
def test_hash_too_long_multibyte_raises_value_error() -> None:
"""40 кириллических символов = 80 байт UTF-8 (2 байта/символ) → ValueError.
Проверяет, что лимит считается в байтах, а не в символах иначе 40-символьный
кириллический пароль (< 72 символов, но 80 байт) прошёл бы мимо guard'а.
"""
long_cyrillic_password = "а" * 40
assert len(long_cyrillic_password.encode("utf-8")) == 80
with pytest.raises(ValueError):
hash_password(long_cyrillic_password)
def test_verify_too_long_returns_false_not_raise() -> None:
"""verify_password на >72-байтовом пароле возвращает False, НЕ raise."""
hashed = hash_password("some valid password")
long_password = "a" * 73
assert verify_password(long_password, hashed) is False
def test_hash_empty_raises_value_error() -> None:
"""Пустой пароль → ValueError в hash_password."""
with pytest.raises(ValueError):
hash_password("")
def test_verify_empty_returns_false() -> None:
"""Пустой пароль в verify_password → False (не raise)."""
hashed = hash_password("some valid password")
assert verify_password("", hashed) is False
def test_hash_is_unique_due_to_salt() -> None:
"""Два хеша одного пароля различаются (уникальная соль на каждый вызов)."""
password = "correct horse battery staple"
hash1 = hash_password(password)
hash2 = hash_password(password)
assert hash1 != hash2
assert verify_password(password, hash1) is True
assert verify_password(password, hash2) is True
def test_verify_malformed_hash_returns_false() -> None:
"""Некорректный (не-bcrypt) хеш в verify_password → False, не raise."""
assert verify_password("some password", "not-a-bcrypt-hash") is False

View file

@ -34,7 +34,7 @@ from fastapi.testclient import TestClient
from app.api.v1 import me as me_router
from app.core import auth as auth_mod
from app.core.rbac import rbac_guard
from app.core.rbac import _db_role_path_allowed, rbac_guard
@pytest.fixture(autouse=True)
@ -69,10 +69,30 @@ def _build_test_app() -> FastAPI:
async def brand_dummy() -> dict:
return {"ok": True}
# Внутренние инструменты, закрытые от клиентских ролей 2026-07-31
# (см. _INTERNAL_TOOL_PATHS ниже): API «Доли в продаже» и «Кэша».
@app.get("/api/v1/buildings/sale-share")
async def buildings_sale_share() -> dict:
return {"ok": True}
@app.get("/api/v1/trade-in/cache-stats")
async def tradein_cache_stats() -> dict:
return {"ok": True}
@app.get("/health")
async def health() -> dict:
return {"status": "ok"}
# Анонимная поддержка (инцидент 2026-07-31) — публичная ветка рядом с
# авторизованной, чтобы тесты ниже проверяли ИМЕННО границу между ними.
@app.get("/api/v1/trade-in/support/anon/unread")
async def anon_support_unread() -> dict:
return {"unread": 0}
@app.get("/api/v1/trade-in/support/unread")
async def support_unread() -> dict:
return {"unread": 0}
return app
@ -90,7 +110,9 @@ def test_get_role_known_users() -> None:
assert auth_mod.get_role("admin") == "admin"
assert auth_mod.get_role("kopylov") == "pilot"
for n in range(1, 11):
assert auth_mod.get_role(f"user{n}") == "pilot"
# user2 («Брусника») — доступ закрыт 2026-07-30 (#2548)
expected = "expired" if n == 2 else "pilot"
assert auth_mod.get_role(f"user{n}") == expected
def test_get_role_unknown_user_raises() -> None:
@ -251,6 +273,24 @@ def test_rbac_guard_skips_health(client: TestClient) -> None:
assert resp.json()["status"] == "ok"
def test_rbac_guard_lets_anon_support_through_without_identity(client: TestClient) -> None:
"""Инцидент 2026-07-31: поддержка должна работать БЕЗ входа — иначе тот, кто
не может залогиниться, не может и пожаловаться на это."""
resp = client.get("/api/v1/trade-in/support/anon/unread")
assert resp.status_code == 200
assert resp.json() == {"unread": 0}
def test_rbac_guard_still_gates_authenticated_support(client: TestClient) -> None:
"""Обратная сторона той же границы: анонимная ветка НЕ распахнула соседний
авторизованный support (тред залогиненного юзера по-прежнему за identity)."""
assert client.get("/api/v1/trade-in/support/unread").status_code == 401
resp = client.get(
"/api/v1/trade-in/support/unread", headers={"X-Authenticated-User": "nosuchuser"}
)
assert resp.status_code == 403
def test_rbac_guard_pilot_can_hit_non_admin_api(client: TestClient) -> None:
resp = client.get(
"/api/v1/me",
@ -325,3 +365,168 @@ def test_rbac_guard_no_header_on_non_admin_path_returns_401(client: TestClient)
resp = client.get("/api/v1/me")
assert resp.status_code == 401
assert "no authenticated user" in resp.json()["detail"].lower()
# ---------------------------------------------------------------------------
# 2026-07-31: внутренние разделы («Доля в продаже» / «Кэш») закрыты от клиентов
# ---------------------------------------------------------------------------
#
# Решение владельца продукта: оба раздела — внутренние инструменты (аналитика
# рынка / состояние кэшей и скраперов), клиентские аккаунты их видеть не должны
# (триггер — praktika, DB-роль manager, у которого оба пункта висели в топбаре).
# Deny заведён в DB_ROLE_PATHS (employee/manager) и зеркально в pilot.deny
# (auth/roles.yaml) — страницы И их API, чтобы гейт сработал сразу в трёх местах:
# пункт меню (Topbar через /me), страница (RouteGuard), ручки (rbac_guard).
# Внешние пути (как их видит RBAC-конфиг): 2 страницы + все API раздела.
# Проверяются матчерами напрямую — регистрировать их в тестовом app не нужно.
_INTERNAL_TOOL_PATHS = (
"/trade-in/sale-share",
"/trade-in/cache",
"/trade-in/api/v1/buildings/sale-share",
# Остальные ручки роутера buildings.py — глоб '/…/buildings/**' обязан
# покрывать и их, включая параметризованную (самый вероятный кандидат на
# переезд под другой префикс — тогда этот тест упадёт, а не промолчит).
"/trade-in/api/v1/buildings/sale-share/summary",
"/trade-in/api/v1/buildings/123/listings",
"/trade-in/api/v1/trade-in/cache-stats",
# Трейлинг-слэш: точный паттерн его НЕ ловил (allowed=True), защита висела
# на Starlette redirect_slashes — поэтому deny переведён на глоб-форму.
"/trade-in/api/v1/trade-in/cache-stats/",
)
# Разрез тех же путей по ПРОДУКТАМ — нужен для ролей, у которых доступ
# асимметричен (см. test_yaml_roles_deliberately_outside_client_deny).
# «Поиск домов» — тестовый продукт, admin-only (решение владельца 2026-07-31).
_SALE_SHARE_PATHS = (
"/trade-in/sale-share",
"/trade-in/api/v1/buildings/sale-share",
"/trade-in/api/v1/buildings/sale-share/summary",
"/trade-in/api/v1/buildings/123/listings",
)
# «Кэш» — не продукт, а диагностика состояния кэшей/скраперов.
_CACHE_TOOL_PATHS = (
"/trade-in/cache",
"/trade-in/api/v1/trade-in/cache-stats",
"/trade-in/api/v1/trade-in/cache-stats/",
)
# Разрез обязан покрывать исходный набор целиком — иначе новый внутренний путь
# добавят в _INTERNAL_TOOL_PATHS, забудут отнести к продукту, и роль analyst
# останется непроверенной на нём.
assert set(_SALE_SHARE_PATHS) | set(_CACHE_TOOL_PATHS) == set(_INTERNAL_TOOL_PATHS)
# Основной продукт — не должен быть задет deny выше.
_CORE_PRODUCT_PATHS = ("/trade-in/", "/trade-in/api/v1/trade-in/estimate")
def test_db_roles_denied_on_internal_tool_paths() -> None:
"""manager/employee (DB-роли, session-auth ветка rbac_guard) → deny."""
for role in ("manager", "employee"):
for path in _INTERNAL_TOOL_PATHS:
assert not _db_role_path_allowed(role, path), f"{role} must not reach {path}"
def test_db_admin_still_allowed_on_internal_tool_paths() -> None:
for path in _INTERNAL_TOOL_PATHS:
assert _db_role_path_allowed("admin", path), f"admin lost access to {path}"
def test_yaml_roles_deliberately_outside_client_deny() -> None:
"""Пиннит ОБРАТНУЮ сторону правки 2026-07-31: роли, которые сознательно НЕ
попали под клиентский deny.
Без этого теста «синхронизация» deny-списков между ролями в auth/roles.yaml
(соблазн скопировать pilot.deny в соседей) молча отрезала бы админа от его
же инструментов, и ни один тест бы не упал: roles.yaml лежит ВНЕ paths-фильтров
`backend/**` и `tradein-mvp/**`, т.е. CI такую правку не проверяет вовсе
ровно тот класс рассинхрона, что уже случился с user2 (см.
backend/tests/test_rbac.py::test_get_role_known_users).
`analyst` (внутренняя роль, paths "/**") попадает под клиентский deny
ЧАСТИЧНО, и обе стороны асимметрии здесь запиннены намеренно:
- «Поиск домов» ЗАКРЫТ тестовый продукт, доступ только у admin
(решение владельца 2026-07-31; «только у админа» включает и внутренние
роли, поэтому analyst тоже в deny);
- «Кэш» ОТКРЫТ это не продукт, а диагностика кэшей/скраперов, ровно тот
инструмент, ради которого роль analyst и заведена.
Если решение поменяется упадёт этот тест, а не пользователь на проде.
"""
for path in _INTERNAL_TOOL_PATHS:
assert auth_mod.is_path_allowed("admin", path), f"admin lost access to {path}"
for path in _SALE_SHARE_PATHS:
assert not auth_mod.is_path_allowed("analyst", path), (
f"analyst не должен видеть «Поиск домов» ({path}) — тестовый продукт, "
f"admin-only; если решение изменилось, обнови тест И комментарий у роли "
f"analyst в auth/roles.yaml"
)
for path in _CACHE_TOOL_PATHS:
assert auth_mod.is_path_allowed("analyst", path), (
f"analyst потерял «Кэш» ({path}) — это его рабочий инструмент; если "
f"закрыли намеренно, обнови тест И комментарий у роли analyst"
)
def test_db_roles_still_allowed_on_core_product() -> None:
"""Регресс: оценка (основной продукт) для клиентских ролей не задета."""
for role in ("manager", "employee"):
for path in _CORE_PRODUCT_PATHS:
assert _db_role_path_allowed(role, path), f"{role} lost access to {path}"
def test_legacy_pilot_denied_on_internal_tool_paths() -> None:
"""Зеркало в auth/roles.yaml: пока auth_mode=dual, legacy-pilot не должен
видеть то, что DB-employee уже не видит."""
for path in _INTERNAL_TOOL_PATHS:
assert not auth_mod.is_path_allowed("pilot", path), f"pilot must not reach {path}"
for path in _CORE_PRODUCT_PATHS:
assert auth_mod.is_path_allowed("pilot", path), f"pilot lost access to {path}"
def test_rbac_guard_blocks_pilot_on_internal_tool_api(client: TestClient) -> None:
"""Тот же deny через РЕАЛЬНЫЙ guard (legacy trusted-header ветка): ручки
sale-share/кэша отдают 403, а не только прячутся из меню."""
for path in ("/api/v1/buildings/sale-share", "/api/v1/trade-in/cache-stats"):
resp = client.get(path, headers={"X-Authenticated-User": "kopylov"})
assert resp.status_code == 403, f"pilot {path}: {resp.status_code}"
assert "forbidden for role" in resp.json()["detail"].lower()
def test_rbac_guard_admin_keeps_internal_tool_api(client: TestClient) -> None:
for path in ("/api/v1/buildings/sale-share", "/api/v1/trade-in/cache-stats"):
resp = client.get(path, headers={"X-Authenticated-User": "admin"})
assert resp.status_code == 200, f"admin {path}: {resp.text}"
def test_internal_deny_globs_do_not_leak_to_sibling_prefixes() -> None:
"""Граничный случай: '<prefix>/**' компилируется в '^<prefix>(?:/.*)?$'
матчит сам prefix, prefix со слэшем и подпути через '/', но НЕ соседей по
префиксу (дефис не матчится). Именно поэтому глоб-форма безопасна как
замена точного пути: '/trade-in/cache/**' не задевает страницу
'/trade-in/cache-stats', а '/…/trade-in/cache-stats/**' не гипотетическую
'/…/trade-in/cache-statistics'. Фиксируем семантику тестом: если её однажды
поменяют (напр. на префиксный startswith), соседние пути начнут молча
падать в 403."""
siblings_allowed = (
"/trade-in/cache-stats",
"/trade-in/sale-share-report",
"/trade-in/api/v1/trade-in/cache-statistics",
)
section_denied = (
"/trade-in/cache/detail",
"/trade-in/sale-share/123",
"/trade-in/api/v1/trade-in/cache-stats/reset",
)
for role in ("manager", "employee"):
for path in siblings_allowed:
assert _db_role_path_allowed(role, path), f"{role} lost sibling {path}"
# ...при том что сам раздел и его подпути закрыты.
for path in section_denied:
assert not _db_role_path_allowed(role, path), f"{role} must not reach {path}"
for path in siblings_allowed:
assert auth_mod.is_path_allowed("pilot", path), f"pilot lost sibling {path}"
for path in section_denied:
assert not auth_mod.is_path_allowed("pilot", path), f"pilot must not reach {path}"

View file

@ -93,6 +93,7 @@ class _Scenario:
avito_serp_ok_not_banned: bool = True,
avito_proxy_max_rotations: int = 0,
lots_have_house_url: bool = False,
city_slug: str | None = None,
) -> None:
self.anchors = anchors
self.per_anchor = per_anchor
@ -105,6 +106,9 @@ class _Scenario:
self.avito_serp_ok_not_banned = avito_serp_ok_not_banned
self.avito_proxy_max_rotations = avito_proxy_max_rotations
self.lots_have_house_url = lots_have_house_url
# #2594: city_slug развёртки — прокидывается в run_avito_city_sweep(city_slug=...)
# для проверки, что save_listings получает правильный city=... из контекста.
self.city_slug = city_slug
def _config(self) -> SimpleNamespace:
return SimpleNamespace(
@ -170,11 +174,16 @@ def _async_session_cm() -> MagicMock:
return sess
async def _drive(scenario: _Scenario) -> _DriveResult:
async def _drive(scenario: _Scenario, *, capture: dict[str, Any] | None = None) -> _DriveResult:
"""capture: опциональный dict — если передан, кладём туда save_mock (#2594) для
инспекции call_args (city=...) без изменения возвращаемого _DriveResult (backward-compat
для всех существующих вызовов _drive без capture)."""
recorder = _RunsRecorder()
db = _make_db(scenario)
scraper = _make_scraper(scenario, AvitoBlockedError)
save_mock = MagicMock(side_effect=scenario._save_side_effects())
if capture is not None:
capture["save_mock"] = save_mock
imv_res = None
if scenario.imv_result is not None:
@ -208,6 +217,7 @@ async def _drive(scenario: _Scenario) -> _DriveResult:
shutdown_requested=lambda: False,
radius_m=1000,
anchors=scenario.anchors,
city_slug=scenario.city_slug,
pages_per_anchor=1,
enrich_houses=scenario.enrich_houses,
detail_top_n=scenario.detail_top_n,
@ -307,3 +317,43 @@ async def test_imv_phase_counters() -> None:
assert counters["imv_attempted"] == 3
assert counters["imv_enriched"] == 2
assert counters["imv_failed"] == 1
# ── #2594: listings.city проставляется из контекста развёртки ────────────────
#
# Критичный дефект: развёртка ЗНАЕТ город (city_slug), но раньше НИКУДА его не
# писала — адрес без города в тексте ("ул. Победы, 30") при геокодинге считался
# «город не назван» и коллизировал с одноимённой ЕКБ-улицей. Тесты проверяют, что
# save_listings() теперь получает правильный city= для обоих случаев: явный
# oblast-город (city_slug задан) И EKB-развёртка той же функции (city_slug=None —
# симметрия, а не «не знаем город»).
@pytest.mark.asyncio
async def test_city_stamped_from_city_slug() -> None:
"""city_slug='nizhniy_tagil' → save_listings(..., city='Нижний Тагил')."""
scenario = _Scenario(
anchors=[(56.84, 60.60, "A1")],
per_anchor=[("lots", 3, 3, 0)],
city_slug="nizhniy_tagil",
)
capture: dict[str, Any] = {}
await _drive(scenario, capture=capture)
save_mock = capture["save_mock"]
assert save_mock.call_args.kwargs["city"] == "Нижний Тагил"
@pytest.mark.asyncio
async def test_city_defaults_to_ekaterinburg_when_no_city_slug() -> None:
"""city_slug=None (ЕКБ-развёртка той же run_avito_city_sweep) →
save_listings(..., city='Екатеринбург') симметрия с oblast-городами (#2594),
а не оставленный NULL."""
scenario = _Scenario(
anchors=[(56.84, 60.60, "A1")],
per_anchor=[("lots", 3, 3, 0)],
city_slug=None,
)
capture: dict[str, Any] = {}
await _drive(scenario, capture=capture)
save_mock = capture["save_mock"]
assert save_mock.call_args.kwargs["city"] == "Екатеринбург"

View file

@ -136,7 +136,10 @@ def _yandex_scraper(combos: list[tuple[str, list[Any]]]) -> MagicMock:
return _ctx_scraper(fetch_around_multi_room=_fetch)
async def _drive_yandex_city() -> _DriveResult:
async def _drive_yandex_city(
*, city_slug: str | None = None, capture: dict[str, Any] | None = None
) -> _DriveResult:
"""capture: опционально — если передан, кладём save_mock (#2594, инспекция city=...)."""
recorder = _RunsRecorder()
db = MagicMock()
combos = [
@ -145,6 +148,8 @@ async def _drive_yandex_city() -> _DriveResult:
]
scraper = _yandex_scraper(combos)
save_mock = MagicMock(side_effect=[(2, 0), (1, 0)])
if capture is not None:
capture["save_mock"] = save_mock
cfg = _config()
enrichment = MagicMock()
enrichment.record_yandex_price_history = MagicMock(return_value=5)
@ -160,6 +165,7 @@ async def _drive_yandex_city() -> _DriveResult:
enrichment=enrichment,
run_id=1,
anchors=None,
city_slug=city_slug,
pages_per_anchor=1,
request_delay_sec=0.0,
enrich_address=False,
@ -185,13 +191,18 @@ def _cian_lot(segment: str) -> MagicMock:
return MagicMock(listing_segment=segment, house_source=None, house_ext_id=None)
async def _drive_cian_city() -> _DriveResult:
async def _drive_cian_city(
*, city_slug: str | None = None, capture: dict[str, Any] | None = None
) -> _DriveResult:
"""capture: опционально — если передан, кладём save_mock (#2594, инспекция city=...)."""
recorder = _RunsRecorder()
db = MagicMock()
# 3 novostroyki + 2 secondary → newbuilding_only оставит 3.
lots = [_cian_lot("novostroyki")] * 3 + [_cian_lot("vtorichnaya")] * 2
scraper = _ctx_scraper(fetch_around_multi_room=AsyncMock(return_value=lots))
save_mock = MagicMock(side_effect=[(3, 0)])
if capture is not None:
capture["save_mock"] = save_mock
cfg = _config()
with (
patch(f"{PFX}.CianScraper", return_value=scraper),
@ -204,6 +215,7 @@ async def _drive_cian_city() -> _DriveResult:
matcher=MagicMock(),
run_id=1,
anchors=[(56.84, 60.60, "A1")],
city_slug=city_slug,
radius_m=1000,
pages_per_anchor=1,
request_delay_sec=0.0,
@ -228,7 +240,10 @@ async def test_cian_city_sweep() -> None:
# ── DomClick city sweep ───────────────────────────────────────────────────────
async def _drive_domclick(*, lots_n: int, blocked: bool) -> _DriveResult:
async def _drive_domclick(
*, lots_n: int, blocked: bool, capture: dict[str, Any] | None = None
) -> _DriveResult:
"""capture: опционально — если передан, кладём save_mock (#2594, инспекция city=...)."""
recorder = _RunsRecorder()
db = MagicMock()
lots = [MagicMock() for _ in range(lots_n)]
@ -239,6 +254,8 @@ async def _drive_domclick(*, lots_n: int, blocked: bool) -> _DriveResult:
fetch_errors=0,
)
save_mock = MagicMock(side_effect=[(lots_n, 0)] if lots_n else [])
if capture is not None:
capture["save_mock"] = save_mock
cfg = _config()
with (
patch(f"{PFX}.DomClickScraper", return_value=scraper),
@ -272,7 +289,8 @@ async def test_domclick_city_sweep_blocked_failed() -> None:
# ── Avito newbuilding sweep ───────────────────────────────────────────────────
async def _drive_nb_sweep() -> _DriveResult:
async def _drive_nb_sweep(*, capture: dict[str, Any] | None = None) -> _DriveResult:
"""capture: опционально — если передан, кладём save_mock (#2594, инспекция city=...)."""
recorder = _RunsRecorder()
db = MagicMock()
lots = [MagicMock() for _ in range(6)]
@ -281,6 +299,8 @@ async def _drive_nb_sweep() -> _DriveResult:
scraper._browser = None
scraper.fetch_newbuildings = AsyncMock(return_value=lots)
save_mock = MagicMock(side_effect=[(5, 1)])
if capture is not None:
capture["save_mock"] = save_mock
cfg = _config()
with (
patch(f"{PFX}.AvitoScraper", return_value=scraper),
@ -319,7 +339,8 @@ def _full_load_scraper(buckets: list[tuple[str, list[Any]]]) -> MagicMock:
return scraper
async def _drive_full_load(*, source: str) -> _DriveResult:
async def _drive_full_load(*, source: str, capture: dict[str, Any] | None = None) -> _DriveResult:
"""capture: опционально — если передан, кладём save_mock (#2594, инспекция city=...)."""
recorder = _RunsRecorder()
db = MagicMock()
buckets = [
@ -328,6 +349,8 @@ async def _drive_full_load(*, source: str) -> _DriveResult:
]
scraper = _full_load_scraper(buckets)
save_mock = MagicMock(side_effect=[(2, 0), (1, 0)])
if capture is not None:
capture["save_mock"] = save_mock
cfg = _config()
fn_map = {
@ -365,3 +388,77 @@ async def test_full_load_smoke(source: str) -> None:
assert counters["saved_inserted"] == 3
assert counters["saved_updated"] == 0
assert calls[-1][0] == "mark_done"
# ── #2594: listings.city проставляется из контекста развёртки ────────────────
#
# Критичный дефект: развёртка ЗНАЕТ город (city_slug), но раньше НИКУДА его не
# писала. Тесты проверяют save_listings(..., city=...) для yandex/cian city-sweep
# (oblast + EKB-симметрия), domclick (EKB-only city_id) и full_load'ов (ЕКБ вторичка).
@pytest.mark.asyncio
async def test_yandex_city_sweep_stamps_city_from_slug() -> None:
"""city_slug='kamensk_uralskiy' → save_listings(..., city='Каменск-Уральский')."""
capture: dict[str, Any] = {}
await _drive_yandex_city(city_slug="kamensk_uralskiy", capture=capture)
save_mock = capture["save_mock"]
assert save_mock.call_args_list[-1].kwargs["city"] == "Каменск-Уральский"
@pytest.mark.asyncio
async def test_yandex_city_sweep_stamps_ekaterinburg_when_no_city_slug() -> None:
"""city_slug=None (ЕКБ-развёртка) → save_listings(..., city='Екатеринбург')."""
capture: dict[str, Any] = {}
await _drive_yandex_city(city_slug=None, capture=capture)
save_mock = capture["save_mock"]
assert save_mock.call_args_list[-1].kwargs["city"] == "Екатеринбург"
@pytest.mark.asyncio
async def test_cian_city_sweep_stamps_city_from_slug() -> None:
"""city_slug='pervouralsk' → save_listings(..., city='Первоуральск')."""
capture: dict[str, Any] = {}
await _drive_cian_city(city_slug="pervouralsk", capture=capture)
save_mock = capture["save_mock"]
assert save_mock.call_args.kwargs["city"] == "Первоуральск"
@pytest.mark.asyncio
async def test_cian_city_sweep_stamps_ekaterinburg_when_no_city_slug() -> None:
"""city_slug=None (ЕКБ-развёртка) → save_listings(..., city='Екатеринбург')."""
capture: dict[str, Any] = {}
await _drive_cian_city(city_slug=None, capture=capture)
save_mock = capture["save_mock"]
assert save_mock.call_args.kwargs["city"] == "Екатеринбург"
@pytest.mark.asyncio
async def test_domclick_city_sweep_stamps_ekaterinburg_for_default_city_id() -> None:
"""city_id=DOMCLICK_DEFAULT_CITY_ID (4, ЕКБ) → save_listings(..., city='Екатеринбург')."""
capture: dict[str, Any] = {}
await _drive_domclick(lots_n=4, blocked=False, capture=capture)
save_mock = capture["save_mock"]
assert save_mock.call_args.kwargs["city"] == "Екатеринбург"
@pytest.mark.asyncio
async def test_avito_newbuilding_sweep_stamps_ekaterinburg() -> None:
"""Citywide novostroyka-обход — только ЕКБ → save_listings(..., city='Екатеринбург')."""
capture: dict[str, Any] = {}
await _drive_nb_sweep(capture=capture)
save_mock = capture["save_mock"]
assert save_mock.call_args.kwargs["city"] == "Екатеринбург"
@pytest.mark.asyncio
@pytest.mark.parametrize("source", ["avito", "cian", "yandex"])
async def test_full_load_stamps_ekaterinburg(source: str) -> None:
"""Exhaustive региональный сбор — только ЕКБ вторичка → city='Екатеринбург' на КАЖДОМ
бакете (on_bucket сохраняет инкрементально, не один batch на весь run)."""
capture: dict[str, Any] = {}
await _drive_full_load(source=source, capture=capture)
save_mock = capture["save_mock"]
assert save_mock.call_count > 0
for call in save_mock.call_args_list:
assert call.kwargs["city"] == "Екатеринбург"

View file

@ -57,6 +57,7 @@ _PRODUCT_SOURCES: set[str] = {
"rosreestr_dkp_import",
"listing_source_snapshot",
"asking_to_sold_ratio_refresh",
"deal_city_price_bands_refresh",
"refresh_search_matview",
"yandex_address_backfill",
"deactivate_stale_avito",

View file

@ -87,7 +87,11 @@ def client(db: MagicMock) -> TestClient:
yield db
app.dependency_overrides[get_db] = fake_db
return TestClient(app)
# https, а не дефолтный http: анонимная ветка ставит идентити-куку с
# `secure=True` (как session-cookie), и по http httpx её не вернул бы в
# следующем запросе — тесты «тот же тред / тот же бюджет лимита» тихо
# проверяли бы каждый раз НОВОГО анонима. Прод и так только https.
return TestClient(app, base_url="https://testserver")
def _auth(username: str = "alice") -> dict[str, str]:
@ -417,17 +421,19 @@ def test_list_messages_returns_thread_scoped_rows(
monkeypatch.setattr(
support_module.storage,
"list_messages",
lambda db, *, thread_id, since_id, limit: [
{
"id": 1,
"direction": "in",
"text_body": "hi",
"operator_tg_id": None,
"created_at": "2026-07-26T00:00:00+00:00",
}
]
if thread_id == 7
else [],
lambda db, *, thread_id, since_id, limit: (
[
{
"id": 1,
"direction": "in",
"text_body": "hi",
"operator_tg_id": None,
"created_at": "2026-07-26T00:00:00+00:00",
}
]
if thread_id == 7
else []
),
)
r = client.get("/api/v1/trade-in/support/messages", params={"since": 0}, headers=_auth("alice"))
assert r.status_code == 200
@ -534,3 +540,194 @@ def test_mark_read_calls_storage_when_thread_exists(
assert r.status_code == 200
assert mark_called == [7]
assert db.commit.called
# ── анонимная ветка: поддержка без входа (инцидент 2026-07-31) ────────────────
@pytest.fixture(autouse=True)
def _fresh_anon_ip_limiter(monkeypatch: pytest.MonkeyPatch) -> None:
"""Как `_fresh_rate_limiter`, но для per-IP бюджета анонимной ветки — иначе
состояние течёт между тестами в одном процессе pytest."""
monkeypatch.setattr(
support_module, "_anon_ip_limiter", SlidingWindowLimiter(limit=1000, window_s=60.0)
)
def _patch_anon_storage(monkeypatch: pytest.MonkeyPatch) -> list[str]:
"""Мокает storage для send-пути и возвращает список ключей тредов, с которыми
его позвали (проверяем, что аноним адресуется `anon:<token>`, а не логином)."""
seen_keys: list[str] = []
def fake_get_or_create(db: Any, username: str) -> int:
seen_keys.append(username)
return 1
monkeypatch.setattr(support_module.storage, "get_or_create_thread", fake_get_or_create)
monkeypatch.setattr(
support_module.storage,
"record_inbound",
lambda *a, **kw: {
"id": 1,
"direction": "in",
"text_body": kw["text_body"],
"operator_tg_id": None,
"created_at": "2026-07-31T00:00:00+00:00",
},
)
return seen_keys
def test_anon_send_without_any_auth_succeeds_and_sets_cookie(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Суть фичи: залогиниться нельзя, а написать в поддержку — можно."""
seen_keys = _patch_anon_storage(monkeypatch)
r = client.post("/api/v1/trade-in/support/anon/messages", json={"text": "не могу войти"})
assert r.status_code == 200, r.text
assert r.json()["text_body"] == "не могу войти"
token = client.cookies.get(support_module._ANON_COOKIE_NAME)
assert token is not None
assert support_module._ANON_TOKEN_RE.match(token)
# Тред адресован анонимным ключом, не голым токеном и не чьим-то логином.
assert seen_keys == [f"anon:{token}"]
def test_anon_cookie_reused_across_messages_same_thread(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
seen_keys = _patch_anon_storage(monkeypatch)
client.post("/api/v1/trade-in/support/anon/messages", json={"text": "первое"})
token_after_first = client.cookies.get(support_module._ANON_COOKIE_NAME)
client.post("/api/v1/trade-in/support/anon/messages", json={"text": "второе"})
assert client.cookies.get(support_module._ANON_COOKIE_NAME) == token_after_first
assert seen_keys == [f"anon:{token_after_first}"] * 2
def test_anon_mirror_is_labelled_and_never_leaks_token(
client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
) -> None:
"""Оператор видит, что это НЕзалогиненный посетитель, но bearer треда в
Telegram-топик не уходит (топик читают люди и пересылают дальше)."""
_patch_anon_storage(monkeypatch)
client.post("/api/v1/trade-in/support/anon/messages", json={"text": "помогите"})
token = client.cookies.get(support_module._ANON_COOKIE_NAME)
sent_text = _fake_telegram_client.calls[-1]["text"]
assert sent_text.startswith("[С САЙТА · БЕЗ ВХОДА] anon-")
assert "помогите" in sent_text
assert token not in sent_text
assert support_module._anon_display_id(token) in sent_text
def test_anon_read_paths_without_cookie_are_empty_not_401(client: TestClient) -> None:
"""Виджет поллит эти ручки ДО первого сообщения — 401 там был бы ложной ошибкой."""
assert client.get("/api/v1/trade-in/support/anon/messages").status_code == 200
assert client.get("/api/v1/trade-in/support/anon/messages").json() == []
assert client.get("/api/v1/trade-in/support/anon/unread").json() == {"unread": 0}
assert client.post("/api/v1/trade-in/support/anon/read").json() == {"status": "ok"}
def test_anon_malformed_cookie_ignored_and_never_reaches_storage(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Кука клиент-контролируема: мусор из браузера не должен становиться ключом
треда. Считаем куку отсутствующей и выдаём новую."""
seen_keys = _patch_anon_storage(monkeypatch)
bogus = "not-a-valid-token!@#$%^"
client.cookies.set(support_module._ANON_COOKIE_NAME, bogus)
r = client.post("/api/v1/trade-in/support/anon/messages", json={"text": "hi"})
assert r.status_code == 200
assert len(seen_keys) == 1
assert bogus not in seen_keys[0]
assert seen_keys[0].startswith("anon:")
assert support_module._ANON_TOKEN_RE.match(seen_keys[0].removeprefix("anon:"))
def test_anon_read_path_with_malformed_cookie_returns_empty(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
find_calls = []
monkeypatch.setattr(
support_module.storage,
"find_thread_id",
lambda db, username: find_calls.append(username),
)
client.cookies.set(support_module._ANON_COOKIE_NAME, "!!not-a-token!!")
assert client.get("/api/v1/trade-in/support/anon/messages").json() == []
assert find_calls == [] # до storage мусор не доехал вообще
def test_anon_per_ip_rate_limit_429(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
"""Главный анти-абуз: per-token бюджет обходится сбросом куки, per-IP — нет."""
_patch_anon_storage(monkeypatch)
monkeypatch.setattr(
support_module, "_anon_ip_limiter", SlidingWindowLimiter(limit=1, window_s=60.0)
)
assert (
client.post("/api/v1/trade-in/support/anon/messages", json={"text": "1"}).status_code == 200
)
# Ротация куки НЕ спасает — бюджет привязан к IP.
client.cookies.delete(support_module._ANON_COOKIE_NAME)
r = client.post("/api/v1/trade-in/support/anon/messages", json={"text": "2"})
assert r.status_code == 429
assert "Retry-After" in r.headers
def test_anon_per_token_rate_limit_429(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
_patch_anon_storage(monkeypatch)
monkeypatch.setattr(
support_module, "_send_limiter", SlidingWindowLimiter(limit=1, window_s=60.0)
)
assert (
client.post("/api/v1/trade-in/support/anon/messages", json={"text": "1"}).status_code == 200
)
assert (
client.post("/api/v1/trade-in/support/anon/messages", json={"text": "2"}).status_code == 429
)
def test_anon_failed_send_sets_no_cookie_and_writes_nothing(
client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
) -> None:
"""Неудачная отправка не должна закреплять за посетителем пустой тред."""
seen_keys = _patch_anon_storage(monkeypatch)
_fake_telegram_client._response = TelegramApiError("sendMessage", 500, "boom")
r = client.post("/api/v1/trade-in/support/anon/messages", json={"text": "hi"})
assert r.status_code == 502
assert seen_keys == []
assert client.cookies.get(support_module._ANON_COOKIE_NAME) is None
def test_anon_bot_not_configured_503(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(support_module.settings, "telegram_bot_token", "")
r = client.post("/api/v1/trade-in/support/anon/messages", json={"text": "hi"})
assert r.status_code == 503
assert client.cookies.get(support_module._ANON_COOKIE_NAME) is None
def test_anon_blank_text_422(client: TestClient) -> None:
r = client.post("/api/v1/trade-in/support/anon/messages", json={"text": " "})
assert r.status_code == 422
def test_anon_thread_key_cannot_collide_with_real_username() -> None:
"""Инвариант изоляции: `anon:` невозможен в реальном логине (CHECK миграции
193 + Pydantic `^[A-Za-z0-9._-]{3,64}$`), значит аноним структурно не может
попасть в тред существующего пользователя."""
from app.schemas.team import _USERNAME_RE
key = support_module._anon_thread_key(support_module.secrets.token_urlsafe(18))
assert key.startswith("anon:")
assert _USERNAME_RE.match(key) is None

File diff suppressed because it is too large Load diff

View file

@ -76,14 +76,30 @@ def _db_mock(rows: list[dict[str, Any]]) -> MagicMock:
return db
def _fetch(db: MagicMock) -> tuple[list[dict[str, Any]], str | None]:
"""Вызов _fetch_anchor_comps с Tier A-релевантными аргументами (без lat/lon → Tier C skip)."""
# #2581: subject lat/lon — совпадают с _row()'s hardcoded lat/lon (56.838/60.595),
# т.е. subject и comps в одной точке ЕКБ → ST_DWithin(ANCHOR_TIER_A_RADIUS_M)
# тривиально проходит, не мешая #1774 novostroyki-gating semantics ниже.
_LAT = 56.838
_LON = 60.595
def _fetch(
db: MagicMock, *, lat: float | None = None, lon: float | None = None
) -> tuple[list[dict[str, Any]], str | None]:
"""Вызов _fetch_anchor_comps с Tier A-релевантными аргументами.
lat/lon по умолчанию None тесты, ожидающие tier=None (novostroyki-gate
отбраковал все comps до min_comps), передают None намеренно: с #2581
геогейтом Tier A целиком пропускается без lat/lon (см. Tier C тот же
паттерн), что и раньше давало tier=None (Tier C тоже требовал lat/lon).
Тесты, ожидающие tier=='A', передают _LAT/_LON явно.
"""
return _fetch_anchor_comps(
db,
address=_ADDRESS,
target_house_id=None,
lat=None,
lon=None,
lat=lat,
lon=lon,
rooms=2,
area=50.0,
)
@ -106,7 +122,7 @@ def test_tier_a_includes_novostroyki_when_secondary_present() -> None:
]
db = _db_mock(rows)
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
comps, tier = _fetch(db)
comps, tier = _fetch(db, lat=_LAT, lon=_LON)
assert tier == "A"
# Все 6 (4 вторички + 2 novostroyki-переуступки) учтены.
assert len(comps) == 6
@ -178,7 +194,7 @@ def test_tier_a_dedup_same_source_id_collapses() -> None:
]
db = _db_mock(rows)
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
comps, tier = _fetch(db)
comps, tier = _fetch(db, lat=_LAT, lon=_LON)
assert tier == "A"
# 6 строк, но 2 cian-строки с одинаковым source_id → 1 comp → итого 5.
assert len(comps) == 5
@ -214,7 +230,7 @@ def test_tier_a_dedup_same_source_id_different_url_collapses() -> None:
]
db = _db_mock(rows)
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
comps, tier = _fetch(db)
comps, tier = _fetch(db, lat=_LAT, lon=_LON)
assert tier == "A"
# source_id-primary схлопывает несмотря на разные url → 5 comps.
assert len(comps) == 5
@ -233,7 +249,7 @@ def test_tier_a_dedup_null_url_keeps_distinct_rows() -> None:
]
db = _db_mock(rows)
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
comps, tier = _fetch(db)
comps, tier = _fetch(db, lat=_LAT, lon=_LON)
assert tier == "A"
# 4 разных лота (разные source_id/площадь) → 4 comps, ничего не схлопнуто.
assert len(comps) == 4

View file

@ -0,0 +1,305 @@
"use client";
/**
* #2555 (эпик #2549) login-форма для новой DB-backed session auth
* (POST /api/v1/auth/login, см. `tradein-mvp/backend/app/api/v1/auth.py`).
*
* Не гейтится RouteGuard'ом (см. `components/auth/RouteGuard.tsx`
* `isLoginPage` bypass) иначе редирект-петля: 401 от /me на /login тоже
* пытался бы увести на /login.
*
* `next=` читаем вручную из `window.location.search` (SSR-guard), а НЕ
* через `next/navigation` `useSearchParams()` тот форсит Suspense boundary
* и ломает `next build` (см. `app/v2/page.tsx: readUrlId` тот же паттерн,
* уже принятый в этом репо).
*/
import { useState } from "react";
import type { CSSProperties, FormEvent } from "react";
import { useRouter } from "next/navigation";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiFetch, HTTPError } from "@/lib/api";
import { ME_QUERY_KEY } from "@/lib/useMe";
import { AnonSupportWidget } from "@/components/auth/AnonSupportWidget";
interface LoginInput {
username: string;
password: string;
}
async function loginRequest(input: LoginInput): Promise<void> {
await apiFetch<{ ok: boolean }>("/api/v1/auth/login", {
method: "POST",
body: JSON.stringify(input),
});
}
function readNextParam(): string | null {
if (typeof window === "undefined") return null;
return new URLSearchParams(window.location.search).get("next");
}
/**
* Open-redirect guard: принимаем только внутренний путь, начинающийся
* ровно с одного "/" не "//host" (protocol-relative URL) и не "/\host"
* (браузеры местами трактуют backslash как forward slash в URL-парсинге).
*
* PR #2562 review finding 1: WHATWG URL-парсер (который `router.push`
* использует под капотом) убирает ВСЕ ASCII tab/CR/LF из строки ПЕРЕД
* парсингом так `"/\t//evil"` для наивного regex выглядит как безопасный
* путь с одним leading slash (символ в позиции 1 таб, не "/" и не "\"),
* а после навигации превращается в `"//evil"` (protocol-relative чужой
* origin). Убираем те же символы ДО валидации, чтобы regex видел ту же
* строку, что увидит парсер.
*
* PR #2562 review finding 2: `next=/login` (или `/login?...`) после успешного
* логина кидал бы юзера обратно на форму входа (RouteGuard не гейтит
* `/login`) dead-end. Фолбэк на "/" в этом случае.
*/
function sanitizeNext(next: string | null): string {
if (!next) return "/";
const cleaned = next.replace(/[\t\r\n]/g, "");
if (!/^\/(?!\/|\\)/.test(cleaned)) return "/";
if (
cleaned === "/login" ||
cleaned.startsWith("/login?") ||
cleaned.startsWith("/login#")
) {
return "/";
}
return cleaned;
}
function loginErrorMessage(error: unknown): string {
if (error instanceof HTTPError) {
if (error.status === 401) return "Неверный логин или пароль";
if (error.status === 429) {
return "Слишком много попыток. Попробуйте через несколько минут";
}
}
return "Не удалось войти. Проверьте подключение и попробуйте ещё раз";
}
const cardStyle: CSSProperties = {
background: "var(--bg-card)",
border: "1px solid var(--border-card)",
borderRadius: 12,
padding: "32px 28px",
maxWidth: 380,
width: "100%",
};
const labelStyle: CSSProperties = {
display: "block",
fontSize: 12,
fontWeight: 500,
color: "var(--fg-secondary)",
marginBottom: 6,
};
const inputStyle: CSSProperties = {
width: "100%",
boxSizing: "border-box",
height: 40,
padding: "0 12px",
border: "1px solid var(--border-card)",
borderRadius: 8,
fontSize: 14,
color: "var(--fg-primary)",
background: "var(--bg-card)",
fontFamily: "inherit",
};
export default function LoginPage() {
const router = useRouter();
const queryClient = useQueryClient();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const loginMutation = useMutation({
mutationFn: loginRequest,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ME_QUERY_KEY });
router.push(sanitizeNext(readNextParam()));
},
});
function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
if (loginMutation.isPending) return;
loginMutation.mutate({ username: username.trim(), password });
}
return (
<main
style={{
minHeight: "100vh",
background: "var(--bg-app)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 24,
fontFamily: "Inter, -apple-system, 'Segoe UI', system-ui, sans-serif",
}}
>
<style>{`
@keyframes login-spin { to { transform: rotate(360deg); } }
.login-spinner { animation: login-spin .7s linear infinite; }
.login-input:focus-visible {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-soft);
}
.login-submit:hover:not(:disabled) { background: var(--accent-hover); }
`}</style>
<form onSubmit={handleSubmit} style={cardStyle} aria-label="Вход в Меру">
<h1
style={{
margin: "0 0 8px",
fontSize: 22,
fontWeight: 600,
color: "var(--fg-primary)",
lineHeight: 1.25,
}}
>
Вход
</h1>
<p
style={{
margin: "0 0 24px",
fontSize: 14,
color: "var(--fg-secondary)",
lineHeight: 1.5,
}}
>
Войдите, чтобы продолжить работу с Мерой.
</p>
<div style={{ marginBottom: 16 }}>
<label style={labelStyle} htmlFor="login-username">
Логин
</label>
<input
id="login-username"
name="username"
type="text"
autoComplete="username"
required
autoFocus
className="login-input"
style={inputStyle}
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={loginMutation.isPending}
/>
</div>
<div style={{ marginBottom: 24 }}>
<label style={labelStyle} htmlFor="login-password">
Пароль
</label>
<input
id="login-password"
name="password"
type="password"
autoComplete="current-password"
required
className="login-input"
style={inputStyle}
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loginMutation.isPending}
/>
</div>
{loginMutation.isError ? (
<p
role="alert"
style={{
margin: "0 0 16px",
padding: "8px 12px",
borderRadius: 8,
background: "var(--danger-soft)",
color: "var(--danger)",
fontSize: 13,
lineHeight: 1.4,
}}
>
{loginErrorMessage(loginMutation.error)}
</p>
) : null}
<button
type="submit"
className="login-submit"
disabled={loginMutation.isPending}
style={{
width: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
background: "var(--accent)",
color: "#FFFFFF",
border: "none",
borderRadius: 8,
padding: "10px 16px",
fontSize: 14,
fontWeight: 500,
cursor: loginMutation.isPending ? "wait" : "pointer",
opacity: loginMutation.isPending ? 0.75 : 1,
fontFamily: "inherit",
}}
>
{loginMutation.isPending ? (
<>
<svg
className="login-spinner"
width={16}
height={16}
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<circle
cx="12"
cy="12"
r="9"
stroke="rgba(255,255,255,0.35)"
strokeWidth="3"
/>
<path
d="M21 12a9 9 0 0 0-9-9"
stroke="#FFFFFF"
strokeWidth="3"
strokeLinecap="round"
/>
</svg>
Входим
</>
) : (
"Войти"
)}
</button>
<p
style={{
margin: "16px 0 0",
fontSize: 12,
color: "var(--fg-tertiary)",
lineHeight: 1.5,
}}
>
Не получается войти? Напишите нам кнопка «Поддержка» в правом нижнем
углу. Отвечаем без входа в систему.
</p>
</form>
{/* Инцидент 2026-07-31: без этого тот, кто не может залогиниться, не
может и сообщить об этом единственный чат был за логином. */}
<AnonSupportWidget />
</main>
);
}

View file

@ -6,9 +6,9 @@ import { pageBg } from "@/components/trade-in/v2/tokens";
/**
* ЭТАП 1 плана B2C-запуска МЕРА: публичный периметр БЕЗ функционала.
*
* Обслуживается ОТДЕЛЬНЫМ поддоменом mera.gendsgn.ru (см. корневой Caddyfile,
* site-блок `mera.gendsgn.ru { ... }`) не напрямую по basePath. Caddy на
* этом поддомене rewrite'ит запрос корня "/" в "/trade-in/mera-public" (тот
* Обслуживается ОТДЕЛЬНЫМ доменом meraocenka.ru (см. корневой Caddyfile,
* site-блок `meraocenka.ru { ... }`) не напрямую по basePath. Caddy на
* этом домене rewrite'ит запрос корня "/" в "/trade-in/mera-public" (тот
* же образ tradein-frontend, что обслуживает и gendsgn.ru/trade-in/*, у него
* запечён NEXT_PUBLIC_BASE_PATH=/trade-in) пользователь префикс /trade-in
* никогда не видит, это внутренний Caddybackend hop.

View file

@ -5,7 +5,18 @@
* Порог % дома вторички, где доля квартир, выставленных на продажу, порога.
* Сигнал для девелопера: расселение / инвест-выход / проблемный дом.
*
* Доступ: pilot + admin (RBAC roles.yaml: pilot paths `/trade-in/**`).
* Доступ: ТОЛЬКО admin (с 2026-07-31). Это ТЕСТОВЫЙ продукт решение владельца
* продукта: клиентам не показывать, доступ только у админа. Явный deny
* `/trade-in/sale-share/**` + `/trade-in/api/v1/buildings/**` заведён для
* DB-ролей employee/manager (`app/services/auth_session.py: DB_ROLE_PATHS`),
* для legacy `pilot` И для внутренней `analyst` (`auth/roles.yaml`) «только у
* админа» включает и внутренние роли. Ср. соседний «Кэш»: он закрыт от клиентов,
* но analyst'у оставлен, т.к. это диагностика, а не продукт.
*
* NB: короткий адрес `gendsgn.ru/sale-share` (301 сюда, см. Caddyfile) ведёт
* на NoAccessScreen для всех, кроме admin. Если раздел когда-нибудь станет
* продаваемым, одним снятием deny не обойтись: нужен per-account carve-out
* скоуп сейчас только ролевой, выдать раздел отдельному клиенту нечем.
*/
import { useCallback, useMemo, useRef, useState } from "react";
import dynamic from "next/dynamic";

View file

@ -0,0 +1,180 @@
"use client";
/**
* Team-дашборд менеджера (#2556, эпик #2549) единая страница для ролей
* `admin` и `manager` (backend сам скоупит список по org-изоляции, см.
* `app/api/v1/team.py::list_employees`), UI не дублируется под роли.
*
* Доступ: `useMe().role` проверяется на клиенте ДО первого запроса (не ждём
* доомed 403 round-trip), но HTTPError 403 от самого списка сотрудников тоже
* ловится тот же defense-in-depth паттерн, что и `app/admin/audit/page.tsx`
* (прямой заход на URL не крашит страницу, даже если nav её уже прячет).
*
* #2556 fix (эпик #2549, live-audit): team-роуты требуют session-cookie
* (см. team-api.ts header comment) легаси Caddy-заголовок (`/me` 200 с
* ролью admin/manager) через него НЕ проходит. Юзер, прошедший только Caddy
* basic_auth (без входа в форму `/login`), видел здесь непонятную красную
* плашку «Не удалось выполнить запрос». Ловим именно 401 от employeesQ
* ОТДЕЛЬНО от 403 (403 = «нет прав», это ожидаемо и есть у другой роли; 401
* = «нет сессии вообще», это решается логином) и уводим на /login?next=/team
* тот же redirect-паттерн, что RouteGuard.tsx использует для 401 от /me.
*/
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import "@/components/trade-in/trade-in.css";
import { NoAccessScreen } from "@/components/auth/NoAccessScreen";
import { CreateEmployeeForm } from "@/components/team/CreateEmployeeForm";
import { EmployeeHistoryDrawer } from "@/components/team/EmployeeHistoryDrawer";
import { EmployeeTable } from "@/components/team/EmployeeTable";
import { QuotaEditor } from "@/components/team/QuotaEditor";
import { Topbar } from "@/components/trade-in/Topbar";
import { HTTPError } from "@/lib/api";
import type { Employee } from "@/lib/team-api";
import { teamErrorMessage, useEmployees } from "@/lib/team-api";
import { useMe } from "@/lib/useMe";
const PAGE_LIMIT = 50;
const TEAM_LOGIN_NEXT = `/login?next=${encodeURIComponent("/team")}`;
export default function TeamPage() {
const meQ = useMe();
const router = useRouter();
// Вычисляем ДО useEmployees (не после) — иначе для employee/analyst/pilot
// при прямом заходе на /team улетает обречённый GET (401/403) ещё до того,
// как ниже отрисуется role-gate. Порядок хуков не меняется — это просто
// производное значение, не условный вызов хука.
const role = meQ.data?.role;
const isAllowedRole = role === "admin" || role === "manager";
const [offset, setOffset] = useState(0);
const employeesQ = useEmployees(PAGE_LIMIT, offset, isAllowedRole);
const [showCreateForm, setShowCreateForm] = useState(false);
const [quotaEmployee, setQuotaEmployee] = useState<Employee | null>(null);
const [historyEmployee, setHistoryEmployee] = useState<Employee | null>(null);
// 401 = легаси Caddy-роль прошла role-gate выше, но team-эндпоинт требует
// session-cookie, которой у юзера нет (см. header comment). Отдельно от 403
// (та ветка — «роль есть, прав недостаточно», ниже).
const sessionRequired =
employeesQ.error instanceof HTTPError && employeesQ.error.status === 401;
// Редирект — побочный эффект (нельзя router.push во время рендера), тот же
// паттерн, что RouteGuard.tsx использует для 401 от /me. NODE_ENV-гейт
// зеркалит RouteGuard: в dev без Caddy/логина 401 — обычное дело, авто-
// редирект там мешал бы локальной разработке; вместо него — сообщение с
// кнопкой ниже.
useEffect(() => {
if (!sessionRequired) return;
if (process.env.NODE_ENV !== "production") return;
router.push(TEAM_LOGIN_NEXT);
}, [sessionRequired, router]);
// Роль ещё не загружена — не решаем ничего, RouteGuard уже отрисовал
// страницу (401/403 верхнего уровня он ловит сам), здесь только role-gate.
if (meQ.isPending) {
return (
<>
<Topbar active="team" />
<main className="page scraper-page" style={{ maxWidth: 1180 }}>
<p className="scraper-hint">Загрузка</p>
</main>
</>
);
}
if (!isAllowedRole) {
return <NoAccessScreen variant="user" />;
}
if (sessionRequired) {
// Prod: редирект уже запущен эффектом выше — не мигаем error-текстом,
// пока навигация не завершится (тот же приём, что RouteGuard.tsx).
if (process.env.NODE_ENV === "production") return null;
return (
<>
<Topbar active="team" />
<main className="page scraper-page" style={{ maxWidth: 1180 }}>
<h1 className="scraper-h1">Команда</h1>
<p className="scraper-result scraper-result--error">
Войдите в систему, чтобы управлять командой.
</p>
<a
href={TEAM_LOGIN_NEXT}
className="team-btn-primary"
style={{ display: "inline-block", textDecoration: "none" }}
>
Войти
</a>
</main>
</>
);
}
// Backend 403 регардлесс роли фронта (напр. session истекла между /me и
// /employees) — тот же fallback, что и в admin/audit.
if (employeesQ.error instanceof HTTPError && employeesQ.error.status === 403) {
return <NoAccessScreen variant="user" />;
}
const employees = employeesQ.data ?? [];
const hasNextPage = employees.length === PAGE_LIMIT;
return (
<>
<Topbar active="team" />
<main className="page scraper-page" style={{ maxWidth: 1180 }}>
<h1 className="scraper-h1">Команда</h1>
<p className="scraper-subtitle">
Сотрудники, их доступ и месячные квоты на оценку. История оценок по клику
«История».
</p>
<section className="scraper-section">
<div className="team-toolbar">
<h2 style={{ margin: 0 }}>Сотрудники</h2>
<button
type="button"
className="team-btn-primary"
onClick={() => setShowCreateForm(true)}
>
+ Добавить сотрудника
</button>
</div>
{employeesQ.isPending ? <p className="scraper-hint">Загрузка</p> : null}
{employeesQ.isError && !(employeesQ.error instanceof HTTPError && employeesQ.error.status === 403) ? (
<p className="scraper-result scraper-result--error">
{teamErrorMessage(employeesQ.error)}
</p>
) : null}
{employeesQ.data ? (
<EmployeeTable
employees={employees}
onEditQuota={setQuotaEmployee}
onViewHistory={setHistoryEmployee}
limit={PAGE_LIMIT}
offset={offset}
onOffsetChange={setOffset}
hasNextPage={hasNextPage}
/>
) : null}
</section>
</main>
{showCreateForm ? <CreateEmployeeForm onClose={() => setShowCreateForm(false)} /> : null}
{quotaEmployee ? (
<QuotaEditor employee={quotaEmployee} onClose={() => setQuotaEmployee(null)} />
) : null}
{historyEmployee ? (
<EmployeeHistoryDrawer
employee={historyEmployee}
onClose={() => setHistoryEmployee(null)}
/>
) : null}
</>
);
}

View file

@ -53,6 +53,7 @@ import type {
TradeInEstimateInput,
} from "@/types/trade-in";
import { HTTPError } from "@/lib/api";
import { isPathAllowed } from "@/lib/isPathAllowed";
import {
useEstimate,
useEstimateHistory,
@ -66,7 +67,7 @@ import {
} from "@/lib/trade-in-api";
import { useQuota } from "@/lib/useQuota";
import { useMe } from "@/lib/useMe";
import { logout } from "@/lib/logout";
import { useLogout } from "@/lib/useLogout";
// OUTER HUD FRAME + 4 corner brackets (design lines 31-37). Decorative,
// non-interactive overlay drawn over the artboard gradient. The frame has
@ -524,6 +525,10 @@ export default function TradeInV2Page() {
// (#2046) — known profile fields fall back to username / brand ?? role / ""
// when absent (never invented). undefined while loading → TopNav «Гость».
const me = useMe();
// #2555: session-logout (POST /api/v1/auth/logout + local /me-cache
// invalidate + redirect на /login) — replaces the legacy basic_auth-cache
// -bust `logout()` for the v2 TopNav (new /login-form users).
const logoutMutation = useLogout();
// Dashboard sub-hooks — each resolves independently; failure degrades its
// section via the mappers (null input) rather than blanking the page.
@ -662,6 +667,21 @@ export default function TradeInV2Page() {
};
}, [me.data]);
// #2556 (эпик #2549): вход в раздел «Команда» из v2-навигации — раньше
// TopNav (v2) не знал про team-дашборд вообще, он был достижим только из
// legacy Topbar.tsx (пункт в NAV_ITEMS), а `/trade-in/` редиректит на
// `/trade-in/v2` → дашборд существовал, но был недостижим кликом. Тот же
// двойной гейт, что и Topbar.NAV_ITEMS "team": isPathAllowed(scopePath) —
// DB-роль `manager` получает голый `/api/v1/team/**` в allowed_paths (см.
// DB_ROLE_PATHS, tradein-mvp/backend/app/services/auth_session.py) — ПЛЮС
// roleGate поверх, потому что legacy-роль `analyst` (auth/roles.yaml,
// paths: "/**", без явного deny на /api/v1/team) тоже прошла бы
// isPathAllowed, но не должна видеть «Команда» (scope: только admin/manager).
const showTeamNavItem =
me.data != null &&
isPathAllowed(me.data.allowed_paths, me.data.deny_paths, "/api/v1/team") &&
(me.data.role === "admin" || me.data.role === "manager");
// Analog price pins for the 01 map, projected from the real estimate. No
// estimate → mapMarkers(null) → [] → ParamsPanel renders only the subject pin
// (Finding #2: never the static fixture price markers).
@ -973,7 +993,8 @@ export default function TradeInV2Page() {
onNavigate={setNav}
reports={reportsCount ?? 0}
user={topNavUser}
onLogout={logout}
onLogout={() => logoutMutation.mutate()}
showTeamLink={showTeamNavItem}
/>
</nav>
<main
@ -1008,6 +1029,9 @@ export default function TradeInV2Page() {
error={apiError}
initialValues={initialValues}
markers={markers}
// #2576 — city_hint contract (backend PR #2580): honest
// heads-up when the geocoder picked the city itself.
cityAmbiguous={estimate?.target_city_ambiguous ?? false}
/>
{middleContent}
{/* #2275: on mobile ObjectSummary is rendered fluid in the

View file

@ -0,0 +1,32 @@
"use client";
/**
* Чат поддержки для экранов БЕЗ входа экран логина и «доступа нет».
*
* WHY (инцидент 2026-07-31): после cutover'а на свою авторизацию (#2558)
* единственным каналом в поддержку был чат ЗА логином, а самая частая причина
* писать в поддержку как раз «не могу войти». В тот день «Практика» весь день
* билась в форму входа (5 неудачных попыток с трёх разных IP, ни одной успешной)
* и достучаться до нас из продукта не могла ничем: на `/login` не было ни чата,
* ни контакта.
*
* Технически это тот же `SupportButton` + `SupportChatPanel`, что и в `/v2`,
* только в анонимном scope (`SupportScope = "anon"`, ручки `/support/anon/*`)
* тред резолвится из httpOnly-куки, а не из идентити. Провайдер здесь свой:
* `SupportChatProvider` живёт в `app/v2/layout.tsx`, куда эти экраны не входят.
*
* Отдельный компонент, а не копипаста в двух местах: точек монтирования две
* (login-страница и `NoAccessScreen`), и обе тупики, из которых пользователю
* больше некуда идти.
*/
import { SupportButton } from "@/components/trade-in/v2/SupportButton";
import { SupportChatProvider } from "@/components/trade-in/v2/SupportChatContext";
export function AnonSupportWidget() {
return (
<SupportChatProvider>
<SupportButton scope="anon" />
</SupportChatProvider>
);
}

View file

@ -7,9 +7,15 @@
*
* Fullscreen «доступа нет» для 403 от /me или для denied path.
* Token-based styling per `.claude/rules/ui-tokens.md` (см. globals.css).
*
* СОЗНАТЕЛЬНОЕ расхождение с зеркалом (2026-07-31): здесь внизу монтируется
* `AnonSupportWidget`, в копии Site Finder'а его нет и быть не может виджет
* ходит в trade-in'овый support-бридж (`/api/v1/trade-in/support/anon/*`),
* которого в том бэкенде не существует. Всё остальное держим в синхроне.
*/
import { logout } from "@/lib/logout";
import { AnonSupportWidget } from "@/components/auth/AnonSupportWidget";
interface NoAccessScreenProps {
variant: "user" | "path" | "session" | "trial" | "error";
@ -168,6 +174,12 @@ export function NoAccessScreen({ variant, path }: NoAccessScreenProps) {
</button>
)}
</div>
{/* Второй тупик, из которого пользователю некуда идти (первый /login).
Анонимный scope, а не авторизованный: на variant="session"/"error"
идентити уже нет, а разное поведение чата на соседних вариантах одного
экрана лишняя развилка на ровном месте. Инцидент 2026-07-31. */}
<AnonSupportWidget />
</main>
);
}

View file

@ -10,9 +10,17 @@
* RBAC config (`auth/roles.yaml`) использует абсолютные пути сайта
* (`/trade-in/**`, `/trade-in/api/v1/admin/**`), поэтому перед проверкой
* isPathAllowed мы префиксим pathname через NEXT_PUBLIC_BASE_PATH.
*
* #2555 login redirect: `router.push()` (как и `usePathname()`) работает в
* пространстве путей БЕЗ basePath Next сам префиксит basePath на навигации
* (см. `next.config.ts` комментарий `basePath`). Поэтому `next=` в query
* строится из `rawPath` (БЕЗ basePath), а не `absolutePath` иначе
* `/login/page.tsx` сделал бы `router.push("/trade-in/history")`, и Next
* задвоил бы префикс в `/trade-in/trade-in/history`.
*/
import { usePathname } from "next/navigation";
import { useRouter, usePathname } from "next/navigation";
import { useEffect } from "react";
import { NoAccessScreen } from "@/components/auth/NoAccessScreen";
import { HTTPError } from "@/lib/api";
@ -22,7 +30,7 @@ import { useMe } from "@/lib/useMe";
const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
// #801: dev/CI-only preview-маршрут (a11y/lighthouse) рендерится оффлайн без RBAC.
const ENABLE_PREVIEW = process.env.NEXT_PUBLIC_ENABLE_PREVIEW === "1";
// ЭТАП 1 B2C-плана МЕРА: публичный периметр (mera.gendsgn.ru → rewrite на
// ЭТАП 1 B2C-плана МЕРА: публичный периметр (meraocenka.ru → rewrite на
// /trade-in/mera-public, см. Caddyfile). В отличие от ENABLE_PREVIEW выше —
// ЭТОТ bypass ВСЕГДА включён (в т.ч. в проде): страница публична по
// продуктовому решению, а не временный QA-артефакт.
@ -34,6 +42,7 @@ interface RouteGuardProps {
export function RouteGuard({ children }: RouteGuardProps) {
const rawPath = usePathname() ?? "/";
const router = useRouter();
// Абсолютный путь сайта: BASE_PATH + rawPath. Аккуратно с двойным слэшем
// на `/`: `BASE_PATH = "/trade-in"` + `"/"` → `/trade-in/` (ок).
const absolutePath = BASE_PATH
@ -41,12 +50,42 @@ export function RouteGuard({ children }: RouteGuardProps) {
: rawPath;
const { data, isLoading, error } = useMe();
// #2555: /login сам себя не гейтит — иначе редирект-петля (401 на /me →
// редирект на /login → RouteGuard на /login опять видит 401 → редирект…).
const isLoginPage = rawPath === "/login";
// Prod-only: сессия истекла/отсутствует → уводим на логин вместо старого
// NoAccessScreen variant="session". Редирект — побочный эффект (нельзя
// router.push во время рендера), поэтому useEffect; пока он не сработал,
// рендерим null (см. return ниже), чтобы не мигал старый contents.
const shouldRedirectToLogin =
!isLoginPage &&
process.env.NODE_ENV === "production" &&
error instanceof HTTPError &&
error.status === 401;
useEffect(() => {
if (!shouldRedirectToLogin) return;
// PR #2562 review finding 3: deep-links carry их state в query (`/v2?id=
// <uuid>` — см. next.config.ts redirect comment про restore-by-id). Без
// `window.location.search` юзер, чья сессия истекла mid-session на такой
// ссылке, после логина попадал бы на голый `/v2` и терял отчёт. Effect
// — гарантированно client-side (useEffect тело никогда не бежит на SSR),
// поэтому `window` тут безопасен без typeof-guard.
const next = `${rawPath}${window.location.search}`;
router.push(`/login?next=${encodeURIComponent(next)}`);
}, [shouldRedirectToLogin, rawPath, router]);
// #801: preview-страница самодостаточна (свой QueryClient с фейковым me),
// RBAC к ней не применяем. Только под флагом — в проде по умолчанию выключено.
if (ENABLE_PREVIEW && rawPath.startsWith("/ui-preview")) {
return <>{children}</>;
}
if (isLoginPage) {
return <>{children}</>;
}
// ЭТАП 1 B2C: публичная заглушка МЕРА не ждёт /api/v1/me и не проверяет
// RBAC — рендерится сразу для анонимного посетителя. useMe() выше уже
// вызван (Rules of Hooks — нельзя условно), но его результат здесь
@ -60,11 +99,11 @@ export function RouteGuard({ children }: RouteGuardProps) {
if (error instanceof HTTPError && error.status === 401) {
// Dev without Caddy: 401 is normal, mount the app so local dev works.
// Prod: mounting children on 401 causes TanStack Query re-subscribe storm
// (each new observer on errored query triggers a refetch). Show session screen
// instead — prevents the subtree from mounting, kills the loop.
if (process.env.NODE_ENV !== "production") return <>{children}</>;
return <NoAccessScreen variant="session" />;
// Prod: редирект уже запущен эффектом выше — ничего не рендерим, пока
// навигация не завершится (mounting children on 401 causes TanStack
// Query re-subscribe storm, см. историю до #2555 в git blame).
return null;
}
if (error instanceof HTTPError && error.status === 403) {

View file

@ -0,0 +1,219 @@
"use client";
/**
* Модалка создания сотрудника (#2556, эпик #2549) `POST /api/v1/team/employees`.
*
* Пароль задаётся вручную менеджером/админом email-рассылки нет (см. issue
* DoD), поэтому после успешного создания показываем явное «передайте пароль
* сотруднику» вместо тихого закрытия модалки.
*/
import { useState } from "react";
import type { FormEvent } from "react";
import {
USERNAME_HINT,
USERNAME_PATTERN,
teamErrorMessage,
useCreateEmployee,
} from "@/lib/team-api";
interface CreateEmployeeFormProps {
onClose: () => void;
}
export function CreateEmployeeForm({ onClose }: CreateEmployeeFormProps) {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [displayName, setDisplayName] = useState("");
const [orgName, setOrgName] = useState("");
const [email, setEmail] = useState("");
const [monthlyLimit, setMonthlyLimit] = useState("");
const [usernameTouched, setUsernameTouched] = useState(false);
const createMutation = useCreateEmployee();
const usernameValid = USERNAME_PATTERN.test(username);
const usernameInvalid = usernameTouched && username.length > 0 && !usernameValid;
function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
setUsernameTouched(true);
if (createMutation.isPending) return;
if (!USERNAME_PATTERN.test(username)) return;
if (password.length === 0) return;
const parsedLimit = monthlyLimit.trim() === "" ? undefined : Number(monthlyLimit);
createMutation.mutate({
username: username.trim(),
password,
display_name: displayName.trim() || undefined,
org_name: orgName.trim() || undefined,
email: email.trim() || undefined,
monthly_limit:
parsedLimit !== undefined && Number.isFinite(parsedLimit) && parsedLimit >= 1
? Math.trunc(parsedLimit)
: undefined,
});
}
if (createMutation.isSuccess) {
return (
<div className="team-modal-backdrop" role="presentation" onClick={onClose}>
<div
className="team-modal"
role="dialog"
aria-modal="true"
aria-label="Сотрудник создан"
onClick={(e) => e.stopPropagation()}
>
<h2>Сотрудник создан</h2>
<p className="team-form-success">
Логин «{createMutation.data.username}» готов. Передайте пароль сотруднику
он не сохраняется в системе и не отправляется автоматически.
</p>
<div className="team-modal-actions">
<button type="button" className="team-btn-primary" onClick={onClose}>
Готово
</button>
</div>
</div>
</div>
);
}
return (
<div className="team-modal-backdrop" role="presentation" onClick={onClose}>
<form
className="team-modal"
role="dialog"
aria-modal="true"
aria-label="Новый сотрудник"
onClick={(e) => e.stopPropagation()}
onSubmit={handleSubmit}
>
<h2>Новый сотрудник</h2>
<p className="team-modal-subtitle">
Пароль задаётся вручную передайте его сотруднику лично, рассылки нет.
</p>
{createMutation.isError ? (
<p role="alert" className="team-form-error">
{teamErrorMessage(createMutation.error)}
</p>
) : null}
<div className="team-field">
<label htmlFor="team-new-username">Логин</label>
<input
id="team-new-username"
name="username"
type="text"
autoComplete="off"
required
value={username}
onChange={(e) => setUsername(e.target.value)}
onBlur={() => setUsernameTouched(true)}
disabled={createMutation.isPending}
aria-invalid={usernameInvalid}
/>
<p className="team-field-hint">{USERNAME_HINT}</p>
{usernameInvalid ? (
<p className="team-field-hint" style={{ color: "var(--danger, #b3261e)" }}>
Логин не соответствует формату
</p>
) : null}
</div>
<div className="team-field team-password-row">
<label htmlFor="team-new-password">Пароль</label>
<input
id="team-new-password"
name="password"
type={showPassword ? "text" : "password"}
autoComplete="new-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={createMutation.isPending}
/>
<button
type="button"
className="team-password-toggle"
onClick={() => setShowPassword((v) => !v)}
tabIndex={-1}
>
{showPassword ? "Скрыть" : "Показать"}
</button>
</div>
<div className="team-field">
<label htmlFor="team-new-display-name">Имя (необязательно)</label>
<input
id="team-new-display-name"
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
disabled={createMutation.isPending}
/>
</div>
<div className="team-field">
<label htmlFor="team-new-org">Организация (необязательно)</label>
<input
id="team-new-org"
type="text"
value={orgName}
onChange={(e) => setOrgName(e.target.value)}
disabled={createMutation.isPending}
/>
</div>
<div className="team-field">
<label htmlFor="team-new-email">Email (необязательно)</label>
<input
id="team-new-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={createMutation.isPending}
/>
</div>
<div className="team-field">
<label htmlFor="team-new-limit">Месячный лимит оценок (необязательно)</label>
<input
id="team-new-limit"
type="number"
min={1}
step={1}
placeholder="по умолчанию"
value={monthlyLimit}
onChange={(e) => setMonthlyLimit(e.target.value)}
disabled={createMutation.isPending}
/>
</div>
<div className="team-modal-actions">
<button
type="button"
className="team-btn-secondary"
onClick={onClose}
disabled={createMutation.isPending}
>
Отмена
</button>
<button
type="submit"
className="team-btn-primary"
disabled={createMutation.isPending || password.length === 0 || !usernameValid}
>
{createMutation.isPending ? "Создаём…" : "Создать"}
</button>
</div>
</form>
</div>
);
}

View file

@ -0,0 +1,94 @@
"use client";
/**
* Drawer с историей оценок сотрудника (#2556, эпик #2549)
* `GET /api/v1/team/employees/{id}/history`.
*/
import { teamErrorMessage, useEmployeeHistory } from "@/lib/team-api";
import type { Employee } from "@/lib/team-api";
function formatDateTime(iso: string): string {
try {
return new Date(iso).toLocaleString("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return iso;
}
}
function formatPrice(value: number | null): string {
if (value === null) return "—";
return `${value.toLocaleString("ru-RU")}`;
}
interface EmployeeHistoryDrawerProps {
employee: Employee;
onClose: () => void;
}
export function EmployeeHistoryDrawer({ employee, onClose }: EmployeeHistoryDrawerProps) {
const historyQ = useEmployeeHistory(employee.id, 50, 0);
return (
<>
<div className="team-drawer-backdrop" role="presentation" onClick={onClose} />
<aside
className="team-drawer"
role="dialog"
aria-modal="true"
aria-label={`История оценок сотрудника ${employee.username}`}
>
<div className="team-drawer-header">
<div>
<h2>История оценок</h2>
<p className="scraper-hint" style={{ margin: 0 }}>
{employee.display_name ?? employee.username}
</p>
</div>
<button
type="button"
className="team-drawer-close"
onClick={onClose}
aria-label="Закрыть"
>
×
</button>
</div>
{historyQ.isPending ? <p className="scraper-hint">Загрузка</p> : null}
{historyQ.isError ? (
<p className="scraper-result scraper-result--error">
{teamErrorMessage(historyQ.error)}
</p>
) : null}
{historyQ.data && historyQ.data.length === 0 ? (
<p className="scraper-hint">У сотрудника пока нет оценок.</p>
) : null}
{historyQ.data && historyQ.data.length > 0 ? (
<div>
{historyQ.data.map((entry, i) => (
<div className="team-history-row" key={entry.estimate_id ?? i}>
<p className="team-history-address">{entry.address ?? "Адрес не указан"}</p>
<p className="team-history-meta">
{formatDateTime(entry.created_at)}
{entry.rooms ? ` · ${entry.rooms}к` : ""}
{entry.area_m2 ? ` · ${entry.area_m2} м²` : ""}
{entry.median_price !== null ? ` · ${formatPrice(entry.median_price)}` : ""}
{entry.confidence ? ` · точность: ${entry.confidence}` : ""}
{entry.n_analogs !== null ? ` · аналогов: ${entry.n_analogs}` : ""}
</p>
</div>
))}
</div>
) : null}
</aside>
</>
);
}

View file

@ -0,0 +1,222 @@
"use client";
/**
* Таблица сотрудников для team-дашборда менеджера (#2556, эпик #2549).
*
* Блокировка/разблокировка прямое действие в строке (один PATCH-запрос
* с `is_active`), с `window.confirm` перед деструктивным изменением (спек
* DoD: «действия деструктивные с подтверждением»). Квота + сброс пароля
* вынесены в отдельный модал (`QuotaEditor`) они бьют в тот же PATCH
* эндпоинт одним запросом, см. `app/api/v1/team.py::update_employee`.
*/
import { Fragment, useState } from "react";
import type { Employee } from "@/lib/team-api";
import { teamErrorMessage, useUpdateEmployee } from "@/lib/team-api";
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
} catch {
return iso;
}
}
/**
* Винительный падеж для confirm-текста: список admin'а содержит и менеджеров
* (см. `Employee.role`), а «Заблокировать сотрудника «praktika»?» про владельца
* организации прямая дезинформация в самом опасном диалоге дашборда.
*/
function roleAccusative(role: Employee["role"]): string {
return role === "manager" ? "менеджера" : "сотрудника";
}
function QuotaCell({ quota }: { quota: Employee["quota"] }) {
if (quota.unlimited) {
return <span className="team-quota-unlimited">без лимита</span>;
}
const low = quota.remaining <= 0;
return (
<span className={low ? "team-quota-low" : undefined}>
{quota.used}/{quota.limit}
</span>
);
}
interface EmployeeTableProps {
employees: Employee[];
onEditQuota: (employee: Employee) => void;
onViewHistory: (employee: Employee) => void;
limit: number;
offset: number;
onOffsetChange: (offset: number) => void;
hasNextPage: boolean;
}
export function EmployeeTable({
employees,
onEditQuota,
onViewHistory,
limit,
offset,
onOffsetChange,
hasNextPage,
}: EmployeeTableProps) {
const updateMutation = useUpdateEmployee();
const [pendingId, setPendingId] = useState<number | null>(null);
const [rowError, setRowError] = useState<{ id: number; message: string } | null>(null);
function handleToggleActive(employee: Employee) {
const nextActive = !employee.is_active;
const who = roleAccusative(employee.role);
const confirmed = window.confirm(
nextActive
? `Разблокировать ${who} «${employee.username}»?`
: `Заблокировать ${who} «${employee.username}»? Все его текущие сессии будут завершены.`,
);
if (!confirmed) return;
setRowError(null);
setPendingId(employee.id);
updateMutation.mutate(
{ employeeId: employee.id, patch: { is_active: nextActive } },
{
onSettled: () => setPendingId(null),
onError: (error) => setRowError({ id: employee.id, message: teamErrorMessage(error) }),
},
);
}
const isEmpty = employees.length === 0;
// Пустая ПЕРВАЯ страница = сотрудников вообще нет (предлагаем создать).
// Пустая страница при offset>0 = перешли за границу списка (напр. ровно
// 50/100/150 сотрудников и клик «Далее» после последней полной страницы)
// — это НЕ «сотрудников нет», нужен путь назад, а не тупик без пейджера.
const isEmptyPastEnd = isEmpty && offset > 0;
return (
<>
{isEmpty ? (
<p className="scraper-hint">
{isEmptyPastEnd
? "На этой странице пусто — вы перешли за конец списка."
: "Сотрудников пока нет. Добавьте первого через «+ Добавить сотрудника»."}
</p>
) : (
<table className="runs-table team-table">
<thead>
<tr>
<th scope="col">Логин</th>
<th scope="col">Имя</th>
<th scope="col">Статус</th>
<th scope="col">Квота</th>
<th scope="col">Создан</th>
<th scope="col">Действия</th>
</tr>
</thead>
<tbody>
{employees.map((employee) => (
<Fragment key={employee.id}>
<tr>
<td>
{employee.username}
{employee.role === "manager" ? (
<span className="team-role-badge" title="Менеджер организации">
менеджер
</span>
) : null}
</td>
<td className="run-muted">
{employee.display_name ?? "—"}
{employee.org_name ? ` · ${employee.org_name}` : ""}
</td>
<td>
<span
className={
employee.is_active
? "team-status-badge team-status-badge--active"
: "team-status-badge team-status-badge--blocked"
}
>
{employee.is_active ? "Активен" : "Заблокирован"}
</span>
</td>
<td>
<QuotaCell quota={employee.quota} />
</td>
<td className="run-muted">{formatDate(employee.created_at)}</td>
<td>
<div className="team-actions">
<button
type="button"
className="team-action-btn"
onClick={() => onViewHistory(employee)}
>
История
</button>
<button
type="button"
className="team-action-btn"
onClick={() => onEditQuota(employee)}
>
Изменить
</button>
<button
type="button"
className={
employee.is_active
? "team-action-btn team-action-btn--danger"
: "team-action-btn"
}
disabled={pendingId === employee.id}
onClick={() => handleToggleActive(employee)}
>
{employee.is_active ? "Заблокировать" : "Разблокировать"}
</button>
</div>
</td>
</tr>
{rowError?.id === employee.id ? (
<tr>
<td colSpan={6} style={{ padding: "0 10px 8px" }}>
<p className="team-form-error" style={{ margin: 0 }}>
{rowError.message}
</p>
</td>
</tr>
) : null}
</Fragment>
))}
</tbody>
</table>
)}
<div className="team-pager">
<button
type="button"
disabled={offset === 0}
onClick={() => onOffsetChange(Math.max(0, offset - limit))}
>
Назад
</button>
{!isEmpty ? (
<span>
{offset + 1}{offset + employees.length}
</span>
) : null}
<button
type="button"
disabled={isEmpty || !hasNextPage}
onClick={() => onOffsetChange(offset + limit)}
>
Далее
</button>
</div>
</>
);
}

View file

@ -0,0 +1,147 @@
"use client";
/**
* Модалка изменения сотрудника (#2556, эпик #2549) месячная квота + (опц.)
* сброс пароля. Один PATCH-запрос `/api/v1/team/employees/{id}` backend
* принимает `monthly_limit` и `new_password` в одном теле (см.
* `app/api/v1/team.py::update_employee`), поэтому оба поля живут в одной
* форме вместо двух раздельных round-trip'ов.
*
* Явная установка `monthly_limit` ВСЕГДА сбрасывает `unlimited=false` на
* бэкенде (см. `_upsert_quota_override`) предупреждаем об этом в тексте,
* если у сотрудника сейчас безлимит.
*/
import { useState } from "react";
import type { FormEvent } from "react";
import type { Employee, UpdateEmployeeInput } from "@/lib/team-api";
import { teamErrorMessage, useUpdateEmployee } from "@/lib/team-api";
interface QuotaEditorProps {
employee: Employee;
onClose: () => void;
}
export function QuotaEditor({ employee, onClose }: QuotaEditorProps) {
const [monthlyLimit, setMonthlyLimit] = useState(
employee.quota.unlimited ? "" : String(employee.quota.limit),
);
const [newPassword, setNewPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
// Список admin'а смешанный (сотрудники + менеджеры организаций) — «Изменить
// сотрудника» над карточкой владельца организации вводит в заблуждение.
const who = employee.role === "manager" ? "менеджера" : "сотрудника";
const updateMutation = useUpdateEmployee();
function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
if (updateMutation.isPending) return;
const patch: UpdateEmployeeInput = {};
const trimmedLimit = monthlyLimit.trim();
if (trimmedLimit !== "") {
const parsed = Number(trimmedLimit);
if (Number.isFinite(parsed) && parsed >= 1) {
patch.monthly_limit = Math.trunc(parsed);
}
}
if (newPassword.length > 0) {
patch.new_password = newPassword;
}
if (Object.keys(patch).length === 0) {
onClose();
return;
}
updateMutation.mutate(
{ employeeId: employee.id, patch },
{ onSuccess: onClose },
);
}
return (
<div className="team-modal-backdrop" role="presentation" onClick={onClose}>
<form
className="team-modal"
role="dialog"
aria-modal="true"
aria-label={`Изменить ${who} ${employee.username}`}
onClick={(e) => e.stopPropagation()}
onSubmit={handleSubmit}
>
<h2>Изменить {who}</h2>
<p className="team-modal-subtitle">{employee.username}</p>
{updateMutation.isError ? (
<p role="alert" className="team-form-error">
{teamErrorMessage(updateMutation.error)}
</p>
) : null}
<div className="team-field">
<label htmlFor="team-quota-limit">Месячный лимит оценок</label>
<input
id="team-quota-limit"
type="number"
min={1}
step={1}
value={monthlyLimit}
onChange={(e) => setMonthlyLimit(e.target.value)}
disabled={updateMutation.isPending}
/>
<p className="team-field-hint">
Сейчас использовано {employee.quota.used}
{employee.quota.unlimited
? " · без лимита"
: ` из ${employee.quota.limit} (осталось ${employee.quota.remaining})`}
{employee.quota.unlimited
? ". Заполнение поля снимет безлимитный статус."
: ""}
</p>
</div>
<div className="team-field team-password-row">
<label htmlFor="team-quota-password">Новый пароль (необязательно)</label>
<input
id="team-quota-password"
type={showPassword ? "text" : "password"}
autoComplete="new-password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
disabled={updateMutation.isPending}
/>
<button
type="button"
className="team-password-toggle"
onClick={() => setShowPassword((v) => !v)}
tabIndex={-1}
>
{showPassword ? "Скрыть" : "Показать"}
</button>
<p className="team-field-hint">
Смена пароля завершает все текущие сессии {who}.
</p>
</div>
<div className="team-modal-actions">
<button
type="button"
className="team-btn-secondary"
onClick={onClose}
disabled={updateMutation.isPending}
>
Отмена
</button>
<button type="submit" className="team-btn-primary" disabled={updateMutation.isPending}>
{updateMutation.isPending ? "Сохраняем…" : "Сохранить"}
</button>
</div>
</form>
</div>
);
}

View file

@ -7,6 +7,7 @@ import { API_BASE_URL, HTTPError } from "@/lib/api";
import { isPathAllowed } from "@/lib/isPathAllowed";
import { safeUrl } from "@/lib/safeUrl";
import { useBrand } from "@/lib/useBrand";
import type { Role } from "@/lib/useMe";
import { useMe } from "@/lib/useMe";
/** Telegram-канал обратной связи для пилота. Build-time env пусто до тех
@ -84,7 +85,8 @@ export type ActiveTab =
| "cian"
| "yandex"
| "audit"
| "analytics";
| "analytics"
| "team";
interface TopbarProps {
active: ActiveTab;
@ -105,15 +107,43 @@ interface TopbarProps {
* путь, чтобы pilot их не видел в навигации. Direct URL access на
* `/trade-in/scrapers/avito` НЕ блокируется (RouteGuard следует yaml). Если
* нужна полная блокировка добавить `/trade-in/scrapers/**` в pilot.deny.
*
* Исключение из этого caveat `sale-share` и `cache` (2026-07-31): для них
* заведён ЯВНЫЙ deny (`/trade-in/sale-share/**`, `/trade-in/cache/**` + их API)
* в `DB_ROLE_PATHS` (employee/manager) и в `pilot.deny` (auth/roles.yaml).
* Т.е. это НЕ scopePath-трюк, как у скрапперов: гейт реальный, а не только
* косметический.
*
* Но точность важнее красивой формулировки где именно он стоит:
* - пункт меню исчезает (фильтр ниже, deny из `/me`);
* - страница по прямому URL отдаёт HTTP **200** с HTML (Next.js рендерит
* маршрут всегда) её закрывает КЛИЕНТСКИЙ `RouteGuard` (app/layout.tsx),
* рисуя NoAccessScreen вместо контента;
* - единственный СЕРВЕРНЫЙ рубеж API: `/api/v1/buildings/**` и
* `/api/v1/trade-in/cache-stats/**` дают 403 из `rbac_guard`.
* Данные без API недостижимы, поэтому 200 на HTML безвреден но не читай это
* как «страница блокируется на сервере»: следующий, кто добавит сюда раздел с
* SSR-данными, обязан закрывать именно его API, а не только этот список.
*/
const NAV_ITEMS: Array<{
key: ActiveTab;
href: string;
scopePath: string;
label: string;
// Доп. gate ПОВЕРХ isPathAllowed — нужен для "team": DB-роль `manager`
// получает `/api/v1/team/**` в allowed_paths (см. DB_ROLE_PATHS,
// tradein-mvp/backend/app/services/auth_session.py), но legacy-роль
// `analyst` (auth/roles.yaml, paths: "/**", БЕЗ явного deny на /api/v1/team)
// тоже прошла бы обычный isPathAllowed-фильтр — analyst не должен видеть
// пункт «Команда» (#2556 scope: только admin/manager).
roleGate?: (role: Role) => boolean;
}> = [
{ key: "estimate", href: "/", scopePath: "/trade-in/", label: "Оценка" },
// Доля квартир дома в продаже — доступно pilot (scopePath под /trade-in/**).
// Доля квартир дома в продаже — ВНУТРЕННИЙ инструмент (аналитика рынка).
// Скрыт для employee/manager/pilot явным deny `/trade-in/sale-share/**`
// (DB_ROLE_PATHS + auth/roles.yaml), а не scopePath-трюком как у скрапперов:
// scopePath остаётся честным путём страницы, фильтр ниже — прежний
// isPathAllowed, просто deny побеждает allow `/trade-in/**`.
{
key: "sale-share",
href: "/sale-share",
@ -121,6 +151,8 @@ const NAV_ITEMS: Array<{
label: "Доля в продаже",
},
{ key: "history", href: "/history", scopePath: "/trade-in/history", label: "История" },
// Кэш — внутренний инструмент (состояние кэшей/скраперов). Скрыт тем же
// способом, что и sale-share выше: явный deny `/trade-in/cache/**`.
{ key: "cache", href: "/cache", scopePath: "/trade-in/cache", label: "Кэш" },
// Скраперы — admin-only UI. Маппим на admin-deny path, чтобы pilot их не видел.
{
@ -162,6 +194,16 @@ const NAV_ITEMS: Array<{
scopePath: "/trade-in/api/v1/admin/analytics",
label: "Активность",
},
// Team-дашборд менеджера (#2556, эпик #2549) — CRUD сотрудников/квоты/история.
// scopePath БЕЗ /trade-in-префикса — DB_ROLE_PATHS отдаёт голый /api/v1/team/**
// (не /trade-in/api/v1/team/**), см. комментарий у roleGate выше.
{
key: "team",
href: "/team",
scopePath: "/api/v1/team",
label: "Команда",
roleGate: (role) => role === "admin" || role === "manager",
},
];
export function Topbar({ active }: TopbarProps) {
@ -176,8 +218,10 @@ export function Topbar({ active }: TopbarProps) {
const isDev401 = error instanceof HTTPError && error.status === 401;
const items =
data && !isDev401
? NAV_ITEMS.filter((item) =>
isPathAllowed(data.allowed_paths, data.deny_paths, item.scopePath),
? NAV_ITEMS.filter(
(item) =>
isPathAllowed(data.allowed_paths, data.deny_paths, item.scopePath) &&
(item.roleGate ? item.roleGate(data.role) : true),
)
: NAV_ITEMS;

View file

@ -2820,3 +2820,335 @@ html, body { overflow-x: clip; }
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
gap: 16px;
}
/* ── Team dashboard (#2556, эпик #2549) ── */
.team-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.team-status-badge {
padding: 3px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
white-space: nowrap;
}
.team-status-badge--active {
background: var(--success-soft, #dcfce7);
color: var(--success, #0a7a3a);
}
.team-status-badge--blocked {
background: var(--danger-soft, #fee2e2);
color: var(--danger, #b3261e);
}
/* Пометка «менеджер» рядом с логином в списке admin'а: список смешанный
(сотрудники + менеджеры организаций), без метки строки неразличимы. */
.team-role-badge {
margin-left: 6px;
padding: 2px 6px;
border-radius: 4px;
font-size: 10px;
font-weight: 500;
white-space: nowrap;
background: var(--accent-soft, #e0edff);
color: var(--accent, #0d6fd6);
}
.team-quota-unlimited {
color: var(--fg-secondary, #5b6066);
font-size: 11px;
}
.team-quota-low {
color: var(--danger, #b3261e);
font-weight: 600;
}
.team-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.team-action-btn {
padding: 4px 10px;
background: var(--bg-card-alt, #fafbfc);
color: var(--fg-primary, #111111);
border: 1px solid var(--border-strong, #d1d5db);
border-radius: 4px;
font-size: 11px;
cursor: pointer;
}
.team-action-btn:hover:not(:disabled) {
background: var(--accent-soft, #dbeafe);
border-color: var(--accent, #1d4ed8);
}
.team-action-btn:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.team-action-btn--danger {
color: var(--danger, #b3261e);
border-color: var(--danger, #b3261e);
}
.team-action-btn--danger:hover:not(:disabled) {
background: var(--danger-soft, #fee2e2);
}
.team-pager {
display: flex;
align-items: center;
gap: 12px;
margin-top: 16px;
font-size: 13px;
color: var(--fg-secondary, #5b6066);
}
.team-pager button {
padding: 6px 12px;
background: var(--bg-card, #ffffff);
border: 1px solid var(--border-strong, #d1d5db);
border-radius: 6px;
font-size: 13px;
cursor: pointer;
}
.team-pager button:hover:not(:disabled) {
background: var(--accent-soft, #dbeafe);
border-color: var(--accent, #1d4ed8);
}
.team-pager button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
/* Modal (CreateEmployeeForm / QuotaEditor) box-shadow допустим для
modals/popovers per .claude/rules/ui-tokens.md. */
.team-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.45);
display: flex;
align-items: flex-start;
justify-content: center;
padding: 48px 16px;
z-index: 100;
overflow-y: auto;
}
.team-modal {
background: var(--bg-card, #ffffff);
border: 1px solid var(--border-card, #e6e8ec);
border-radius: 12px;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.2);
padding: 24px;
max-width: 440px;
width: 100%;
}
.team-modal h2 {
font-size: 18px;
font-weight: 600;
margin: 0 0 4px;
color: var(--fg-primary, #111111);
}
.team-modal p.team-modal-subtitle {
font-size: 13px;
color: var(--fg-secondary, #5b6066);
margin: 0 0 20px;
}
.team-field {
margin-bottom: 14px;
}
.team-field label {
display: block;
font-size: 12px;
font-weight: 500;
color: var(--fg-secondary, #5b6066);
margin-bottom: 6px;
}
.team-field input {
width: 100%;
box-sizing: border-box;
height: 38px;
padding: 0 12px;
border: 1px solid var(--border-card, #e6e8ec);
border-radius: 8px;
font-size: 14px;
color: var(--fg-primary, #111111);
background: var(--bg-card, #ffffff);
font-family: inherit;
}
.team-field input:focus-visible {
outline: none;
border-color: var(--accent, #1d4ed8);
box-shadow: 0 0 0 2px var(--accent-soft, #dbeafe);
}
.team-field-hint {
margin: 6px 0 0;
font-size: 11px;
color: var(--fg-tertiary, #73767e);
}
.team-password-row {
position: relative;
}
.team-password-toggle {
position: absolute;
right: 8px;
top: 30px;
background: none;
border: none;
color: var(--fg-secondary, #5b6066);
font-size: 11px;
cursor: pointer;
padding: 4px 6px;
}
.team-form-error {
margin: 0 0 16px;
padding: 8px 12px;
border-radius: 8px;
background: var(--danger-soft, #fee2e2);
color: var(--danger, #b3261e);
font-size: 13px;
line-height: 1.4;
}
.team-form-success {
margin: 0 0 16px;
padding: 8px 12px;
border-radius: 8px;
background: var(--success-soft, #dcfce7);
color: var(--success, #0a7a3a);
font-size: 13px;
line-height: 1.4;
}
.team-modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 20px;
}
.team-btn-primary {
padding: 9px 16px;
background: var(--accent, #1d4ed8);
color: #fff;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.team-btn-primary:hover:not(:disabled) {
background: var(--accent-hover, #1e40af);
}
.team-btn-primary:disabled {
background: var(--border-strong, #d1d5db);
cursor: not-allowed;
}
.team-btn-secondary {
padding: 9px 16px;
background: var(--bg-card, #ffffff);
color: var(--fg-primary, #111111);
border: 1px solid var(--border-strong, #d1d5db);
border-radius: 8px;
font-size: 14px;
cursor: pointer;
}
.team-btn-secondary:hover:not(:disabled) {
background: var(--bg-card-alt, #fafbfc);
}
/* Drawer (EmployeeHistoryDrawer) */
.team-drawer-backdrop {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.35);
z-index: 100;
}
.team-drawer {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: min(480px, 100vw);
background: var(--bg-card, #ffffff);
border-left: 1px solid var(--border-card, #e6e8ec);
box-shadow: -16px 0 40px rgba(15, 23, 42, 0.15);
z-index: 101;
overflow-y: auto;
padding: 24px;
}
.team-drawer-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.team-drawer-header h2 {
font-size: 18px;
font-weight: 600;
margin: 0 0 4px;
color: var(--fg-primary, #111111);
}
.team-drawer-close {
background: none;
border: none;
font-size: 20px;
line-height: 1;
color: var(--fg-secondary, #5b6066);
cursor: pointer;
padding: 4px 6px;
}
.team-history-row {
padding: 10px 0;
border-bottom: 1px solid var(--border-soft, #eef0f3);
}
.team-history-row:last-child {
border-bottom: none;
}
.team-history-address {
font-size: 13px;
color: var(--fg-primary, #111111);
margin: 0 0 2px;
}
.team-history-meta {
font-size: 11px;
color: var(--fg-secondary, #5b6066);
}

View file

@ -38,6 +38,12 @@ import {
type MapMarker,
} from "./mappers";
import { useGeocodeSuggest } from "@/lib/trade-in-api";
import {
CITY_LABELS,
DEFAULT_CITY,
UNCONFIRMED_CITY_LABEL,
detectCityInText,
} from "@/lib/city-registry";
import type {
GeocodeSuggestion,
HouseType,
@ -45,7 +51,7 @@ import type {
TradeInEstimateInput,
} from "@/types/trade-in";
type DdKey = "rooms" | "houseType" | "repair" | "radius" | null;
type DdKey = "rooms" | "houseType" | "repair" | "radius" | "city" | null;
// Russian plural picker (one / few / many) for SR announcements.
function pluralRu(n: number, one: string, few: string, many: string): string {
@ -523,6 +529,18 @@ const errorText: CSSProperties = {
color: tokens.danger,
};
// #2576 — honest, calm (не danger) heads-up когда бэкенд вернул
// target_city_ambiguous: true: город определил геокодер, не пользователь.
// Тот же тон, что и другие честные caption'ы в этой панели (не паника, не
// ошибка формы — просто прозрачность + предложение уточнить).
const cityAmbiguousText: CSSProperties = {
marginTop: 4,
fontSize: 10.5,
letterSpacing: 0.3,
lineHeight: 1.4,
color: tokens.warn,
};
// РАДИУС dropdown panel — mirrors the <Dd> HUD panel (surface.w98 + soft blue
// shadow), sized to the narrow radius trigger and dropped just beneath it.
const radiusPanel: CSSProperties = {
@ -551,6 +569,12 @@ interface ParamsPanelProps {
error?: string | null;
/** Prefill for restore-by-id (?id=) — maps API enums back to RU dropdown labels. */
initialValues?: Partial<TradeInEstimateInput>;
/**
* #2576: true когда последняя оценка вернулась с `target_city_ambiguous`
* геокодер сам угадал город (пользователь его не назвал явно). Рисует
* честную некритичную подсказку у поля адреса, не блокирует форму.
*/
cityAmbiguous?: boolean;
/** Analog price pins, projected from the real estimate via mapMarkers() onto
* the OLD decorative SVG's fixed 0-100% grid (never a real geo scale see
* mapMarkers() comment in ./mappers.ts). Kept in the prop contract for
@ -578,6 +602,31 @@ function initRepairLabel(rs: RepairState | undefined): string {
return rs ? REPAIR_RU[rs] : "Не указано";
}
// Город формы (#2576) — ДВЕ раздельных величины, не одна:
// - `city` (initCityLabel) — best-guess ДЛЯ ОТОБРАЖЕНИЯ/подстановки после
// подтверждения. Restore-by-id: detect из сохранённого адреса (иначе сброс
// на Екатеринбург молча увёл бы восстановленный отчёт другого города назад
// на ЕКБ-подсказки); новая форма без initialValues → DEFAULT_CITY.
// - `cityConfirmed` (initCityConfirmed, ниже) — ГЕЙТ на отправку city_hint.
// Deep-review R2 (#2576): слать `city` в city_hint ВСЕГДА (независимо от того,
// подтверждён он или нет) обнуляло весь смысл фичи — бэкенд трактует любой
// city_hint как «пользователь назвал город», так что необнаруженный житель
// Нижнего Тагила (дропдаун не тронут, город не упомянут в тексте) молча
// резолвился бы в Екатеринбург, а target_city_ambiguous был бы false
// практически всегда. `city` НИКОГДА не отправляется сам по себе — только
// вместе с cityConfirmed=true (см. handleSubmit / useGeocodeSuggest ниже).
function initCityLabel(address: string | undefined): string {
return detectCityInText(address) ?? DEFAULT_CITY.label;
}
// true ⇔ город реально известен (restore: обнаружен в сохранённом адресе).
// false для свежей формы — «Екатеринбург» в `city` в этот момент ТОЛЬКО
// внутренний best-guess (для честного текста плашки), НЕ подтверждённое
// значение; дропдаун показывает UNCONFIRMED_CITY_LABEL, а не "Екатеринбург".
function initCityConfirmed(address: string | undefined): boolean {
return detectCityInText(address) != null;
}
// РАДИУС options. "Авто" (default) sends no radius_m → the backend keeps its
// two-tier default (1000 m primary / 2000 m fallback). A fixed value overrides
// both ("ищем строго в пределах X м"). Design dropdown was values-only.
@ -603,6 +652,7 @@ export default function ParamsPanel({
hasEstimate = false,
error = null,
initialValues,
cityAmbiguous = false,
// markers intentionally not destructured — see the ParamsPanelProps.markers
// doc comment: its %-positions belong to the retired decorative SVG grid and
// do not correspond to real lat/lon on the Leaflet map below.
@ -615,6 +665,16 @@ export default function ParamsPanel({
const radiusListId = useId();
const [radiusActive, setRadiusActive] = useState(-1);
const [address, setAddress] = useState(initialValues?.address ?? "");
// Город формы (#2576 city_hint) — см. initCityLabel doc comment. Independent
// control от адреса: меняется через дропдаун ИЛИ автоопределяется из
// набранного/выбранного текста (см. handleAddressChange/pickSuggestion).
const [city, setCity] = useState(initCityLabel(initialValues?.address));
// Гейт на отправку city_hint (deep-review R2) — см. initCityConfirmed doc
// comment. true только после явного выбора в дропдауне или совпадения
// detectCityInText; нетронутый дефолт держит его false.
const [cityConfirmed, setCityConfirmed] = useState(
initCityConfirmed(initialValues?.address),
);
const [area, setArea] = useState(
initialValues?.area_m2 != null ? String(initialValues.area_m2) : "",
);
@ -676,7 +736,15 @@ export default function ParamsPanel({
// БАЛКОН radiogroup focus targets (roving tabindex).
const balNoRef = useRef<HTMLButtonElement>(null);
const balYesRef = useRef<HTMLButtonElement>(null);
const suggest = useGeocodeSuggest(addressQuery);
// #2576 / deep-review R2: подсказки скопированы по городу ТОЛЬКО когда он
// подтверждён (cityConfirmed) — иначе suggest() уходит без city_hint, и
// бэкенд смещает bias на всю область (OBLAST66_VIEWBOX), а не молча на ЕКБ.
// Это и есть исправление: нетронутая форма для «Ленина 1» теперь видит
// кандидатов из ВСЕЙ области (в т.ч. Нижний Тагил), а не только ЕКБ.
// cityHintForApi в зависимостях useGeocodeSuggest — подтверждение города
// обязано рефетчить (city/cityConfirmed оба в queryKey через этот аргумент).
const cityHintForApi = cityConfirmed ? city : undefined;
const suggest = useGeocodeSuggest(addressQuery, cityHintForApi);
const handleAddressChange = (v: string) => {
setAddress(v);
@ -686,6 +754,18 @@ export default function ParamsPanel({
setAddressActive(-1); // a new query invalidates the previous highlight
if (fieldErrors.address)
setFieldErrors((prev) => ({ ...prev, address: undefined }));
// Autodetect (#2576): если пользователь сам печатает город в тексте
// адреса ("Нижний Тагил, Ленина 1"), подхватываем его в дропдаун сразу —
// не ждём выбора подсказки. Найденное совпадение — реальное подтверждение
// (пользователь сам его написал), поэтому это ЕДИНСТВЕННОЕ место, где
// cityConfirmed включается без явного клика по дропдауну. Отсутствие
// совпадения НЕ трогает city/cityConfirmed — предыдущее подтверждение
// (если было) остаётся в силе при мелкой правке текста.
const detected = detectCityInText(v);
if (detected) {
setCity(detected);
setCityConfirmed(true);
}
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => setAddressQuery(v), 200);
};
@ -697,6 +777,30 @@ export default function ParamsPanel({
setFias(s.fias_id ?? null);
setSuggestOpen(false);
setAddressActive(-1);
// Autodetect (#2576): выбор реального гео-резолвленного адреса — сильный
// сигнал подтверждения (даже сильнее, чем просто набранный текст).
// full_address обычно содержит город независимо от того, был ли отправлен
// city_hint (провайдер возвращает его как часть резолвленного адреса) —
// так первый же pick из НЕподтверждённого поиска (bias на всю область)
// подтверждает город без отдельного клика по дропдауну.
const detected = detectCityInText(s.full_address);
if (detected) {
setCity(detected);
setCityConfirmed(true);
}
};
// Явный выбор города из дропдауна — прямое подтверждение пользователем
// (deep-review R2: единственный способ гарантированно включить cityConfirmed
// независимо от текста адреса). Сбрасывает ранее выбранные координаты/ФИАС —
// тот же текст адреса под другим городом это уже другая точка (зеркалит
// handleAddressChange's stale-guard для ручной правки).
const selectCity = (v: string) => {
setCity(v);
setCityConfirmed(true);
setOpenDd(null);
setCoords(null);
setFias(null);
};
// Address combobox keyboard (M6). aria-activedescendant pattern: focus STAYS on
@ -830,6 +934,12 @@ export default function ParamsPanel({
lon: coords?.lon ?? null,
// Детерминированный ключ дома — только из выбранной подсказки (иначе null).
target_fias_id: fias,
// #2576 / deep-review R2 — город уходит ТОЛЬКО когда подтверждён
// (явный выбор в дропдауне или совпадение в тексте/подсказке); иначе
// city_hint не отправляется вовсе (undefined → ключ выпадает из JSON),
// и бэкенд честно помечает target_city_ambiguous=true вместо того
// чтобы получить непроверенный "Екатеринбург" и промолчать.
city_hint: cityConfirmed ? city : undefined,
// "Авто" → null → backend keeps its two-tier default (1000 m primary /
// 2000 m fallback). A fixed value overrides both. (Не отправлять 500 по
// умолчанию — это сузило бы поиск аналогов и убрало fallback.)
@ -1384,12 +1494,45 @@ export default function ParamsPanel({
}}
>
<div>
<label
htmlFor="pp-address"
style={{ ...hintLabel, display: "block" }}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: 10,
}}
>
АДРЕС (YANDEX / NOMINATIM)
</label>
<label htmlFor="pp-address" style={hintLabel}>
АДРЕС (YANDEX / NOMINATIM)
</label>
{/* Город (#2576) компактный дропдаун рядом с лейблом адреса, не
отдельная строка: не отнимает вертикальное место у и так плотной
панели. Explicit pick сразу подтверждает город (city_hint
отправляется дальше). До подтверждения показывает
UNCONFIRMED_CITY_LABEL, НЕ "Екатеринбург" (deep-review R2)
иначе дропдаун выдавал бы наш внутренний best-guess за
подтверждённый пользователем выбор, хотя ничего ещё не
отправлялось. Для ЕКБ-сценария это не лишний клик: как только
пользователь печатает/выбирает адрес, detectCityInText обычно
находит город сам (см. handleAddressChange/pickSuggestion) и
лейбл сменится на «Екатеринбург» автоматически. Список городов
растёт см. city-registry.ts. */}
<div style={{ position: "relative", width: 176, flex: "0 0 auto" }}>
<Dd
open={openDd === "city"}
onToggle={() => toggle("city")}
value={cityConfirmed ? city : UNCONFIRMED_CITY_LABEL}
options={CITY_LABELS}
onSelect={selectCity}
mono={false}
triggerFontSize={11}
optionFontSize={11.5}
triggerHeight={22}
variant="dashed"
ariaLabel="Город"
/>
</div>
</div>
<div style={{ position: "relative" }}>
{/* Address autocomplete (geocode suggest, ЕКБ viewbox). Typed text
is debounced into `addressQuery`; the dropdown below mirrors the
@ -1508,6 +1651,33 @@ export default function ParamsPanel({
{fieldErrors.address}
</div>
)}
{/* #2576 честная, не блокирующая подсказка: последняя оценка
вернулась с target_city_ambiguous, город определил геокодер
сам. role="status" (не alert) это информация, не ошибка.
Deep-review R3: текст различает cityConfirmed если бэкенд
не получил city_hint (`cityConfirmed === false`), `city` в
этот момент внутренний best-guess ("Екатеринбург"), а НЕ то,
что реально нашёл геокодер (в ответе вообще нет угаданного
города, только сам булев признак) называть конкретный город
в этом случае было бы той же нечестностью, которую фикс
убирал из запроса, только в тексте плашки. */}
{cityAmbiguous && !fieldErrors.address && (
<div style={cityAmbiguousText} role="status">
{cityConfirmed ? (
<>
Город определён автоматически результат может
относиться к другому населённому пункту области. Если это
не {city}, выберите верный город выше и повторите оценку.
</>
) : (
<>
Город определён автоматически результат может
относиться к другому населённому пункту области. Если это
неверно, выберите город выше и повторите оценку.
</>
)}
</div>
)}
</div>
</div>

View file

@ -89,7 +89,6 @@ const SOURCE_COLORS: Record<string, string> = {
cian: "#1baf7a",
yandex: "#eda100",
domklik: "#008300",
n1: "#4a3aa7",
};
const FALLBACK_SOURCE_COLOR = "#6b7280"; // неизвестный/прочий листинговый источник
const COLOR_TARGET = "#f59e0b"; // та же семантика, что и MapCard.COLOR_TARGET

View file

@ -32,6 +32,7 @@ import { tokens } from "./tokens";
import { useSupportChat } from "./SupportChatContext";
import { SupportChatPanel } from "./SupportChatPanel";
import { useSupportUnread } from "@/lib/useSupportChat";
import type { SupportScope } from "@/lib/useSupportChat";
const { accent, accentDeep, onAccent, surface, font, danger } = tokens;
@ -45,7 +46,16 @@ const styles = `
}
`;
export function SupportButton() {
interface SupportButtonProps {
/**
* "anon" экран входа / «доступа нет», где идентити нет и быть не может.
* Дефолт "auth" все существующие места монтирования (v2 layout) не меняются.
* См. `SupportScope` в `@/lib/useSupportChat`.
*/
scope?: SupportScope;
}
export function SupportButton({ scope = "auth" }: SupportButtonProps = {}) {
// Portal-mount guard (SSR-safe): `document` only exists after mount
// (mirrors MapPicker.tsx:107-108 / BuildingListingsDrawer.tsx:29-30).
const [mounted, setMounted] = useState(false);
@ -54,7 +64,7 @@ export function SupportButton() {
const { open, toggleChat, closeChat } = useSupportChat();
// Unread badge only matters while the panel is closed — see
// useSupportUnread's docstring for why polling stops entirely once open.
const unreadQuery = useSupportUnread(!open);
const unreadQuery = useSupportUnread(!open, scope);
const unread = unreadQuery.data?.unread ?? 0;
if (!mounted) return null;
@ -139,7 +149,7 @@ export function SupportButton() {
</span>
)}
</button>
<SupportChatPanel open={open} onClose={closeChat} />
<SupportChatPanel open={open} onClose={closeChat} scope={scope} />
</>,
document.body,
);

View file

@ -24,11 +24,13 @@ import {
useSendSupportMessage,
useSupportMessages,
} from "@/lib/useSupportChat";
import type { SupportMessage } from "@/lib/useSupportChat";
import type { SupportMessage, SupportScope } from "@/lib/useSupportChat";
interface SupportChatPanelProps {
open: boolean;
onClose: () => void;
/** См. `SupportScope` — "anon" для экрана входа / «доступа нет». */
scope?: SupportScope;
}
const TIME_FORMAT = new Intl.DateTimeFormat("ru-RU", {
@ -54,7 +56,11 @@ const PANEL_STYLES = `
.support-chat-close:hover { border-color: ${tokens.accent}; color: ${tokens.accent}; }
`;
export function SupportChatPanel({ open, onClose }: SupportChatPanelProps) {
export function SupportChatPanel({
open,
onClose,
scope = "auth",
}: SupportChatPanelProps) {
const dialogRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const listEndRef = useRef<HTMLDivElement>(null);
@ -64,9 +70,9 @@ export function SupportChatPanel({ open, onClose }: SupportChatPanelProps) {
const [draft, setDraft] = useState("");
const messagesQuery = useSupportMessages(open);
const sendMessage = useSendSupportMessage();
const markRead = useMarkSupportRead();
const messagesQuery = useSupportMessages(open, scope);
const sendMessage = useSendSupportMessage(scope);
const markRead = useMarkSupportRead(scope);
// Mark the thread read on BOTH the closed->open and open->closed edges (the
// effect fires on mount-while-open and its cleanup fires on the reverse

View file

@ -10,6 +10,8 @@
import { useState } from "react";
import type { CSSProperties } from "react";
import { API_BASE_URL } from "@/lib/api";
import { tokens } from "./tokens";
import { navLabels, version } from "./fixtures";
import { useSupportChat } from "./SupportChatContext";
@ -35,6 +37,12 @@ interface TopNavProps {
user?: TopNavUser;
// Sign out — page wires this to logout(); closes the menu first.
onLogout?: () => void;
// #2556 (эпик #2549): показывать пункт «Команда» в user-меню — вычисляется
// страницей из useMe() (роль admin/manager + isPathAllowed на /api/v1/team,
// тот же двойной гейт, что и Topbar.tsx NAV_ITEMS "team"). false/undefined
// → пункт не рендерится (роль employee/pilot/analyst/expired или /me ещё
// не загрузился).
showTeamLink?: boolean;
}
// Neutral fallback when the user prop is absent (loading / unauthenticated).
@ -57,6 +65,24 @@ const menuItemStyle: CSSProperties = {
transition: "background .12s",
};
/** Inline Users icon (lucide-react `Users` SVG path, stroke 1.2) для
пункта «Команда» в user-меню (#2556). Тот же inline-SVG паттерн (15x15,
tokens.muted), что и остальные иконки этого дропдауна. */
function UsersIcon() {
return (
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
<circle cx="5.3" cy="5" r="2.2" stroke={tokens.muted} strokeWidth="1.2" />
<path
d="M1 13c0-2.6 1.9-3.9 4.3-3.9S9.6 10.4 9.6 13"
stroke={tokens.muted}
strokeWidth="1.2"
/>
<path d="M9.3 1.8a2.1 2.1 0 0 1 0 4" stroke={tokens.muted} strokeWidth="1.2" />
<path d="M11 9.5c1.9.4 3 1.6 3 3.5" stroke={tokens.muted} strokeWidth="1.2" />
</svg>
);
}
// Профиль / Настройки have no pages yet — render them dimmed and
// non-interactive (no hover class, default cursor) so they read as disabled.
// «Помощь» used to be in this group too, then linked out to the Telegram
@ -74,6 +100,7 @@ export default function TopNav({
reports = 0,
user,
onLogout,
showTeamLink = false,
}: TopNavProps) {
const [userOpen, setUserOpen] = useState(false);
const { openChat } = useSupportChat();
@ -489,6 +516,28 @@ export default function TopNav({
Помощь
</button>
{/* «Команда» (#2556, эпик #2549) team-дашборд (`/team`) отдельный
роут, не таб SectionOverlay, поэтому это ссылка в user-меню
рядом с «Выйти», а не пункт в tnav-tab-ряду выше: v2-табы
это разделы ОДНОЙ страницы (переключают `active`/onNavigate),
а «Команда» уводит на другой URL. Гейт (showTeamLink) считает
страница из useMe() здесь только рендер. */}
{showTeamLink && (
<a
role="menuitem"
href={`${API_BASE_URL}/team`}
className="tnav-menuitem"
style={{
...menuItemStyle,
textDecoration: "none",
}}
onClick={() => setUserOpen(false)}
>
<UsersIcon />
Команда
</a>
)}
<div
style={{
height: "1px",

View file

@ -0,0 +1,105 @@
/**
* city-registry единый источник правды по городам Свердловской области,
* поддержанным сбором «Меры» (#2576 этап B: расширение на область).
*
* Раньше геокодер молча подставлял "Екатеринбург, " в любой запрос без явного
* города в тексте адреса житель Нижнего Тагила, вводя «Ленина, 1», получал
* уверенно неверную цену по одноимённой екатеринбургской улице (backend fix
* #2576, PR #2580: `city_hint` query-параметр + `TradeInEstimateInput.city_hint`
* + честный `AggregatedEstimate.target_city_ambiguous`).
*
* ВАЖНО (deep-review R2 на #2576 UI-часть): `city_hint` сигнал «пользователь
* НАЗВАЛ город», а не «форма его подставила». Если слать дефолт всегда, бэкенд
* трактует ЛЮБОЙ запрос как «город указан» `target_city_ambiguous` перестаёт
* быть честным, а необнаруженный Нижний Тагил (дропдаун не тронут, город не
* упомянут в тексте) молча резолвится как Екатеринбург ровно баг, который
* чинил backend. Поэтому вызывающий код (ParamsPanel.tsx) обязан слать
* `city_hint` ТОЛЬКО когда город реально известен (явный выбор в дропдауне ИЛИ
* `detectCityInText` нашёл совпадение) см. `cityConfirmed` в ParamsPanel.tsx.
* Нетронутый дефолт город НЕ отправляется.
*
* Список городов растёт по мере расширения покрытия скрейперами не хардкодь
* города россыпью по компонентам, добавляй сюда (мирроит паттерн
* source-registry.ts). Parity-риск (deep-review R2, дёшево): эти 6 лейблов
* должны оставаться подмножеством `SVERDLOVSK_OBLAST_CITIES`
* (tradein-mvp/backend/app/services/geocoder.py) та же кириллица в нижнем
* регистре. Расхождение не ломается типами (`city_hint` свободный текст,
* бэкенд его не валидирует по списку), но подсунет город без реального
* покрытия скрейпером. Перед добавлением 7-го города сверить оба списка
* вручную (codegen/тест на это пока нет, backend другой PR).
*/
export interface OblastCity {
/** Стабильный id (React key / будущая аналитика) — НЕ отправляется бэкенду. */
readonly id: string;
/**
* Человекочитаемое имя ровно то, что отправляется бэкенду как `city_hint`
* (backend/app/services/geocoder.py::_resolve_city_for_geocode принимает
* произвольный текст города, не enum/slug).
*/
readonly label: string;
}
// ЕКБ первым — это DEFAULT_CITY (см. ниже) и основной трафик; порядок
// остальных — по охвату сбора, не алфавитный.
export const OBLAST_CITIES: readonly OblastCity[] = [
{ id: "ekaterinburg", label: "Екатеринбург" },
{ id: "nizhny_tagil", label: "Нижний Тагил" },
{ id: "kamensk_uralsky", label: "Каменск-Уральский" },
{ id: "pervouralsk", label: "Первоуральск" },
{ id: "verkhnyaya_pyshma", label: "Верхняя Пышма" },
{ id: "serov", label: "Серов" },
];
/**
* Дефолт формы Екатеринбург (визуальный/best-guess, для текста подсказки и
* подстановки значения ПОСЛЕ подтверждения). НЕ означает "отправляется по
* умолчанию" см. предупреждение в шапке файла: пока город не подтверждён
* (`cityConfirmed` в ParamsPanel.tsx), в запрос ничего не уходит.
*/
export const DEFAULT_CITY: OblastCity = OBLAST_CITIES[0];
/**
* Лейбл дропдауна ДО подтверждения города (свежая форма, город ещё не
* выбран/определён). Честнее статичного "Екатеринбург": не выдаёт наш
* best-guess за подтверждённый выбор пользователя (deep-review R2
* `city_hint` не должен отправляться в этом состоянии).
*
* Fix (эпик #2576, живая проверка прода после #2584): дропдаун-триггер в
* ParamsPanel.tsx фиксированные 176×22px, font-size 11px. Прежний текст
* "Определить автоматически" (~146px в Manrope 400 измерено
* opentype.js-сравнением с рендером на проде) не влезал в однострочный
* бюджет ~143px (176 - padding 2×13 - место под "▼"), переносился на вторую
* строку и обрезался высотой триггера. "Автоопределение" (~96px) укладывается
* с большим запасом, сохраняя смысл сравни с "Авто" у РАДИУС АНАЛИЗА
* (тот же проектный паттерн для автоматического режима), но без риска
* двусмысленности рядом с названиями городов. Самое длинное название города
* в CITY_LABELS, "Каменск-Уральский" (~108px), укладывается в тот же бюджет
* без переноса проверено тем же замером, отдельного фикса не требует.
*/
export const UNCONFIRMED_CITY_LABEL = "Автоопределение";
/** RU-лейблы для дропдауна — тот же формат (string[]), что dropdownOptions.* в v2/fixtures.ts. */
export const CITY_LABELS: string[] = OBLAST_CITIES.map((c) => c.label);
/**
* Best-effort поиск известного города области как ЦЕЛОГО токена в
* произвольном тексте (набранный адрес / `full_address` выбранной подсказки)
* автоопределение города наравне с явным выбором из дропдауна.
*
* Токен обязан быть delimited началом/концом строки, запятой или пробелом
* иначе "Серов" ложно сматчился бы внутри "ул. Серова" (реальная
* екатеринбургская улица, названная в честь лётчика Серова) и увёл бы выбор
* города от Екатеринбурга на Серов. JS `\b` не работает вокруг кириллицы
* (только ASCII `\w`), поэтому границы токена явные символьные классы, как
* в v2/mappers.ts::STREET_RE.
*/
export function detectCityInText(text: string | null | undefined): string | undefined {
if (!text) return undefined;
return OBLAST_CITIES.find((c) => cityTokenRe(c.label).test(text))?.label;
}
function cityTokenRe(label: string): RegExp {
const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(`(^|[,\\s])${escaped}([,\\s]|$)`, "i");
}

View file

@ -0,0 +1,202 @@
"use client";
/**
* Team-management API client (#2556, эпик #2549) CRUD сотрудников, квоты,
* история. Mirrors `app/api/v1/team.py` + `app/schemas/team.py` (tradein-mvp
* backend, PR #2554/#2563). Hand-written types tradein-mvp/frontend has no
* `codegen` script (см. `src/lib/admin-audit-api.ts` для того же паттерна).
*
* Auth: team-роуты требуют session-cookie (`current_team_actor` в team.py
* читает ТОЛЬКО cookie, не legacy X-Authenticated-User). `apiFetch` не задаёт
* `credentials` явно запросы same-origin (basePath `/trade-in` за тем же
* Caddy), браузер по умолчанию шлёт cookie на same-origin fetch.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiFetch, HTTPError } from "./api";
const BASE = "/api/v1/team";
// Тот же regex что и `app.schemas.team._USERNAME_RE` — клиентская валидация
// зеркалит серверную, чтобы показывать понятную подсказку ДО round-trip.
export const USERNAME_PATTERN = /^[A-Za-z0-9._-]{3,64}$/;
export const USERNAME_HINT =
"Латиница, цифры, точка, дефис, подчёркивание; 364 символа";
// ---- Types (mirror app/schemas/team.py) ------------------------------------
export interface QuotaStatus {
limit: number;
used: number;
remaining: number;
unlimited: boolean;
}
export interface Employee {
id: number;
username: string;
/**
* Кем является строка в списке. Бэкенд отдаёт менеджеров ТОЛЬКО актору-admin
* (`app/api/v1/team.py`, `_LIST_EMPLOYEES_ALL_SQL`) менеджер в своём списке
* видит по-прежнему только `"employee"`. Строк `role: "admin"` этот API не
* отдаёт никогда, поэтому в юнионе их нет.
*/
role: "employee" | "manager";
display_name: string | null;
org_name: string | null;
email: string | null;
is_active: boolean;
manager_id: number | null;
created_at: string;
quota: QuotaStatus;
}
export interface EmployeeHistoryEntry {
estimate_id: string | null;
address: string | null;
area_m2: string | null;
rooms: string | null;
median_price: number | null;
confidence: string | null;
n_analogs: number | null;
created_at: string;
}
export interface CreateEmployeeInput {
username: string;
password: string;
display_name?: string;
org_name?: string;
email?: string;
monthly_limit?: number;
}
export interface UpdateEmployeeInput {
is_active?: boolean;
monthly_limit?: number;
display_name?: string;
org_name?: string;
email?: string;
new_password?: string;
}
// ---- Hooks -------------------------------------------------------------------
const EMPLOYEES_LIST_KEY = ["team", "employees"] as const;
/**
* GET /api/v1/team/employees?limit=&offset=
* Backend сам скоупит по роли (manager только свои, admin все).
*
* `enabled` (default true) вызывающая страница обязана передать `false`
* для ролей, которым эндпоинт всё равно ответит 401/403 (employee/analyst/
* pilot и т.п.): без этого прямой заход на /team шлёт обречённый round-trip
* ДО отрисовки role-gate, который лишний и палит наличие эндпоинта.
*/
export function useEmployees(limit: number, offset: number, enabled = true) {
return useQuery<Employee[]>({
queryKey: [...EMPLOYEES_LIST_KEY, limit, offset],
queryFn: () =>
apiFetch<Employee[]>(`${BASE}/employees?limit=${limit}&offset=${offset}`),
enabled,
staleTime: 15_000,
retry: false,
});
}
/**
* GET /api/v1/team/employees/{id}/history?limit=&offset=
* `enabled: false` пока employeeId === null ленивая загрузка при открытии drawer.
*/
export function useEmployeeHistory(
employeeId: number | null,
limit = 50,
offset = 0,
) {
return useQuery<EmployeeHistoryEntry[]>({
queryKey: ["team", "employees", employeeId, "history", limit, offset],
queryFn: () =>
apiFetch<EmployeeHistoryEntry[]>(
`${BASE}/employees/${employeeId}/history?limit=${limit}&offset=${offset}`,
),
enabled: employeeId !== null,
staleTime: 15_000,
retry: false,
});
}
/** POST /api/v1/team/employees — создать сотрудника. */
export function useCreateEmployee() {
const queryClient = useQueryClient();
return useMutation<Employee, Error, CreateEmployeeInput>({
mutationFn: (input) =>
apiFetch<Employee>(`${BASE}/employees`, {
method: "POST",
body: JSON.stringify(input),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: EMPLOYEES_LIST_KEY });
},
});
}
/**
* PATCH /api/v1/team/employees/{id} частичное обновление (блокировка,
* квота, профиль, сброс пароля любая комбинация полей в одном запросе,
* зеркалит `EmployeeUpdateRequest`).
*/
export function useUpdateEmployee() {
const queryClient = useQueryClient();
return useMutation<
Employee,
Error,
{ employeeId: number; patch: UpdateEmployeeInput }
>({
mutationFn: ({ employeeId, patch }) =>
apiFetch<Employee>(`${BASE}/employees/${employeeId}`, {
method: "PATCH",
body: JSON.stringify(patch),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: EMPLOYEES_LIST_KEY });
},
});
}
// ---- Error mapping -----------------------------------------------------------
/**
* Достаёт человеко-читаемый текст из FastAPI error body либо `{detail:
* string}` (наши ручные `HTTPException(...)`), либо Pydantic-валидация
* `{detail: [{msg: string, ...}]}` (напр. username не прошёл field_validator
* на этапе парсинга тела запроса, ДО хендлера).
*/
function extractDetailMessage(body: unknown): string | null {
if (body === null || typeof body !== "object" || !("detail" in body)) {
return null;
}
const detail = (body as { detail?: unknown }).detail;
if (typeof detail === "string") return detail;
if (Array.isArray(detail) && detail.length > 0) {
const first: unknown = detail[0];
if (first !== null && typeof first === "object" && "msg" in first) {
const msg = (first as { msg?: unknown }).msg;
if (typeof msg === "string") return msg;
}
}
return null;
}
/** Русский текст ошибки для team-мутаций/запросов — по коду статуса. */
export function teamErrorMessage(error: unknown): string {
if (error instanceof HTTPError) {
if (error.status === 409) return "Логин уже занят";
if (error.status === 403) return "Недостаточно прав";
if (error.status === 404) return "Сотрудник не найден";
if (error.status === 422) {
return extractDetailMessage(error.body) ?? "Проверьте правильность заполнения формы";
}
}
return "Не удалось выполнить запрос. Попробуйте ещё раз";
}

View file

@ -257,22 +257,35 @@ export function useEstimateHistory(limit = 50) {
}
/**
* GET /api/v1/geocode/suggest?q=&limit=
* Автокомплит адресов в пределах ЕКБ для поля адреса (ParamsPanel). Debounce-
* friendly: вызывающий компонент дебаунсит строку query, хук кешируется по
* queryKey; `enabled` срабатывает только начиная с 3 символов (бэкенд min 2,
* берём 3 чтобы не дёргать на 1-2 символа). `select` разворачивает обёртку
* {items} GeocodeSuggestion[]; keepPreviousData убирает мерцание списка между
* последовательными запросами.
* GET /api/v1/geocode/suggest?q=&limit=&city_hint=
* Автокомплит адресов в Свердловской области для поля адреса (ParamsPanel).
* Debounce-friendly: вызывающий компонент дебаунсит строку query, хук
* кешируется по queryKey; `enabled` срабатывает только начиная с 3 символов
* (бэкенд min 2, берём 3 чтобы не дёргать на 1-2 символа). `select`
* разворачивает обёртку {items} GeocodeSuggestion[]; keepPreviousData
* убирает мерцание списка между последовательными запросами.
*
* `cityHint` #2576 (backend PR #2580): без него геокодер больше НЕ
* подставляет "Екатеринбург" молча (см. src/lib/city-registry.ts форма
* держит дефолт "Екатеринбург", так что ЕКБ-сценарий не деградирует). Часть
* queryKey переключение города в форме обязано рефетчить подсказки.
*/
export function useGeocodeSuggest(query: string, limit = 8) {
export function useGeocodeSuggest(
query: string,
cityHint?: string | null,
limit = 8,
) {
const q = query.trim();
const hint = (cityHint ?? "").trim();
return useQuery<GeocodeSuggestResponse, Error, GeocodeSuggestion[]>({
queryKey: ["trade-in", "geocode-suggest", q, limit],
queryFn: () =>
apiFetch<GeocodeSuggestResponse>(
`${GEOCODE_BASE}/suggest?q=${encodeURIComponent(q)}&limit=${limit}`,
),
queryKey: ["trade-in", "geocode-suggest", q, hint, limit],
queryFn: () => {
const params = new URLSearchParams({ q, limit: String(limit) });
if (hint) params.set("city_hint", hint);
return apiFetch<GeocodeSuggestResponse>(
`${GEOCODE_BASE}/suggest?${params.toString()}`,
);
},
select: (r) => r.items,
enabled: q.length >= 3,
staleTime: 5 * 60_000,

View file

@ -0,0 +1,40 @@
"use client";
/**
* #2555: session-logout POST /api/v1/auth/logout (revoke DB session +
* очистка httponly cookie tradein_session), затем чистим локальный
* TanStack Query /me-кэш и уходим на /login.
*
* NB: это НЕ замена legacy `@/lib/logout.ts` (Caddy basic_auth cache-bust +
* hard reload) тот остаётся для страниц/пользователей на старом
* trusted-header механизме (см. `app.core.rbac` dual-mode resolver).
* useLogout для юзеров, залогиненных через новую /login форму (#2552).
*
* Backend logout best-effort по духу (revoke конкретной сессии), поэтому
* локальный logout (кэш + редирект) выполняется в `onSettled`, а не только
* `onSuccess`: сетевой сбой / уже-протухшая сессия не должны запирать юзера
* на странице без возможности разлогиниться.
*/
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { ME_QUERY_KEY } from "@/lib/useMe";
async function logoutRequest(): Promise<void> {
await apiFetch<{ ok: boolean }>("/api/v1/auth/logout", { method: "POST" });
}
export function useLogout() {
const queryClient = useQueryClient();
const router = useRouter();
return useMutation({
mutationFn: logoutRequest,
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ME_QUERY_KEY });
router.push("/login");
},
});
}

View file

@ -14,7 +14,18 @@ import { useQuery } from "@tanstack/react-query";
import { apiFetchWithStatus, HTTPError } from "@/lib/api";
export type Role = "admin" | "pilot" | "expired";
// #2555: session-auth (POST /api/v1/auth/login) вводит новые роли
// admin|manager|employee. Legacy Caddy trusted-header роли (pilot|analyst|
// expired) остаются — backend `/api/v1/me` может отдать любую из обеих
// групп в зависимости от того, каким механизмом пришёл юзер (dual-mode
// resolver, см. `tradein-mvp/backend/app/core/rbac.py`).
export type Role =
| "admin"
| "manager"
| "employee"
| "pilot"
| "analyst"
| "expired";
export interface UserScope {
username: string;

View file

@ -39,6 +39,21 @@ import { apiFetch, HTTPError } from "@/lib/api";
const BASE = "/api/v1/trade-in/support";
/**
* Какой поддержкой пользуемся:
* "auth" тред залогиненного юзера, резолвится сервером из сессии/идентити;
* "anon" тред посетителя БЕЗ входа (экран логина, «доступа нет»), резолвится
* из httpOnly-куки, которую ставит бэкенд (`/support/anon/*`).
*
* Появилось после инцидента 2026-07-31: единственный канал в поддержку был ЗА
* логином, а типовая причина писать «не могу войти».
*/
export type SupportScope = "auth" | "anon";
function scopeBase(scope: SupportScope): string {
return scope === "anon" ? `${BASE}/anon` : BASE;
}
// Mirrors backend `MAX_MESSAGE_LENGTH` (app/api/v1/support.py) — enforced
// client-side too so the send button disables before the round-trip 422/400.
export const MAX_SUPPORT_MESSAGE_LENGTH = 4000;
@ -57,8 +72,12 @@ export interface SupportUnread {
unread: number;
}
const SUPPORT_MESSAGES_KEY = ["trade-in", "support", "messages"] as const;
const SUPPORT_UNREAD_KEY = ["trade-in", "support", "unread"] as const;
// Scope — часть ключа: анонимный и авторизованный треды физически разные, общий
// ключ склеил бы их в кэше (после логина в панели висела бы переписка анонима).
const messagesKey = (scope: SupportScope) =>
["trade-in", "support", scope, "messages"] as const;
const unreadKey = (scope: SupportScope) =>
["trade-in", "support", scope, "unread"] as const;
const MESSAGES_POLL_MS = 6_000;
const UNREAD_POLL_MS = 20_000;
@ -67,10 +86,11 @@ const UNREAD_POLL_MS = 20_000;
* Polls the caller's own thread. `enabled` should be the chat-panel `open`
* flag see module docstring.
*/
export function useSupportMessages(enabled: boolean) {
export function useSupportMessages(enabled: boolean, scope: SupportScope = "auth") {
return useQuery<SupportMessage[]>({
queryKey: SUPPORT_MESSAGES_KEY,
queryFn: () => apiFetch<SupportMessage[]>(`${BASE}/messages?since=0`),
queryKey: messagesKey(scope),
queryFn: () =>
apiFetch<SupportMessage[]>(`${scopeBase(scope)}/messages?since=0`),
enabled,
staleTime: 0,
refetchInterval: enabled ? MESSAGES_POLL_MS : false,
@ -81,26 +101,26 @@ export function useSupportMessages(enabled: boolean) {
* Feeds the unread badge on the closed floating button. `enabled` should be
* `!open` see module docstring.
*/
export function useSupportUnread(enabled: boolean) {
export function useSupportUnread(enabled: boolean, scope: SupportScope = "auth") {
return useQuery<SupportUnread>({
queryKey: SUPPORT_UNREAD_KEY,
queryFn: () => apiFetch<SupportUnread>(`${BASE}/unread`),
queryKey: unreadKey(scope),
queryFn: () => apiFetch<SupportUnread>(`${scopeBase(scope)}/unread`),
enabled,
staleTime: 0,
refetchInterval: enabled ? UNREAD_POLL_MS : false,
});
}
export function useSendSupportMessage() {
export function useSendSupportMessage(scope: SupportScope = "auth") {
const queryClient = useQueryClient();
return useMutation<SupportMessage, Error, string>({
mutationFn: (text) =>
apiFetch<SupportMessage>(`${BASE}/messages`, {
apiFetch<SupportMessage>(`${scopeBase(scope)}/messages`, {
method: "POST",
body: JSON.stringify({ text }),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: SUPPORT_MESSAGES_KEY });
queryClient.invalidateQueries({ queryKey: messagesKey(scope) });
},
});
}
@ -111,14 +131,16 @@ export function useSendSupportMessage() {
* unread badge never counts messages the visitor demonstrably already saw
* while the panel was open.
*/
export function useMarkSupportRead() {
export function useMarkSupportRead(scope: SupportScope = "auth") {
const queryClient = useQueryClient();
return useMutation<void, Error, void>({
mutationFn: async () => {
await apiFetch<{ status: string }>(`${BASE}/read`, { method: "POST" });
await apiFetch<{ status: string }>(`${scopeBase(scope)}/read`, {
method: "POST",
});
},
onSuccess: () => {
queryClient.setQueryData<SupportUnread>(SUPPORT_UNREAD_KEY, { unread: 0 });
queryClient.setQueryData<SupportUnread>(unreadKey(scope), { unread: 0 });
},
});
}

View file

@ -75,6 +75,13 @@ export interface TradeInEstimateInput {
// до геокодинга строки. Отправляется ТОЛЬКО когда адрес выбран из подсказки;
// сбрасывается в null при ручной правке адреса или сдвиге пина на карте.
target_fias_id?: string | null;
// #2576 (backend PR #2580): город, выбранный пользователем в форме (см.
// src/lib/city-registry.ts) — без него геокодер больше НЕ подставляет
// "Екатеринбург" молча (житель другого города области раньше получал
// уверенно неверную цену по одноимённой екатеринбургской улице). Дефолт формы
// уже "Екатеринбург" (city-registry.ts::DEFAULT_CITY), так что ЕКБ-сценарий
// отправляет ровно то, что геокодер раньше подставлял сам.
city_hint?: string | null;
}
export interface AnalogLot {
@ -169,6 +176,12 @@ export interface AggregatedEstimate {
target_address: string | null;
target_lat: number | null;
target_lon: number | null;
// #2576 (backend PR #2580): true если ни адрес, ни `TradeInEstimateInput
// .city_hint` не называли город явно — итоговый город (и, соответственно,
// набор аналогов/цена) определил геокодер-провайдер, а не пользователь.
// Ephemeral (не персистится в БД, только для текущего ответа) — optional,
// т.к. оценки, посчитанные до деплоя бэкенда, поле не содержат.
target_city_ambiguous?: boolean;
sources_used: string[]; // ['avito', 'cian', 'rosreestr']
// #2043 (BE-1): достоверность выборки — реальный коэффициент вариации ₽/м² (std/mean),
// счётчики аналогов по источнику, момент создания оценки. Все optional: старые

View file

@ -303,6 +303,7 @@ def save_listings(
region_code: int,
run_id: int | None = None,
skip_seen_today: bool = False,
city: str | None = None,
) -> tuple[int, int]:
"""Пишем list[ScrapedLot] в `listings` с upsert по dedup_hash.
@ -324,6 +325,14 @@ def save_listings(
Используется full_load для экономии redundant upsert + price-trigger churn
при повторном прогоне в тот же день. Новые листинги (prior_row=None) всегда
вставляются. False = старое поведение (всегда upsert).
city: человекочитаемое имя города-цели ЭТОГО batch'а (#2594), например
"Нижний Тагил"/"Екатеринбург". Развёртка знает город из своего контекста
(city_slug) ОДИН на весь вызов save_listings (все lots одного batch'а
принадлежат одному city-sweep run'у), поэтому это kwarg, а НЕ поле
ScrapedLot. None (default) вызывающая сторона город не знает (ad-hoc
admin/manual пути) колонка остаётся NULL, backward-compatible.
ON CONFLICT COALESCE (новое значение НЕ затирает уже известный город
NULL'ом, если какой-то caller ещё не передаёт city).
Returns:
(inserted, updated) counters для логов.
@ -380,6 +389,7 @@ def save_listings(
"dedup": dedup,
"region_code": region_code,
"address": lot.address,
"city": city,
"lat": lot.lat,
"lon": lot.lon,
"rooms": lot.rooms,
@ -439,7 +449,7 @@ def save_listings(
"""
INSERT INTO listings (
source, source_url, source_id, dedup_hash,
address, lat, lon, region_code,
address, city, lat, lon, region_code,
rooms, area_m2, floor, total_floors, year_built,
house_type, repair_state, has_balcony,
kitchen_area_m2, ceiling_height, ceiling_height_m,
@ -460,7 +470,7 @@ def save_listings(
scraped_at, last_seen_at
) VALUES (
:source, :source_url, :source_id, :dedup,
:address, :lat, :lon, :region_code,
:address, :city, :lat, :lon, :region_code,
:rooms, :area_m2, :floor, :total_floors, :year_built,
:house_type, :repair_state, :has_balcony,
-- ceiling: один param :ceiling_height_m пишем в ОБЕ колонки
@ -516,6 +526,9 @@ def save_listings(
metro_stations = EXCLUDED.metro_stations,
listing_date = COALESCE(EXCLUDED.listing_date, listings.listing_date),
area_m2 = COALESCE(EXCLUDED.area_m2, listings.area_m2),
-- #2594: город развёртки — COALESCE, чтобы caller без city (ad-hoc
-- admin/manual пути, city=None) не затирал уже известный город.
city = COALESCE(EXCLUDED.city, listings.city),
-- kitchen/ceiling (#2007): COALESCE — SERP re-scrape источника без
-- этих полей (avito SERP NULL) НЕ затирает detail-enriched значение
-- (avito_detail пишет ceiling_height_m отдельным UPDATE).
@ -627,6 +640,7 @@ def save_listings(
metro_stations = CAST(:metro_stations AS jsonb),
listing_date = COALESCE(:listing_date, listing_date),
area_m2 = COALESCE(:area_m2, area_m2),
city = COALESCE(:city, city),
kitchen_area_m2 = COALESCE(:kitchen_area_m2, kitchen_area_m2),
ceiling_height = COALESCE(:ceiling_height_m, ceiling_height),
ceiling_height_m = COALESCE(:ceiling_height_m, ceiling_height_m),

View file

@ -343,6 +343,41 @@ def get_city_location(city_slug: str | None) -> CityLocation | None:
return CITY_LOCATIONS.get(city_slug)
# Человекочитаемые названия городов области — пишутся в `listings.city` (#2594).
# Развёртка ЗНАЕТ город из своего контекста (city_slug), но раньше нигде его не
# записывала — листинг терял привязку к городу и адрес без города в тексте
# ("ул. Победы, 30" — так отдают и Avito, и Cian, см. providers/cian/serp.py
# `_format_address` skip_types={"location",...}) при геокодинге считался «город не
# назван» и коллизировал с одноимённой ЕКБ-улицей. Ключи СОВПАДАЮТ с CITY_LOCATIONS/
# CITY_ANCHORS; значения — те же формы, что уже есть в geocoder.SVERDLOVSK_OBLAST_CITIES
# (lower + word-boundary матчинг там регистронезависим, поэтому регистр здесь не
# критичен, но человекочитаемый — для админки/логов/дальнейшего QA).
CITY_DISPLAY_NAMES: dict[str, str] = {
"nizhniy_tagil": "Нижний Тагил",
"kamensk_uralskiy": "Каменск-Уральский",
"pervouralsk": "Первоуральск",
"verkhnyaya_pyshma": "Верхняя Пышма",
"serov": "Серов",
}
EKATERINBURG_CITY_NAME = "Екатеринбург"
def resolve_city_name(city_slug: str | None) -> str:
"""Человекочитаемое имя города для `save_listings(..., city=...)` (#2594).
city_slug=None Екатеринбург. Это НЕ заглушка «не знаем» это симметрия с
get_city_location/get_city_anchors (тот же None-путь = ЕКБ-дефолт): EKB-варианты
city-sweep функций (run_avito_city_sweep и т.д., вызванные БЕЗ city_slug) реально
собирают ЕКБ, поэтому их листинги тоже должны получать city="Екатеринбург"
иначе была бы обратная асимметрия «у области город проставлен, у ЕКБ нет».
Неизвестный slug (не в CITY_DISPLAY_NAMES) тоже ЕКБ-дефолт, тем же путём, что и
get_city_location/get_city_anchors для неизвестных slug'ов.
"""
if city_slug is None:
return EKATERINBURG_CITY_NAME
return CITY_DISPLAY_NAMES.get(city_slug, EKATERINBURG_CITY_NAME)
_CHROME_HEADERS = {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8",
@ -896,6 +931,9 @@ async def run_avito_city_sweep(
# kamensk-uralskiy (дефис) / verhnyaya_pyshma (kh→h) отличаются от нашего city_slug —
# вычисляем один раз до цикла anchor'ов, не внутри closure на каждый anchor.
_avito_slug = _loc.avito_slug if _loc else city_slug
# #2594: город для save_listings(..., city=...) — один на весь sweep (все anchor'ы
# одного run'а бьют по одному city_slug), вычисляем один раз до цикла.
_city_name = resolve_city_name(city_slug)
counters = CitySweepCounters(anchors_total=len(_anchors))
all_touched_house_ids: set[int] = set()
@ -1035,7 +1073,11 @@ async def run_avito_city_sweep(
if anchor_lots:
try:
ins, upd = save_listings(
db, anchor_lots, matcher=matcher, region_code=region_code
db,
anchor_lots,
matcher=matcher,
region_code=region_code,
city=_city_name,
)
counters.lots_inserted += ins
counters.lots_updated += upd
@ -1662,7 +1704,14 @@ async def run_avito_newbuilding_sweep(
counters.lots_fetched += len(lots)
if lots:
try:
ins, upd = save_listings(db, lots, matcher=matcher, region_code=region_code)
# #2594: citywide novostroyka-обход — только ЕКБ (см. docstring).
ins, upd = save_listings(
db,
lots,
matcher=matcher,
region_code=region_code,
city=EKATERINBURG_CITY_NAME,
)
counters.lots_inserted += ins
counters.lots_updated += upd
except Exception as save_exc:
@ -1782,6 +1831,8 @@ async def run_yandex_city_sweep(
# city_slug (#12): rgid города-цели → YandexRealtyScraper.city_rgid скоупит SERP
# на город вместо дефолтного ЕКБ. None/неизвестный slug → ЕКБ-дефолт в конструкторе.
_loc = get_city_location(city_slug)
# #2594: город для save_listings(..., city=...) — один на весь sweep.
_city_name = resolve_city_name(city_slug)
_rooms_list = rooms_list or list(ROOM_PATH.keys())
_price_ranges = price_ranges or DEFAULT_PRICE_RANGES
@ -1880,7 +1931,12 @@ async def run_yandex_city_sweep(
counters.lots_fetched += len(new_lots)
try:
ins, upd = save_listings(
db, new_lots, matcher=matcher, region_code=region_code, run_id=run_id
db,
new_lots,
matcher=matcher,
region_code=region_code,
run_id=run_id,
city=_city_name,
)
counters.lots_inserted += ins
counters.lots_updated += upd
@ -2288,6 +2344,8 @@ async def run_cian_city_sweep(
# city_slug (#12): region_id города-цели → CianScraper.city_region_id скоупит SERP
# на город вместо дефолтного ЕКБ. None/неизвестный slug → ЕКБ-дефолт в конструкторе.
_loc = get_city_location(city_slug)
# #2594: город для save_listings(..., city=...) — один на весь sweep.
_city_name = resolve_city_name(city_slug)
counters = CianCitySweepCounters(anchors_total=len(_anchors))
consecutive_failures = 0
cian_rotations_done = 0 # #1848: бюджет IP-ротаций на весь sweep
@ -2383,7 +2441,12 @@ async def run_cian_city_sweep(
counters.lots_dropped_secondary += _before - len(anchor_lots)
if anchor_lots:
inserted, updated = save_listings(
db, anchor_lots, matcher=matcher, region_code=region_code, run_id=run_id
db,
anchor_lots,
matcher=matcher,
region_code=region_code,
run_id=run_id,
city=_city_name,
)
counters.lots_inserted += inserted
counters.lots_updated += updated
@ -2785,6 +2848,8 @@ async def run_cian_full_load(
region_code=region_code,
run_id=run_id,
skip_seen_today=config.scraper_skip_seen_today,
# #2594: exhaustive региональный сбор — только ЕКБ (см. docstring run_*_full_load).
city=EKATERINBURG_CITY_NAME,
)
# save_listings вызывает db.commit() внутри — данные в БД сразу
counters.saved_inserted += inserted
@ -3084,6 +3149,8 @@ async def run_yandex_full_load(
region_code=region_code,
run_id=run_id,
skip_seen_today=config.scraper_skip_seen_today,
# #2594: exhaustive региональный сбор — только ЕКБ (см. docstring run_*_full_load).
city=EKATERINBURG_CITY_NAME,
)
# save_listings вызывает db.commit() внутри — данные в БД сразу
counters.saved_inserted += inserted
@ -3289,6 +3356,8 @@ async def run_avito_full_load(
region_code=region_code,
run_id=run_id,
skip_seen_today=config.scraper_skip_seen_today,
# #2594: exhaustive региональный сбор — только ЕКБ (см. docstring run_*_full_load).
city=EKATERINBURG_CITY_NAME,
)
# save_listings вызывает db.commit() внутри — данные в БД сразу
counters.saved_inserted += inserted
@ -3499,8 +3568,18 @@ async def run_domclick_city_sweep(
lots = await _scraper.fetch_city(city_id=city_id, rooms=rooms, pages=pages)
counters.lots_fetched += len(lots)
if lots:
# #2594: domclick oblast-rollout (B2) ещё не wired (нет city_id→slug
# мэппинга, см. CITY_LOCATIONS) — известный ЕКБ city_id получает
# "Екатеринбург", любой другой (будущий B2) честно остаётся None, а не
# угадывается.
_dc_city = EKATERINBURG_CITY_NAME if city_id == DOMCLICK_DEFAULT_CITY_ID else None
inserted, updated = save_listings(
db, lots, matcher=matcher, region_code=region_code, run_id=run_id
db,
lots,
matcher=matcher,
region_code=region_code,
run_id=run_id,
city=_dc_city,
)
counters.lots_inserted += inserted
counters.lots_updated += updated

68
tradein-mvp/uv.lock generated
View file

@ -43,6 +43,72 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" },
]
[[package]]
name = "bcrypt"
version = "5.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806, upload-time = "2025-09-25T19:49:05.102Z" },
{ url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626, upload-time = "2025-09-25T19:49:06.723Z" },
{ url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853, upload-time = "2025-09-25T19:49:08.028Z" },
{ url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793, upload-time = "2025-09-25T19:49:09.727Z" },
{ url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930, upload-time = "2025-09-25T19:49:11.204Z" },
{ url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194, upload-time = "2025-09-25T19:49:12.524Z" },
{ url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381, upload-time = "2025-09-25T19:49:14.308Z" },
{ url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750, upload-time = "2025-09-25T19:49:15.584Z" },
{ url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757, upload-time = "2025-09-25T19:49:17.244Z" },
{ url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740, upload-time = "2025-09-25T19:49:18.693Z" },
{ url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197, upload-time = "2025-09-25T19:49:20.523Z" },
{ url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974, upload-time = "2025-09-25T19:49:22.254Z" },
{ url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498, upload-time = "2025-09-25T19:49:24.134Z" },
{ url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853, upload-time = "2025-09-25T19:49:25.702Z" },
{ url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626, upload-time = "2025-09-25T19:49:26.928Z" },
{ url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" },
{ url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" },
{ url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" },
{ url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" },
{ url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" },
{ url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" },
{ url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" },
{ url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" },
{ url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" },
{ url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" },
{ url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" },
{ url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" },
{ url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" },
{ url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" },
{ url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" },
{ url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" },
{ url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" },
{ url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" },
{ url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" },
{ url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" },
{ url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" },
{ url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" },
{ url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" },
{ url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" },
{ url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" },
{ url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" },
{ url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" },
{ url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" },
{ url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" },
{ url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" },
{ url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" },
{ url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" },
{ url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" },
{ url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" },
{ url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" },
{ url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" },
{ url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" },
{ url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" },
{ url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" },
{ url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" },
{ url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" },
{ url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" },
{ url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" },
]
[[package]]
name = "brotli"
version = "1.2.0"
@ -1609,6 +1675,7 @@ name = "tradein-mvp-backend"
version = "0.1.0"
source = { virtual = "backend" }
dependencies = [
{ name = "bcrypt" },
{ name = "curl-cffi" },
{ name = "fastapi" },
{ name = "geoalchemy2" },
@ -1644,6 +1711,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "bcrypt", specifier = ">=4.2.0" },
{ name = "curl-cffi", specifier = ">=0.7.0" },
{ name = "fastapi", specifier = ">=0.115.0" },
{ name = "geoalchemy2", specifier = ">=0.15.0" },