From 59fa6d52da8dfdbf6907a444de1e3e4a0db5eefe Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 30 Jul 2026 09:05:43 +0300 Subject: [PATCH 01/41] =?UTF-8?q?fix(tradein/auth):=20=D0=B7=D0=B0=D0=BA?= =?UTF-8?q?=D1=80=D1=8B=D1=82=D1=8C=20=D0=B4=D0=BE=D1=81=D1=82=D1=83=D0=BF?= =?UTF-8?q?=20user2=20(=D0=91=D1=80=D1=83=D1=81=D0=BD=D0=B8=D0=BA=D0=B0)?= =?UTF-8?q?=20=E2=80=94=20role=20pilot=20=E2=86=92=20expired?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- auth/roles.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/auth/roles.yaml b/auth/roles.yaml index d1e26301..ef999bff 100644 --- a/auth/roles.yaml +++ b/auth/roles.yaml @@ -70,7 +70,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 From db2cde287a7a77a4e8ab86bb6e00f8cc5d193527 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 30 Jul 2026 09:20:47 +0300 Subject: [PATCH 02/41] =?UTF-8?q?test(tradein/rbac):=20user2=20=E2=86=92?= =?UTF-8?q?=20expired=20=D0=B2=20test=5Fget=5Frole=5Fknown=5Fusers=20(?= =?UTF-8?q?=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20#2548)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/backend/tests/test_rbac.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tradein-mvp/backend/tests/test_rbac.py b/tradein-mvp/backend/tests/test_rbac.py index 601287f3..c0648e32 100644 --- a/tradein-mvp/backend/tests/test_rbac.py +++ b/tradein-mvp/backend/tests/test_rbac.py @@ -90,7 +90,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: From 0c5246913a2c772ded4e8604b47d96accef77594 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 30 Jul 2026 09:40:05 +0300 Subject: [PATCH 03/41] feat(tradein/db): tradein_users + tradein_sessions schema (#2551) --- .../data/sql/192_tradein_users_auth.sql | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 tradein-mvp/backend/data/sql/192_tradein_users_auth.sql diff --git a/tradein-mvp/backend/data/sql/192_tradein_users_auth.sql b/tradein-mvp/backend/data/sql/192_tradein_users_auth.sql new file mode 100644 index 00000000..df5193c8 --- /dev/null +++ b/tradein-mvp/backend/data/sql/192_tradein_users_auth.sql @@ -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; From 3f1b86f16a804577357feef5f659186905b14ef0 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 30 Jul 2026 09:40:51 +0300 Subject: [PATCH 04/41] feat(tradein/auth): password hashing + session config foundation (#2550) --- tradein-mvp/backend/app/core/config.py | 22 ++++++ tradein-mvp/backend/app/core/password.py | 61 +++++++++++++++++ tradein-mvp/backend/pyproject.toml | 1 + tradein-mvp/backend/tests/test_password.py | 79 ++++++++++++++++++++++ tradein-mvp/uv.lock | 68 +++++++++++++++++++ 5 files changed, 231 insertions(+) create mode 100644 tradein-mvp/backend/app/core/password.py create mode 100644 tradein-mvp/backend/tests/test_password.py diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 7340a8ee..a8da5bb2 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -49,6 +49,28 @@ class Settings(BaseSettings): default="", validation_alias="TRADEIN_INTERNAL_AUTH_SECRET" ) + # ── #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" + ) + # 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). diff --git a/tradein-mvp/backend/app/core/password.py b/tradein-mvp/backend/app/core/password.py new file mode 100644 index 00000000..9616d5bf --- /dev/null +++ b/tradein-mvp/backend/app/core/password.py @@ -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 diff --git a/tradein-mvp/backend/pyproject.toml b/tradein-mvp/backend/pyproject.toml index cc1f1e49..29d7c22c 100644 --- a/tradein-mvp/backend/pyproject.toml +++ b/tradein-mvp/backend/pyproject.toml @@ -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. diff --git a/tradein-mvp/backend/tests/test_password.py b/tradein-mvp/backend/tests/test_password.py new file mode 100644 index 00000000..5cdfa31e --- /dev/null +++ b/tradein-mvp/backend/tests/test_password.py @@ -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 diff --git a/tradein-mvp/uv.lock b/tradein-mvp/uv.lock index 3ef63234..66012765 100644 --- a/tradein-mvp/uv.lock +++ b/tradein-mvp/uv.lock @@ -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" }, From 08352665162f5d5c5e669bb124aa9d1f62fc1cfa Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 30 Jul 2026 10:48:28 +0300 Subject: [PATCH 05/41] =?UTF-8?q?feat(tradein/auth):=20auth-core=20?= =?UTF-8?q?=E2=80=94=20login/logout,=20sessions,=20dual-mode=20rbac=20(#25?= =?UTF-8?q?52)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation для эпика #2549: session-cookie auth поверх legacy Caddy trusted-header. app.services.auth_session — CRUD для tradein_sessions (create/get/revoke) + get_user_by_username для password-логина; opaque secrets.token_urlsafe токены, sliding last_seen_at/expires_at refresh (не чаще раза в 5 минут). POST /api/v1/auth/login проверяет password_hash (bcrypt) через app.core.password, ставит httponly+secure cookie, пишет login_success/login_failed в user_events; per-username+IP rate-limit (SlidingWindowLimiter) отдельно от общего RateLimitMiddleware. POST /logout ревокает сессию и чистит cookie. Оба пути exempt из rbac_guard's auth-required gate (иначе логин сам себя не пропустил бы). rbac_guard теперь dual-mode: session-cookie резолвится первым (DB-роль employee/manager/admin -> paths как у pilot/+team/admin), fallback на legacy X-Authenticated-User + roles.yaml БЕЗ ИЗМЕНЕНИЙ когда auth_mode == "dual"; auth_mode == "db_only" отключает legacy header полностью. Резолвленный сессией username инжектится в ASGI scope headers (до call_next) — RequestAuditMiddleware и downstream route-хендлеры видят его прозрачно; RateLimitMiddleware (внешний относительно rbac_guard) для session-запросов лимитирует по IP, не по username — документированный trade-off, не регрессия. GET /me — session-first: валидная cookie отдаёт scope из tradein_users без похода в roles.yaml; без cookie — прежний legacy путь. session_secret остаётся опциональным (opaque-токены не требуют подписи) — пустое значение только logger.warning на старте, не startup-fail. Полный набор тестов (tests/test_rbac.py, test_internal_auth_secret.py, test_account_quota.py) проходит без правок — regression-safe. --- tradein-mvp/backend/app/api/v1/auth.py | 134 ++++++ tradein-mvp/backend/app/api/v1/me.py | 49 +- tradein-mvp/backend/app/core/rbac.py | 218 ++++++--- tradein-mvp/backend/app/main.py | 15 + .../backend/app/services/auth_session.py | 221 +++++++++ tradein-mvp/backend/tests/test_auth_api.py | 424 ++++++++++++++++++ .../backend/tests/test_auth_session.py | 306 +++++++++++++ 7 files changed, 1310 insertions(+), 57 deletions(-) create mode 100644 tradein-mvp/backend/app/api/v1/auth.py create mode 100644 tradein-mvp/backend/app/services/auth_session.py create mode 100644 tradein-mvp/backend/tests/test_auth_api.py create mode 100644 tradein-mvp/backend/tests/test_auth_session.py diff --git a/tradein-mvp/backend/app/api/v1/auth.py b/tradein-mvp/backend/app/api/v1/auth.py new file mode 100644 index 00000000..3c03987d --- /dev/null +++ b/tradein-mvp/backend/app/api/v1/auth.py @@ -0,0 +1,134 @@ +"""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 защита). + - Rate-limit по (username, IP) — ЖЁСТЧЕ общего `RateLimitMiddleware` + (`/api/*`), т.к. login — типичная brute-force поверхность. Использует + `SlidingWindowLimiter` (тот же примитив, что и общий rate-limit). + - Raw-пароль НИКОГДА не логируется и не попадает в user_events payload — + только username/ip/user_agent/path/method (см. schedule_event ниже). +""" + +from __future__ import annotations + +import logging +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 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, +) + +_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"{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) + credentials_ok = ( + user is not None + and user["is_active"] + and user["password_hash"] is not None + and verify_password(body.password, user["password_hash"]) + ) + + 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} diff --git a/tradein-mvp/backend/app/api/v1/me.py b/tradein-mvp/backend/app/api/v1/me.py index 4b7da093..f5e74b22 100644 --- a/tradein-mvp/backend/app/api/v1/me.py +++ b/tradein-mvp/backend/app/api/v1/me.py @@ -7,16 +7,26 @@ Mounted at /api/v1/me; через Caddy `uri strip_prefix /trade-in` это ст Caddy basic_auth пропускает `X-Authenticated-User: ` через `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, diff --git a/tradein-mvp/backend/app/core/rbac.py b/tradein-mvp/backend/app/core/rbac.py index 0596de39..2f0aed52 100644 --- a/tradein-mvp/backend/app/core/rbac.py +++ b/tradein-mvp/backend/app/core/rbac.py @@ -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,22 @@ 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, не остальные защиты. +_PUBLIC_PATHS = frozenset( + { + "/health", + "/docs", + "/redoc", + "/openapi.json", + "/api/v1/auth/login", + "/api/v1/auth/logout", + } +) # #R2-H3: Caddy срезает внешний префикс /trade-in (uri strip_prefix) перед # tradein-backend, а globs в roles.yaml — ВНЕШНИЕ (/trade-in/api/v1/**). Для # scope-проверки восстанавливаем внешний путь. @@ -51,6 +73,63 @@ _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`` — только формы ``/**`` и + ``/**``, не нужна полная 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()``) видели сессионного DB-юзера так же, как + Caddy trusted-header юзера — без правок в каждом из этих мест по + отдельности (минимально инвазивный способ). + + Механизм: ``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()`` + отработал весь внутренний стек, включая эту мутацию. + + Известное ограничение: ``RateLimitMiddleware`` тоже внешний относительно + rbac_guard, но читает заголовок ДО вызова call_next() (до того, как этот + guard успевает отработать) — для ЭТОГО конкретного запроса сессионный + юзер лимитируется по IP, а не по username (per-user множитель не + применяется). Не регрессия (IP-лимит применялся бы и раньше — до + добавления session-auth такие запросы вообще были 401), просто более + строгий бюджет специфично для session-cookie-запросов; при необходимости + точного per-user квотинга для DB-юзеров — переносить резолв сессии выше + RateLimit в app/main.py отдельным issue. + """ + if request.headers.get("X-Authenticated-User"): + return + request.scope["headers"] = [ + *request.scope.get("headers", []), + (b"x-authenticated-user", username.encode("latin-1")), + ] + + async def rbac_guard( request: Request, call_next: Callable[[Request], Awaitable[Response]], @@ -59,40 +138,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 +214,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)", diff --git a/tradein-mvp/backend/app/main.py b/tradein-mvp/backend/app/main.py index 65fb5098..35ca453e 100644 --- a/tradein-mvp/backend/app/main.py +++ b/tradein-mvp/backend/app/main.py @@ -23,6 +23,7 @@ from sentry_sdk.integrations.starlette import StarletteIntegration from app.api.v1 import ( admin, audit, + auth, brand, buildings, geocode, @@ -106,6 +107,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 +172,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"]) diff --git a/tradein-mvp/backend/app/services/auth_session.py b/tradein-mvp/backend/app/services/auth_session.py new file mode 100644 index 00000000..35b27c9c --- /dev/null +++ b/tradein-mvp/backend/app/services/auth_session.py @@ -0,0 +1,221 @@ +"""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 -> те же права, что legacy pilot (/trade-in/** только). +# manager -> employee + задел /api/v1/team/** (роутер появится в #2554). +# admin -> полный доступ, как legacy admin. +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/**"], + ), + "manager": ( + ["/trade-in/**", "/trade-in/api/v1/**", "/api/v1/team/**"], + ["/admin/**", "/api/v1/admin/**", "/trade-in/api/v1/admin/**"], + ), + "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, ([], ["/**"])) diff --git a/tradein-mvp/backend/tests/test_auth_api.py b/tradein-mvp/backend/tests/test_auth_api.py new file mode 100644 index 00000000..0eb828e2 --- /dev/null +++ b/tradein-mvp/backend/tests/test_auth_api.py @@ -0,0 +1,424 @@ +"""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 Any + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import pytest +from fastapi import FastAPI +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} + + 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_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"] + 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 + assert "/api/v1/team/**" in resp.json()["allowed_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") + assert denied.status_code in (401, 403, 404) diff --git a/tradein-mvp/backend/tests/test_auth_session.py b/tradein-mvp/backend/tests/test_auth_session.py new file mode 100644 index 00000000..650186fa --- /dev/null +++ b/tradein-mvp/backend/tests/test_auth_session.py @@ -0,0 +1,306 @@ +"""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 + + +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 == ["/**"] From 4ecc3d689c0594604a972ad136c158d4f0b0ce43 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 30 Jul 2026 20:14:26 +0300 Subject: [PATCH 06/41] =?UTF-8?q?fix(tradein/auth):=20=D0=BF=D1=80=D0=B5?= =?UTF-8?q?=D0=B4=D0=BE=D1=82=D0=B2=D1=80=D0=B0=D1=82=D0=B8=D1=82=D1=8C=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B4=D0=BC=D0=B5=D0=BD=D1=83=20X-Authenticated-Us?= =?UTF-8?q?er=20=D0=BF=D1=80=D0=B8=20session-auth=20(#2552)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CRITICAL: _propagate_authenticated_user делала skip-if-present вместо перезаписи — клиент-контролируемый X-Authenticated-User (Caddy шлёт его на КАЖДЫЙ прод-запрос) выигрывал у резолвленной сессии для всего downstream-трафика, читающего заголовок напрямую (_assert_estimate_access*, account_quota, /trade-in/history, support.py) — в обоих auth_mode (dual и db_only). Теперь заголовок безусловно перезаписывается сессионным username (ASGI header-имена всегда lowercase bytes). Medium: .encode("latin-1") без errors="replace" крашил бы 500-кой каждый запрос кириллического username. Login timing-oracle — verify_password короткозамыкалась на unknown-username/NULL-hash (~1мс vs ~100-300мс bcrypt) → теперь всегда сверяется против dummy-хеша при отсутствующем юзере/хеше. Login rate-limit key length-prefixed — username с ':' (или IPv6 IP) больше не может схлопнуть чужой бюджет. Новые тесты подтверждают регрессию: прогнаны на старом коде (до фикса) через временный откат rbac.py — все три (spoof dual-mode, spoof db_only, кириллица) падали с 'victim' == 'alice' / UnicodeEncodeError; после фикса — зелёные. test_rbac.py/test_internal_auth_secret.py без изменений. --- tradein-mvp/backend/app/api/v1/auth.py | 38 +++++-- tradein-mvp/backend/app/core/rbac.py | 36 +++++-- tradein-mvp/backend/tests/test_auth_api.py | 118 ++++++++++++++++++++- 3 files changed, 170 insertions(+), 22 deletions(-) diff --git a/tradein-mvp/backend/app/api/v1/auth.py b/tradein-mvp/backend/app/api/v1/auth.py index 3c03987d..9933bc2e 100644 --- a/tradein-mvp/backend/app/api/v1/auth.py +++ b/tradein-mvp/backend/app/api/v1/auth.py @@ -9,9 +9,20 @@ 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). + `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 ниже). """ @@ -19,6 +30,7 @@ Security: from __future__ import annotations import logging +import secrets from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Request, Response @@ -27,7 +39,7 @@ from sqlalchemy.orm import Session from app.core.config import settings from app.core.db import get_db -from app.core.password import verify_password +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 @@ -46,6 +58,13 @@ _LOGIN_LIMITER = SlidingWindowLimiter( 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 = "неверный логин или пароль" @@ -67,7 +86,7 @@ async def login( ) -> LoginResponse: ip = _client_ip(request) user_agent = request.headers.get("user-agent") - rate_key = f"{body.username}:{ip}" + rate_key = f"{len(body.username)}:{body.username}:{ip}" retry_after = _LOGIN_LIMITER.check(rate_key) if retry_after is not None: @@ -78,12 +97,15 @@ async def login( ) user = get_user_by_username(db, body.username) - credentials_ok = ( - user is not None - and user["is_active"] - and user["password_hash"] is not None - and verify_password(body.password, user["password_hash"]) + 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( diff --git a/tradein-mvp/backend/app/core/rbac.py b/tradein-mvp/backend/app/core/rbac.py index 2f0aed52..ffaf1a04 100644 --- a/tradein-mvp/backend/app/core/rbac.py +++ b/tradein-mvp/backend/app/core/rbac.py @@ -95,12 +95,25 @@ def _db_role_path_allowed(role: str, path: str) -> bool: def _propagate_authenticated_user(request: Request, username: str) -> None: - """Инжектит ``X-Authenticated-User`` в ASGI scope (если его там ещё нет), - чтобы ``RateLimitMiddleware``/``RequestAuditMiddleware`` (оба читают сырой - заголовок напрямую, #2213/#2550) и downstream route-хендлеры (читающие - его через FastAPI ``Header()``) видели сессионного DB-юзера так же, как - Caddy trusted-header юзера — без правок в каждом из этих мест по - отдельности (минимально инвазивный способ). + """Инжектит ``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 между @@ -112,6 +125,10 @@ def _propagate_authenticated_user(request: Request, username: str) -> None: предыдущие) и читает ``request.headers`` уже ПОСЛЕ ``call_next()`` отработал весь внутренний стек, включая эту мутацию. + ASGI header-имена — всегда lowercase bytes (см. ASGI spec), поэтому + фильтр по ``b"x-authenticated-user"`` ловит заголовок независимо от + регистра, в котором его прислал клиент (Starlette уже нормализует). + Известное ограничение: ``RateLimitMiddleware`` тоже внешний относительно rbac_guard, но читает заголовок ДО вызова call_next() (до того, как этот guard успевает отработать) — для ЭТОГО конкретного запроса сессионный @@ -122,12 +139,9 @@ def _propagate_authenticated_user(request: Request, username: str) -> None: точного per-user квотинга для DB-юзеров — переносить резолв сессии выше RateLimit в app/main.py отдельным issue. """ - if request.headers.get("X-Authenticated-User"): - return request.scope["headers"] = [ - *request.scope.get("headers", []), - (b"x-authenticated-user", username.encode("latin-1")), - ] + (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( diff --git a/tradein-mvp/backend/tests/test_auth_api.py b/tradein-mvp/backend/tests/test_auth_api.py index 0eb828e2..8b55fb13 100644 --- a/tradein-mvp/backend/tests/test_auth_api.py +++ b/tradein-mvp/backend/tests/test_auth_api.py @@ -18,12 +18,12 @@ from __future__ import annotations import os from datetime import UTC, datetime, timedelta from types import SimpleNamespace -from typing import Any +from typing import Annotated, Any os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") import pytest -from fastapi import FastAPI +from fastapi import FastAPI, Header from fastapi.testclient import TestClient from app.api.v1 import auth as auth_router @@ -182,6 +182,15 @@ def _build_test_app(store: _Store) -> FastAPI: 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} + def _override_get_db(): # generator dependency — matches app.core.db.get_db shape yield _FakeDB(store) @@ -271,6 +280,47 @@ def test_login_null_password_hash_401(client: TestClient, store: _Store) -> None 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 @@ -421,4 +471,66 @@ def test_session_user_can_reach_tradein_but_not_admin(client: TestClient, store: assert ok.status_code == 200 denied = client.get("/api/v1/admin/dummy") - assert denied.status_code in (401, 403, 404) + # 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 + + +# --------------------------------------------------------------------------- +# #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 From 15d506b7dd6d2724a485e96abfb66785fc58f1ad Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 30 Jul 2026 20:46:33 +0300 Subject: [PATCH 07/41] =?UTF-8?q?feat(tradein/ui):=20login-=D1=84=D0=BE?= =?UTF-8?q?=D1=80=D0=BC=D0=B0=20+=20session-aware=20RouteGuard=20(#2555)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/v1/auth/login/logout уже в main (DB-backed session, httponly cookie tradein_session). Фронт: /login-форма (username+password, ошибки 401/429 по-русски, next= redirect с open-redirect guard), RouteGuard редиректит на /login при 401 вместо NoAccessScreen variant=session (prod-only, dev-режим без Caddy не трогаем), useLogout хук чистит /me-кэш и уходит на /login. Role расширена admin|manager|employee (новые) + pilot|analyst|expired (legacy dual-mode resolver на бэке). --- tradein-mvp/frontend/src/app/login/page.tsx | 268 ++++++++++++++++++ tradein-mvp/frontend/src/app/v2/page.tsx | 8 +- .../src/components/auth/RouteGuard.tsx | 42 ++- tradein-mvp/frontend/src/lib/useLogout.ts | 40 +++ tradein-mvp/frontend/src/lib/useMe.ts | 13 +- 5 files changed, 363 insertions(+), 8 deletions(-) create mode 100644 tradein-mvp/frontend/src/app/login/page.tsx create mode 100644 tradein-mvp/frontend/src/lib/useLogout.ts diff --git a/tradein-mvp/frontend/src/app/login/page.tsx b/tradein-mvp/frontend/src/app/login/page.tsx new file mode 100644 index 00000000..4d3965dc --- /dev/null +++ b/tradein-mvp/frontend/src/app/login/page.tsx @@ -0,0 +1,268 @@ +"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"; + +interface LoginInput { + username: string; + password: string; +} + +async function loginRequest(input: LoginInput): Promise { + 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-парсинге). + */ +function sanitizeNext(next: string | null): string { + if (!next) return "/"; + if (!/^\/(?!\/|\\)/.test(next)) return "/"; + return next; +} + +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) { + e.preventDefault(); + if (loginMutation.isPending) return; + loginMutation.mutate({ username: username.trim(), password }); + } + + return ( +
+ + +
+

+ Вход +

+

+ Войдите, чтобы продолжить работу с Мерой. +

+ +
+ + setUsername(e.target.value)} + disabled={loginMutation.isPending} + /> +
+ +
+ + setPassword(e.target.value)} + disabled={loginMutation.isPending} + /> +
+ + {loginMutation.isError ? ( +

+ {loginErrorMessage(loginMutation.error)} +

+ ) : null} + + +
+
+ ); +} diff --git a/tradein-mvp/frontend/src/app/v2/page.tsx b/tradein-mvp/frontend/src/app/v2/page.tsx index b4f2a78f..7979ac71 100644 --- a/tradein-mvp/frontend/src/app/v2/page.tsx +++ b/tradein-mvp/frontend/src/app/v2/page.tsx @@ -66,7 +66,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 +524,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. @@ -973,7 +977,7 @@ export default function TradeInV2Page() { onNavigate={setNav} reports={reportsCount ?? 0} user={topNavUser} - onLogout={logout} + onLogout={() => logoutMutation.mutate()} />
{ + if (!shouldRedirectToLogin) return; + router.push(`/login?next=${encodeURIComponent(rawPath)}`); + }, [shouldRedirectToLogin, rawPath, router]); + // #801: preview-страница самодостаточна (свой QueryClient с фейковым me), // RBAC к ней не применяем. Только под флагом — в проде по умолчанию выключено. if (ENABLE_PREVIEW && rawPath.startsWith("/ui-preview")) { return <>{children}; } + if (isLoginPage) { + return <>{children}; + } + if (isLoading) return null; 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 ; + // Prod: редирект уже запущен эффектом выше — ничего не рендерим, пока + // навигация не завершится (mounting children on 401 causes TanStack + // Query re-subscribe storm, см. историю до #2555 в git blame). + return null; } if (error instanceof HTTPError && error.status === 403) { diff --git a/tradein-mvp/frontend/src/lib/useLogout.ts b/tradein-mvp/frontend/src/lib/useLogout.ts new file mode 100644 index 00000000..93de98c4 --- /dev/null +++ b/tradein-mvp/frontend/src/lib/useLogout.ts @@ -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 { + 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"); + }, + }); +} diff --git a/tradein-mvp/frontend/src/lib/useMe.ts b/tradein-mvp/frontend/src/lib/useMe.ts index 06ef99e6..21fd14f8 100644 --- a/tradein-mvp/frontend/src/lib/useMe.ts +++ b/tradein-mvp/frontend/src/lib/useMe.ts @@ -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; From 712c56f4565bcc1deb4dadefae1ae8f767f70ca6 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 30 Jul 2026 20:51:31 +0300 Subject: [PATCH 08/41] =?UTF-8?q?feat(tradein/team):=20team-management=20A?= =?UTF-8?q?PI=20=E2=80=94=20employees=20CRUD,=20quotas,=20stats=20(#2554)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session-only identity (current_team_actor, admin|manager) поверх tradein_users/ tradein_sessions (#2552 foundation). Org-изоляция manager <-> employee через manager_id: чужой/несуществующий employee_id -> 404 (не 403 — не палим существование), POST с чужим manager_id в теле от manager игнорируется (принудительно свой id). Квота — upsert в account_quota_overrides (существующий паттерн, без правки account_quota.py). История оценок — user_events LEFT JOIN trade_in_estimates. Team-события (employee_created/blocked/unblocked/ password_reset/quota_changed) без пароля в payload. --- tradein-mvp/backend/app/api/v1/team.py | 471 +++++++++++++ tradein-mvp/backend/app/main.py | 2 + tradein-mvp/backend/app/schemas/team.py | 103 +++ tradein-mvp/backend/tests/test_team_api.py | 757 +++++++++++++++++++++ 4 files changed, 1333 insertions(+) create mode 100644 tradein-mvp/backend/app/api/v1/team.py create mode 100644 tradein-mvp/backend/app/schemas/team.py create mode 100644 tradein-mvp/backend/tests/test_team_api.py diff --git a/tradein-mvp/backend/app/api/v1/team.py b/tradein-mvp/backend/app/api/v1/team.py new file mode 100644 index 00000000..8da7a6e1 --- /dev/null +++ b/tradein-mvp/backend/app/api/v1/team.py @@ -0,0 +1,471 @@ +"""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`. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Annotated, Any + +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.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, + ) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _fetch_employee_row(db: Session, employee_id: int) -> RowMapping | None: + return ( + db.execute( + text( + """ + SELECT id, username, display_name, org_name, email, is_active, + manager_id, created_at + FROM tradein_users + WHERE id = :id AND role = 'employee' + """ + ), + {"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. + """ + 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: + db.execute( + text( + """ + INSERT INTO account_quota_overrides (username, monthly_limit, note) + VALUES (:username, CAST(:monthly_limit AS integer), :note) + ON CONFLICT (username) DO UPDATE SET + monthly_limit = EXCLUDED.monthly_limit, + note = EXCLUDED.note, + updated_at = now() + """ + ), + { + "username": username, + "monthly_limit": monthly_limit, + "note": f"team-api: set by {actor_username}", + }, + ) + + +def _employee_out(row: RowMapping, quota: dict[str, Any]) -> EmployeeOut: + return EmployeeOut( + id=row["id"], + username=row["username"], + 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)], +) -> 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, 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)], +) -> EmployeeOut: + """Частичное обновление сотрудника — block/unblock, лимит, профиль, пароль. + + manager может патчить ТОЛЬКО своих (manager_id == actor.user_id), иначе 404. + При is_active=False — обязательно revoke всех сессий (иначе блокировка не + подействует до истечения TTL текущей сессии сотрудника). + """ + row = _fetch_employee_row(db, employee_id) + 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: + # Обязательно ПОСЛЕ UPDATE, ДО финального commit — revoke_user_sessions + # коммитит сам (см. app.services.auth_session), это флашит и наш + # предшествующий UPDATE/quota-upsert в той же сессии. + 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) + assert updated_row is not None # только что успешно обновили эту же строку + quota = account_quota.get_status(db, updated_row["username"]) + return _employee_out(updated_row, quota) + + +# --------------------------------------------------------------------------- +# GET /employees +# --------------------------------------------------------------------------- + + +@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, +) -> list[EmployeeOut]: + """Список сотрудников. manager видит только своих; admin — всех, опц. ?manager_id=.""" + params: dict[str, Any] = {} + where = "WHERE role = 'employee'" + if actor.role == "manager": + where += " AND manager_id = :manager_id" + params["manager_id"] = actor.user_id + elif manager_id is not None: + where += " AND manager_id = :manager_id" + params["manager_id"] = manager_id + + rows = ( + db.execute( + text( + f""" + SELECT id, username, display_name, org_name, email, is_active, + manager_id, created_at + FROM tradein_users + {where} + ORDER BY created_at DESC + """ + ), + params, + ) + .mappings() + .all() + ) + + result: list[EmployeeOut] = [] + for row in rows: + quota = account_quota.get_status(db, row["username"]) + result.append(_employee_out(row, quota)) + return result + + +# --------------------------------------------------------------------------- +# 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) + 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] diff --git a/tradein-mvp/backend/app/main.py b/tradein-mvp/backend/app/main.py index 35ca453e..0a45d24c 100644 --- a/tradein-mvp/backend/app/main.py +++ b/tradein-mvp/backend/app/main.py @@ -31,6 +31,7 @@ from app.api.v1 import ( me, search, support, + team, trade_in, ) from app.core.config import settings @@ -183,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"]) diff --git a/tradein-mvp/backend/app/schemas/team.py b/tradein-mvp/backend/app/schemas/team.py new file mode 100644 index 00000000..a1022247 --- /dev/null +++ b/tradein-mvp/backend/app/schemas/team.py @@ -0,0 +1,103 @@ +"""Pydantic-схемы team-management API (#2554, эпик #2549). + +CRUD сотрудников (`tradein_users.role = 'employee'`), квоты, история оценок. +Org-изоляция (manager видит/меняет только своих employee) реализована в +`app.api.v1.team`, эти схемы — только форма запросов/ответов. +""" + +from __future__ import annotations + +import re +from datetime import datetime + +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, +# а не только на уровне БД. +_USERNAME_RE = re.compile(r"^[A-Za-z0-9._-]{3,64}$") + + +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 + 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 diff --git a/tradein-mvp/backend/tests/test_team_api.py b/tradein-mvp/backend/tests/test_team_api.py new file mode 100644 index 00000000..ae72124d --- /dev/null +++ b/tradein-mvp/backend/tests/test_team_api.py @@ -0,0 +1,757 @@ +"""Integration tests for #2554 team-management API — employees CRUD, quotas, history. + +Same pattern as `tests/test_auth_api.py`: real `rbac_guard` + real `auth.router` / +`team.router` wired into an isolated FastAPI test app, with an in-memory fake DB +(`_Store`/`_FakeDB`) dispatching on SQL text standing in for `tradein_users` / +`tradein_sessions` / `account_quota_overrides` / `account_estimate_usage` / +`user_events` / `trade_in_estimates`. + +`app.core.rbac.SessionLocal` (middleware, no FastAPI DI) and `app.core.db.get_db` +(auth.router / team.router `Depends(get_db)`) both point at the SAME `_Store` +instance per test — a session created via POST /login is immediately visible to +rbac_guard's own DB round trip AND to `current_team_actor`. +""" + +from __future__ import annotations + +import os +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace +from typing import Any +from uuid import uuid4 + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.api.v1 import auth as auth_router +from app.api.v1 import team as team_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 / quota / user_events +# --------------------------------------------------------------------------- + + +class _Store: + def __init__(self) -> None: + self.users: dict[str, dict[str, Any]] = {} # username -> user dict + self.sessions: dict[str, dict[str, Any]] = {} + self.quota_overrides: dict[str, dict[str, Any]] = {} + self.usage: dict[tuple[str, str], int] = {} + self.estimates: dict[str, dict[str, Any]] = {} # estimate_id -> result fields + self.events: list[dict[str, Any]] = [] # user_events rows (history source) + self._next_id = 1 + + def add_user( + self, + username: str, + password_hash: str | None, + *, + role: str = "employee", + manager_id: int | None = None, + is_active: bool = True, + display_name: str | None = None, + org_name: str | None = None, + email: str | None = None, + ) -> int: + uid = self._next_id + self._next_id += 1 + self.users[username] = { + "id": uid, + "username": username, + "password_hash": password_hash, + "role": role, + "manager_id": manager_id, + "display_name": display_name, + "org_name": org_name, + "email": email, + "is_active": is_active, + "created_at": datetime.now(UTC), + } + 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_estimate_event( + self, + username: str, + *, + address: str | None = "ул. Ленина, 1", + area_m2: str | None = "45", + rooms: str | None = "2", + estimate_id: str | None = None, + median_price: int | None = None, + confidence: str | None = None, + n_analogs: int | None = None, + created_at: datetime | None = None, + ) -> None: + eid = estimate_id or str(uuid4()) + self.events.append( + { + "username": username, + "event_type": "estimate_request", + "estimate_id": eid, + "payload": {"address": address, "area_m2": area_m2, "rooms": rooms}, + "created_at": created_at or datetime.now(UTC), + } + ) + if median_price is not None or confidence is not None or n_analogs is not None: + self.estimates[eid] = { + "median_price": median_price, + "confidence": confidence, + "n_analogs": n_analogs, + } + + +class _Result: + """Minimal cursor-result stand-in: `.fetchone()`/`.fetchall()` (attribute-style + Row) AND `.mappings().fetchone()`/`.all()` (dict-style RowMapping) — enough + surface for the SQL used by app.api.v1.team / app.api.v1.auth / + app.services.auth_session / app.services.account_quota.""" + + def __init__(self, rows: list[dict[str, Any]]) -> None: + self._rows = rows + + def fetchone(self) -> SimpleNamespace | None: + return SimpleNamespace(**self._rows[0]) if self._rows else None + + def fetchall(self) -> list[SimpleNamespace]: + return [SimpleNamespace(**r) for r in self._rows] + + def mappings(self) -> _Mappings: + return _Mappings(self._rows) + + +class _Mappings: + def __init__(self, rows: list[dict[str, Any]]) -> None: + self._rows = rows + + def fetchone(self) -> dict[str, Any] | None: + return dict(self._rows[0]) if self._rows else None + + def all(self) -> list[dict[str, Any]]: + return [dict(r) for r in self._rows] + + +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) -> _Result: + sql = str(stmt) + p = params or {} + s = self.store + + # ---- tradein_sessions ---- + if "INSERT INTO tradein_sessions" in sql: + now = datetime.now(UTC) + s.sessions[p["token"]] = { + "user_id": p["user_id"], + "expires_at": now + timedelta(hours=p["ttl_hours"]), + "last_seen_at": now, + } + return _Result([]) + + if "UPDATE tradein_sessions" in sql and "SET last_seen_at" in sql: + sess = s.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 _Result([]) + + if "DELETE FROM tradein_sessions WHERE token" in sql: + s.sessions.pop(p["token"], None) + return _Result([]) + + if "DELETE FROM tradein_sessions WHERE user_id" in sql: + uid = p["user_id"] + for tok in [t for t, sess in s.sessions.items() if sess["user_id"] == uid]: + del s.sessions[tok] + return _Result([]) + + if "FROM tradein_sessions s" in sql and "JOIN tradein_users u" in sql: + sess = s.sessions.get(p["token"]) + if sess is None: + return _Result([]) + user = s.user_by_id(sess["user_id"]) + if user is None: + return _Result([]) + return _Result( + [ + { + "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"], + } + ] + ) + + # ---- tradein_users: login lookup (get_user_by_username) ---- + if "password_hash, role, is_active" in sql and "FROM tradein_users" in sql: + user = s.users.get(p["username"]) + return _Result([user] if user is not None else []) + + # ---- tradein_users: create ---- + if "INSERT INTO tradein_users" in sql: + uid = s._next_id + s._next_id += 1 + created_at = datetime.now(UTC) + row = { + "id": uid, + "username": p["username"], + "password_hash": p["password_hash"], + "role": "employee", + "manager_id": p["manager_id"], + "display_name": p["display_name"], + "org_name": p["org_name"], + "email": p["email"], + "is_active": True, + "created_at": created_at, + } + s.users[p["username"]] = row + return _Result([dict(row)]) + + # ---- tradein_users: manager_id validation ---- + if "role = 'manager'" in sql: + user = s.user_by_id(p["id"]) + match = user is not None and user["role"] == "manager" + return _Result([{"id": user["id"]}] if match else []) + + # ---- tradein_users: list employees (has explicit ORDER BY) ---- + if "role = 'employee'" in sql and "ORDER BY created_at DESC" in sql: + rows = [u for u in s.users.values() if u["role"] == "employee"] + if "manager_id" in p: + rows = [u for u in rows if u["manager_id"] == p["manager_id"]] + rows = sorted(rows, key=lambda u: u["created_at"], reverse=True) + return _Result( + [ + { + "id": u["id"], + "username": u["username"], + "display_name": u["display_name"], + "org_name": u["org_name"], + "email": u["email"], + "is_active": u["is_active"], + "manager_id": u["manager_id"], + "created_at": u["created_at"], + } + for u in rows + ] + ) + + # ---- tradein_users: fetch single employee by id ---- + if "role = 'employee'" in sql: + user = s.user_by_id(p["id"]) + if user is None or user["role"] != "employee": + return _Result([]) + return _Result( + [ + { + "id": user["id"], + "username": user["username"], + "display_name": user["display_name"], + "org_name": user["org_name"], + "email": user["email"], + "is_active": user["is_active"], + "manager_id": user["manager_id"], + "created_at": user["created_at"], + } + ] + ) + + # ---- tradein_users: uniqueness pre-check ---- + if sql.strip().startswith("SELECT id FROM tradein_users WHERE username"): + user = s.users.get(p["u"]) + return _Result([{"id": user["id"]}] if user is not None else []) + + # ---- tradein_users: update (PATCH) ---- + if "UPDATE tradein_users" in sql and "SET display_name = COALESCE" in sql: + user = s.user_by_id(p["id"]) + assert user is not None + if p.get("display_name") is not None: + user["display_name"] = p["display_name"] + if p.get("org_name") is not None: + user["org_name"] = p["org_name"] + if p.get("email") is not None: + user["email"] = p["email"] + if p.get("is_active") is not None: + user["is_active"] = p["is_active"] + if p.get("password_hash") is not None: + user["password_hash"] = p["password_hash"] + return _Result([]) + + # ---- account_quota_overrides upsert ---- + if "INSERT INTO account_quota_overrides" in sql: + s.quota_overrides[p["username"]] = { + "monthly_limit": p["monthly_limit"], + "unlimited": False, + "note": p["note"], + } + return _Result([]) + + if "SELECT unlimited FROM account_quota_overrides" in sql: + override = s.quota_overrides.get(p["u"]) + return _Result([{"unlimited": override["unlimited"]}] if override else []) + + if "SELECT monthly_limit FROM account_quota_overrides" in sql: + override = s.quota_overrides.get(p["u"]) + return _Result([{"monthly_limit": override["monthly_limit"]}] if override else []) + + # ---- account_estimate_usage ---- + if "SELECT used FROM account_estimate_usage" in sql: + used = s.usage.get((p["u"], p["p"])) + return _Result([{"used": used}] if used is not None else []) + + # ---- user_events + trade_in_estimates (history) ---- + if "FROM user_events ue" in sql: + matches = [ + e + for e in s.events + if e["username"] == p["username"] and e["event_type"] == "estimate_request" + ] + matches.sort(key=lambda e: e["created_at"], reverse=True) + page = matches[p["offset"] : p["offset"] + p["limit"]] + rows = [] + for e in page: + extra = s.estimates.get(e["estimate_id"], {}) + rows.append( + { + "estimate_id": e["estimate_id"], + "address": e["payload"].get("address"), + "area_m2": e["payload"].get("area_m2"), + "rooms": e["payload"].get("rooms"), + "median_price": extra.get("median_price"), + "confidence": extra.get("confidence"), + "n_analogs": extra.get("n_analogs"), + "created_at": e["created_at"], + } + ) + return _Result(rows) + + raise AssertionError(f"unhandled fake SQL in test_team_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(team_router.router, prefix="/api/v1/team", tags=["team"]) + + 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") + # team.py / auth.py events go through schedule_event (own SessionLocal(), fire- + # and-forget) — captured into a list instead of hitting a real DB. + monkeypatch.setattr(team_router, "schedule_event", lambda **kw: _EVENTS.append(kw)) + monkeypatch.setattr(auth_router, "schedule_event", lambda **kw: None) + _EVENTS.clear() + + +_EVENTS: list[dict[str, Any]] = [] + + +@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 a Secure cookie; see test_auth_api.py for why + # a plain-http TestClient would silently drop it. + return TestClient(_build_test_app(store), base_url="https://testserver") + + +def _login(client: TestClient, username: str, password: str) -> None: + resp = client.post("/api/v1/auth/login", json={"username": username, "password": password}) + assert resp.status_code == 200, resp.text + + +# --------------------------------------------------------------------------- +# POST /employees — happy path + validation +# --------------------------------------------------------------------------- + + +def test_manager_creates_employee_forces_own_manager_id(client: TestClient, store: _Store) -> None: + mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + _login(client, "mgr_a", "Secret123!") + + resp = client.post( + "/api/v1/team/employees", + json={"username": "emp_alice", "password": "Secret123!", "display_name": "Алиса"}, + ) + assert resp.status_code == 201, resp.text + body = resp.json() + assert body["username"] == "emp_alice" + assert body["manager_id"] == mgr_id + assert body["quota"]["limit"] > 0 + assert store.users["emp_alice"]["password_hash"] is not None + assert any(e["event_type"] == "employee_created" for e in _EVENTS) + created_event = next(e for e in _EVENTS if e["event_type"] == "employee_created") + # Пароль никогда не попадает в аудит-событие. + assert "Secret123!" not in str(created_event) + + +def test_admin_creates_employee_with_explicit_manager_id(client: TestClient, store: _Store) -> None: + store.add_user("admin1", hash_password("Secret123!"), role="admin") + mgr_id = store.add_user("mgr_b", hash_password("Secret123!"), role="manager") + _login(client, "admin1", "Secret123!") + + resp = client.post( + "/api/v1/team/employees", + json={"username": "emp_bob", "password": "Secret123!", "manager_id": mgr_id}, + ) + assert resp.status_code == 201, resp.text + assert resp.json()["manager_id"] == mgr_id + + +def test_admin_creates_employee_without_manager_id(client: TestClient, store: _Store) -> None: + store.add_user("admin1", hash_password("Secret123!"), role="admin") + _login(client, "admin1", "Secret123!") + + resp = client.post( + "/api/v1/team/employees", json={"username": "emp_free", "password": "Secret123!"} + ) + assert resp.status_code == 201, resp.text + assert resp.json()["manager_id"] is None + + +def test_admin_create_employee_invalid_manager_id_422(client: TestClient, store: _Store) -> None: + store.add_user("admin1", hash_password("Secret123!"), role="admin") + _login(client, "admin1", "Secret123!") + + resp = client.post( + "/api/v1/team/employees", + json={"username": "emp_x", "password": "Secret123!", "manager_id": 999}, + ) + assert resp.status_code == 422 + + +def test_create_employee_non_ascii_username_422(client: TestClient, store: _Store) -> None: + store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + _login(client, "mgr_a", "Secret123!") + + resp = client.post( + "/api/v1/team/employees", + json={"username": "сотрудник", "password": "Secret123!"}, + ) + assert resp.status_code == 422 + + +def test_create_employee_duplicate_username_409(client: TestClient, store: _Store) -> None: + store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + store.add_user("emp_dup", hash_password("Secret123!"), role="employee") + _login(client, "mgr_a", "Secret123!") + + resp = client.post( + "/api/v1/team/employees", + json={"username": "emp_dup", "password": "Secret123!"}, + ) + assert resp.status_code == 409 + + +def test_create_employee_no_session_401(client: TestClient) -> None: + resp = client.post( + "/api/v1/team/employees", json={"username": "emp_x", "password": "Secret123!"} + ) + assert resp.status_code == 401 + + +def test_create_employee_employee_role_403(client: TestClient, store: _Store) -> None: + store.add_user("emp_a", hash_password("Secret123!"), role="employee") + _login(client, "emp_a", "Secret123!") + + resp = client.post( + "/api/v1/team/employees", json={"username": "emp_x", "password": "Secret123!"} + ) + assert resp.status_code == 403 + + +def test_employee_role_403_on_all_team_routes(client: TestClient, store: _Store) -> None: + store.add_user("emp_a", hash_password("Secret123!"), role="employee") + _login(client, "emp_a", "Secret123!") + + assert client.get("/api/v1/team/employees").status_code == 403 + assert client.patch("/api/v1/team/employees/1", json={}).status_code == 403 + assert client.get("/api/v1/team/employees/1/history").status_code == 403 + + +# --------------------------------------------------------------------------- +# Org isolation — manager A vs manager B +# --------------------------------------------------------------------------- + + +def test_manager_a_cannot_see_manager_b_employee(client: TestClient, store: _Store) -> None: + mgr_a = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + mgr_b = store.add_user("mgr_b", hash_password("Secret123!"), role="manager") + store.add_user("emp_a", hash_password("Secret123!"), role="employee", manager_id=mgr_a) + store.add_user("emp_b", hash_password("Secret123!"), role="employee", manager_id=mgr_b) + + _login(client, "mgr_a", "Secret123!") + resp = client.get("/api/v1/team/employees") + assert resp.status_code == 200 + usernames = {e["username"] for e in resp.json()} + assert usernames == {"emp_a"} + + +def test_manager_a_patch_manager_b_employee_404(client: TestClient, store: _Store) -> None: + mgr_a = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + mgr_b = store.add_user("mgr_b", hash_password("Secret123!"), role="manager") + emp_b_id = store.add_user( + "emp_b", hash_password("Secret123!"), role="employee", manager_id=mgr_b + ) + assert mgr_a # used only to seed manager_id != mgr_b + + _login(client, "mgr_a", "Secret123!") + resp = client.patch(f"/api/v1/team/employees/{emp_b_id}", json={"display_name": "hacked"}) + assert resp.status_code == 404 + + +def test_manager_a_history_manager_b_employee_404(client: TestClient, store: _Store) -> None: + mgr_a = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + mgr_b = store.add_user("mgr_b", hash_password("Secret123!"), role="manager") + emp_b_id = store.add_user( + "emp_b", hash_password("Secret123!"), role="employee", manager_id=mgr_b + ) + assert mgr_a + + _login(client, "mgr_a", "Secret123!") + resp = client.get(f"/api/v1/team/employees/{emp_b_id}/history") + assert resp.status_code == 404 + + +def test_manager_post_with_foreign_manager_id_creates_under_self( + client: TestClient, store: _Store +) -> None: + """POST с чужим manager_id в теле от лица manager — ИГНОРИРУЕТСЯ, сотрудник + создаётся под ЕГО собственным manager_id, не под указанным чужим.""" + mgr_a_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + mgr_b_id = store.add_user("mgr_b", hash_password("Secret123!"), role="manager") + assert mgr_a_id != mgr_b_id + + _login(client, "mgr_a", "Secret123!") + resp = client.post( + "/api/v1/team/employees", + json={ + "username": "emp_spoof", + "password": "Secret123!", + "manager_id": mgr_b_id, + }, + ) + assert resp.status_code == 201, resp.text + assert resp.json()["manager_id"] == mgr_a_id + assert store.users["emp_spoof"]["manager_id"] == mgr_a_id + + +# --------------------------------------------------------------------------- +# PATCH /employees/{id} — block/unblock/quota/profile/password +# --------------------------------------------------------------------------- + + +def test_block_employee_revokes_sessions(client: TestClient, store: _Store) -> None: + mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + emp_id = store.add_user( + "emp_a", hash_password("EmpSecret1!"), role="employee", manager_id=mgr_id + ) + + # Employee logs in first — real session created via the real auth flow. + emp_client = TestClient(client.app, base_url="https://testserver") + _login(emp_client, "emp_a", "EmpSecret1!") + assert any(sess["user_id"] == emp_id for sess in store.sessions.values()) + + _login(client, "mgr_a", "Secret123!") + resp = client.patch(f"/api/v1/team/employees/{emp_id}", json={"is_active": False}) + assert resp.status_code == 200, resp.text + assert resp.json()["is_active"] is False + + # Blocking must have revoked ALL of emp_a's sessions. + assert not any(sess["user_id"] == emp_id for sess in store.sessions.values()) + assert any(e["event_type"] == "employee_blocked" for e in _EVENTS) + + +def test_unblock_employee_event(client: TestClient, store: _Store) -> None: + mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + emp_id = store.add_user( + "emp_a", hash_password("Secret123!"), role="employee", manager_id=mgr_id, is_active=False + ) + _login(client, "mgr_a", "Secret123!") + + resp = client.patch(f"/api/v1/team/employees/{emp_id}", json={"is_active": True}) + assert resp.status_code == 200 + assert resp.json()["is_active"] is True + assert any(e["event_type"] == "employee_unblocked" for e in _EVENTS) + + +def test_patch_monthly_limit_reflected_in_quota_status(client: TestClient, store: _Store) -> None: + mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + emp_id = store.add_user( + "emp_a", hash_password("Secret123!"), role="employee", manager_id=mgr_id + ) + _login(client, "mgr_a", "Secret123!") + + resp = client.patch(f"/api/v1/team/employees/{emp_id}", json={"monthly_limit": 30}) + assert resp.status_code == 200, resp.text + assert resp.json()["quota"]["limit"] == 30 + assert any(e["event_type"] == "quota_changed" for e in _EVENTS) + + listing = client.get("/api/v1/team/employees") + assert listing.status_code == 200 + entry = next(e for e in listing.json() if e["id"] == emp_id) + assert entry["quota"]["limit"] == 30 + + +def test_patch_reset_password_no_password_in_events(client: TestClient, store: _Store) -> None: + mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + emp_id = store.add_user( + "emp_a", hash_password("OldSecret1!"), role="employee", manager_id=mgr_id + ) + _login(client, "mgr_a", "Secret123!") + + resp = client.patch(f"/api/v1/team/employees/{emp_id}", json={"new_password": "NewSecret2!"}) + assert resp.status_code == 200, resp.text + assert any(e["event_type"] == "employee_password_reset" for e in _EVENTS) + assert "NewSecret2!" not in str(_EVENTS) + + emp_client = TestClient(client.app, base_url="https://testserver") + login_resp = emp_client.post( + "/api/v1/auth/login", json={"username": "emp_a", "password": "NewSecret2!"} + ) + assert login_resp.status_code == 200 + + +def test_patch_no_session_401(client: TestClient, store: _Store) -> None: + mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + emp_id = store.add_user( + "emp_a", hash_password("Secret123!"), role="employee", manager_id=mgr_id + ) + resp = client.patch(f"/api/v1/team/employees/{emp_id}", json={"is_active": False}) + assert resp.status_code == 401 + + +# --------------------------------------------------------------------------- +# GET /employees — list + admin filter +# --------------------------------------------------------------------------- + + +def test_admin_sees_all_employees_and_filters_by_manager(client: TestClient, store: _Store) -> None: + store.add_user("admin1", hash_password("Secret123!"), role="admin") + mgr_a = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + mgr_b = store.add_user("mgr_b", hash_password("Secret123!"), role="manager") + store.add_user("emp_a", hash_password("Secret123!"), role="employee", manager_id=mgr_a) + store.add_user("emp_b", hash_password("Secret123!"), role="employee", manager_id=mgr_b) + + _login(client, "admin1", "Secret123!") + resp_all = client.get("/api/v1/team/employees") + assert resp_all.status_code == 200 + assert {e["username"] for e in resp_all.json()} == {"emp_a", "emp_b"} + + resp_filtered = client.get("/api/v1/team/employees", params={"manager_id": mgr_a}) + assert resp_filtered.status_code == 200 + assert {e["username"] for e in resp_filtered.json()} == {"emp_a"} + + +# --------------------------------------------------------------------------- +# GET /employees/{id}/history +# --------------------------------------------------------------------------- + + +def test_employee_history_happy_path(client: TestClient, store: _Store) -> None: + mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + emp_id = store.add_user( + "emp_a", hash_password("Secret123!"), role="employee", manager_id=mgr_id + ) + now = datetime.now(UTC) + store.add_estimate_event( + "emp_a", + address="ул. Малышева, 10", + median_price=6_500_000, + confidence="high", + n_analogs=12, + created_at=now - timedelta(minutes=5), + ) + store.add_estimate_event("emp_a", address="ул. Мамина-Сибиряка, 5", created_at=now) + + _login(client, "mgr_a", "Secret123!") + resp = client.get(f"/api/v1/team/employees/{emp_id}/history") + assert resp.status_code == 200, resp.text + body = resp.json() + assert len(body) == 2 + assert body[0]["address"] == "ул. Мамина-Сибиряка, 5" # most recent first + priced = next(e for e in body if e["address"] == "ул. Малышева, 10") + assert priced["median_price"] == 6_500_000 + assert priced["confidence"] == "high" + + +def test_employee_history_pagination(client: TestClient, store: _Store) -> None: + mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + emp_id = store.add_user( + "emp_a", hash_password("Secret123!"), role="employee", manager_id=mgr_id + ) + for i in range(5): + store.add_estimate_event("emp_a", address=f"адрес-{i}") + + _login(client, "mgr_a", "Secret123!") + resp = client.get(f"/api/v1/team/employees/{emp_id}/history", params={"limit": 2, "offset": 1}) + assert resp.status_code == 200 + assert len(resp.json()) == 2 + + +def test_employee_history_limit_max_200(client: TestClient, store: _Store) -> None: + mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + emp_id = store.add_user( + "emp_a", hash_password("Secret123!"), role="employee", manager_id=mgr_id + ) + _login(client, "mgr_a", "Secret123!") + + resp = client.get(f"/api/v1/team/employees/{emp_id}/history", params={"limit": 500}) + assert resp.status_code == 422 From e6d68c349efb294036548be17844a8dc720986ca Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 30 Jul 2026 20:57:54 +0300 Subject: [PATCH 09/41] =?UTF-8?q?fix(tradein/ui):=20=D1=85=D0=B0=D1=80?= =?UTF-8?q?=D0=B4=D0=B5=D0=BD=D0=B8=D0=BD=D0=B3=20sanitizeNext=20+=20?= =?UTF-8?q?=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?query=20=D0=B2=20next=20(#2555)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #2562 review, 3 однострочника: 1. sanitizeNext обходился: WHATWG URL-парсер (router.push) вырезает ASCII tab/CR/LF из ВСЕЙ строки перед парсингом, так что "/\t//evil" проходил regex (позиция 1 — таб, не "/"/"\\"), а после навигации резолвился в protocol-relative "//evil" → чужой origin. Теперь сначала strip [\t\r\n], потом валидация — regex видит ту же строку, что увидит парсер. 2. next=/login (или /login?...) кидал юзера обратно на форму входа (RouteGuard не гейтит /login) — dead-end. Фолбэк на "/". 3. RouteGuard брал next= только из usePathname(), без query — сессия, истёкшая на deep-link (/v2?id=), теряла отчёт после релогина. Добавлен window.location.search в next (effect всегда client-side). --- tradein-mvp/frontend/src/app/login/page.tsx | 24 +++++++++++++++++-- .../src/components/auth/RouteGuard.tsx | 9 ++++++- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/tradein-mvp/frontend/src/app/login/page.tsx b/tradein-mvp/frontend/src/app/login/page.tsx index 4d3965dc..b3529e83 100644 --- a/tradein-mvp/frontend/src/app/login/page.tsx +++ b/tradein-mvp/frontend/src/app/login/page.tsx @@ -43,11 +43,31 @@ function readNextParam(): string | null { * 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 "/"; - if (!/^\/(?!\/|\\)/.test(next)) return "/"; - return next; + 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 { diff --git a/tradein-mvp/frontend/src/components/auth/RouteGuard.tsx b/tradein-mvp/frontend/src/components/auth/RouteGuard.tsx index 7f21c7ab..513d4310 100644 --- a/tradein-mvp/frontend/src/components/auth/RouteGuard.tsx +++ b/tradein-mvp/frontend/src/components/auth/RouteGuard.tsx @@ -61,7 +61,14 @@ export function RouteGuard({ children }: RouteGuardProps) { useEffect(() => { if (!shouldRedirectToLogin) return; - router.push(`/login?next=${encodeURIComponent(rawPath)}`); + // PR #2562 review finding 3: deep-links carry их state в query (`/v2?id= + // ` — см. 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), From 4ac3971061ec644d81188b0217214be429913b69 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 30 Jul 2026 21:23:32 +0300 Subject: [PATCH 10/41] =?UTF-8?q?fix(tradein/team):=20revoke=20=D1=81?= =?UTF-8?q?=D0=B5=D1=81=D1=81=D0=B8=D0=B9=20=D0=BF=D1=80=D0=B8=20=D1=81?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=B5=20=D0=BF=D0=B0=D1=80=D0=BE=D0=BB=D1=8F?= =?UTF-8?q?=20+=20Origin-check=20+=20=D0=B1=D0=B0=D1=82=D1=87-=D0=BA=D0=B2?= =?UTF-8?q?=D0=BE=D1=82=D1=8B=20(#2554)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deep-review PR #2563: - HIGH: PATCH new_password не ревокал старые сессии сотрудника — sliding-refresh продлевал скомпрометированную/чужую сессию бесконечно. Теперь revoke_user_sessions вызывается при is_active=False ИЛИ new_password (self-lockout невозможен — _fetch_employee_row фильтрует role='employee'). - Medium1: Origin/Referer-check (защита CSRF) на POST/PATCH team-роутах против settings.cors_origins; оба заголовка отсутствуют (curl-смоук внутри контейнера) — пропускаем, не ломаем прод-смоук. - Medium2: батч-квота для GET /employees (_batch_quota_status) — было 2N+3 SQL-запросов на N сотрудников, стало 5 плоско (замерено: N=10 -> 5 запросов). Добавлена пагинация limit(default 50, max 200)/offset. - Заодно: статические WHERE-ветки вместо f-string-сборки в list_employees; monthly_limit теперь сбрасывает account_quota_overrides.unlimited=false и не затирает существующий note. --- tradein-mvp/backend/app/api/v1/team.py | 233 +++++++++++++++++---- tradein-mvp/backend/tests/test_team_api.py | 152 +++++++++++++- 2 files changed, 346 insertions(+), 39 deletions(-) diff --git a/tradein-mvp/backend/app/api/v1/team.py b/tradein-mvp/backend/app/api/v1/team.py index 8da7a6e1..f75b1624 100644 --- a/tradein-mvp/backend/app/api/v1/team.py +++ b/tradein-mvp/backend/app/api/v1/team.py @@ -25,6 +25,7 @@ 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 @@ -32,6 +33,7 @@ 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 @@ -92,6 +94,47 @@ async def current_team_actor( ) +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 # --------------------------------------------------------------------------- @@ -131,14 +174,22 @@ def _authorize_employee(actor: TeamActor, row: RowMapping | None) -> RowMapping: 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, note) - VALUES (:username, CAST(:monthly_limit AS integer), :note) + 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, - note = EXCLUDED.note, + unlimited = false, + note = COALESCE(account_quota_overrides.note, EXCLUDED.note), updated_at = now() """ ), @@ -150,6 +201,83 @@ def _upsert_quota_override( ) +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`: + unlimited = admin-роль (roles.yaml, in-memory, без похода в БД) ИЛИ + `account_quota_overrides.unlimited=true`; limit = override.monthly_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: + is_admin_role = get_role(username) == "admin" + except KeyError: + is_admin_role = False + unlimited = is_admin_role or bool(override is not None and override["unlimited"]) + 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"], @@ -174,6 +302,7 @@ 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`. @@ -277,12 +406,18 @@ async def update_employee( 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 — обязательно revoke всех сессий (иначе блокировка не - подействует до истечения TTL текущей сессии сотрудника). + При 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) row = _authorize_employee(actor, row) @@ -320,10 +455,12 @@ async def update_employee( if body.monthly_limit is not None: _upsert_quota_override(db, row["username"], body.monthly_limit, actor.username) - if body.is_active is False: + 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 в той же сессии. + # предшествующий UPDATE/quota-upsert в той же сессии. Self-lockout + # невозможен: _fetch_employee_row фильтрует role='employee', actor + # (admin|manager) никогда не может патчить сам себя через этот роут. revoke_user_sessions(db, employee_id) db.commit() @@ -381,44 +518,68 @@ async def update_employee( # --------------------------------------------------------------------------- +# Два статических варианта WHERE (НЕ f-string/динамическая сборка — Medium/ +# "заодно" review PR #2563: значения биндятся параметрами и без того безопасны, +# но статические ветки не провоцируют будущие правки в сторону конкатенации SQL). +_LIST_EMPLOYEES_BY_MANAGER_SQL = text( + """ + SELECT id, username, 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 + LIMIT :limit OFFSET :offset + """ +) + +_LIST_EMPLOYEES_ALL_SQL = text( + """ + SELECT id, username, display_name, org_name, email, is_active, manager_id, created_at + FROM tradein_users + WHERE role = 'employee' + ORDER BY created_at 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=.""" - params: dict[str, Any] = {} - where = "WHERE role = 'employee'" + """Список сотрудников. manager видит только своих; admin — всех, опц. ?manager_id=. + + Квота — ОДИН батч-запрос на всю страницу (`_batch_quota_status`), не N+1 + (Medium2, review PR #2563: было 2N+3 SQL-запросов на N сотрудников). + """ if actor.role == "manager": - where += " AND manager_id = :manager_id" - params["manager_id"] = actor.user_id - elif manager_id is not None: - where += " AND manager_id = :manager_id" - params["manager_id"] = manager_id - - rows = ( - db.execute( - text( - f""" - SELECT id, username, display_name, org_name, email, is_active, - manager_id, created_at - FROM tradein_users - {where} - ORDER BY created_at DESC - """ - ), - params, + 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() ) - .mappings() - .all() - ) - result: list[EmployeeOut] = [] - for row in rows: - quota = account_quota.get_status(db, row["username"]) - result.append(_employee_out(row, quota)) - return result + 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] # --------------------------------------------------------------------------- diff --git a/tradein-mvp/backend/tests/test_team_api.py b/tradein-mvp/backend/tests/test_team_api.py index ae72124d..57a2c6b7 100644 --- a/tradein-mvp/backend/tests/test_team_api.py +++ b/tradein-mvp/backend/tests/test_team_api.py @@ -48,6 +48,7 @@ class _Store: self.estimates: dict[str, dict[str, Any]] = {} # estimate_id -> result fields self.events: list[dict[str, Any]] = [] # user_events rows (history source) self._next_id = 1 + self.query_count = 0 # db.execute() calls — N+1 regression guard (review PR #2563) def add_user( self, @@ -169,6 +170,7 @@ class _FakeDB: sql = str(stmt) p = params or {} s = self.store + s.query_count += 1 # ---- tradein_sessions ---- if "INSERT INTO tradein_sessions" in sql: @@ -258,6 +260,8 @@ class _FakeDB: if "manager_id" in p: rows = [u for u in rows if u["manager_id"] == p["manager_id"]] rows = sorted(rows, key=lambda u: u["created_at"], reverse=True) + offset, limit = p.get("offset", 0), p.get("limit", len(rows)) + rows = rows[offset : offset + limit] return _Result( [ { @@ -315,15 +319,29 @@ class _FakeDB: user["password_hash"] = p["password_hash"] return _Result([]) - # ---- account_quota_overrides upsert ---- + # ---- account_quota_overrides upsert: unlimited always reset to False, + # note preserved (COALESCE) if a row already existed — mirrors real SQL. if "INSERT INTO account_quota_overrides" in sql: + existing_override = s.quota_overrides.get(p["username"]) + preserved_note = ( + existing_override["note"] if existing_override is not None else None + ) or p["note"] s.quota_overrides[p["username"]] = { "monthly_limit": p["monthly_limit"], "unlimited": False, - "note": p["note"], + "note": preserved_note, } return _Result([]) + # ---- account_quota_overrides: batch (list_employees) ---- + if "SELECT username, monthly_limit, unlimited" in sql: + rows = [ + {"username": u, "monthly_limit": ov["monthly_limit"], "unlimited": ov["unlimited"]} + for u, ov in s.quota_overrides.items() + if u in p["usernames"] + ] + return _Result(rows) + if "SELECT unlimited FROM account_quota_overrides" in sql: override = s.quota_overrides.get(p["u"]) return _Result([{"unlimited": override["unlimited"]}] if override else []) @@ -332,7 +350,16 @@ class _FakeDB: override = s.quota_overrides.get(p["u"]) return _Result([{"monthly_limit": override["monthly_limit"]}] if override else []) - # ---- account_estimate_usage ---- + # ---- account_estimate_usage: batch (list_employees) ---- + if "SELECT username, used" in sql: + rows = [ + {"username": u, "used": used} + for (u, period), used in s.usage.items() + if u in p["usernames"] and period == p["period"] + ] + return _Result(rows) + + # ---- account_estimate_usage: single (account_quota.get_status) ---- if "SELECT used FROM account_estimate_usage" in sql: used = s.usage.get((p["u"], p["p"])) return _Result([{"used": used}] if used is not None else []) @@ -619,6 +646,28 @@ def test_block_employee_revokes_sessions(client: TestClient, store: _Store) -> N assert any(e["event_type"] == "employee_blocked" for e in _EVENTS) +def test_reset_password_revokes_old_sessions(client: TestClient, store: _Store) -> None: + """HIGH (deep-review PR #2563): смена пароля обязана ревокать ВСЕ существующие + сессии сотрудника — иначе скомпрометированная/чужая сессия переживает reset + (sliding-refresh в auth_session.py продлевает её бесконечно, а не «до TTL»).""" + mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + emp_id = store.add_user( + "emp_a", hash_password("OldSecret1!"), role="employee", manager_id=mgr_id + ) + + emp_client = TestClient(client.app, base_url="https://testserver") + _login(emp_client, "emp_a", "OldSecret1!") + assert any(sess["user_id"] == emp_id for sess in store.sessions.values()) + + _login(client, "mgr_a", "Secret123!") + resp = client.patch(f"/api/v1/team/employees/{emp_id}", json={"new_password": "NewSecret2!"}) + assert resp.status_code == 200, resp.text + + # Password reset must have revoked ALL of emp_a's pre-existing sessions — + # not just when is_active is explicitly set to False. + assert not any(sess["user_id"] == emp_id for sess in store.sessions.values()) + + def test_unblock_employee_event(client: TestClient, store: _Store) -> None: mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") emp_id = store.add_user( @@ -700,6 +749,103 @@ def test_admin_sees_all_employees_and_filters_by_manager(client: TestClient, sto assert {e["username"] for e in resp_filtered.json()} == {"emp_a"} +def test_list_employees_query_count_is_not_n_plus_1(client: TestClient, store: _Store) -> None: + """Medium2 (deep-review PR #2563): pre-fix measured 2N+3 = 23 SQL queries for + N=10 employees (per-employee `account_quota.get_status`). Batch quota lookup + (`_batch_quota_status`) must keep the query count constant regardless of N.""" + store.add_user("admin1", hash_password("Secret123!"), role="admin") + mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + for i in range(10): + store.add_user(f"emp_{i}", hash_password("Secret123!"), role="employee", manager_id=mgr_id) + + _login(client, "admin1", "Secret123!") + store.query_count = 0 # reset after login's own DB traffic + resp = client.get("/api/v1/team/employees") + assert resp.status_code == 200 + assert len(resp.json()) == 10 + # rbac_guard session lookup (1) + current_team_actor session lookup (1) + + # list query (1) + 2 batch quota queries = 5, flat regardless of N. + assert store.query_count <= 5, f"expected O(1) queries for N=10, got {store.query_count}" + + +def test_list_employees_pagination(client: TestClient, store: _Store) -> None: + store.add_user("admin1", hash_password("Secret123!"), role="admin") + mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + for i in range(5): + store.add_user(f"emp_{i}", hash_password("Secret123!"), role="employee", manager_id=mgr_id) + + _login(client, "admin1", "Secret123!") + resp = client.get("/api/v1/team/employees", params={"limit": 2, "offset": 1}) + assert resp.status_code == 200 + assert len(resp.json()) == 2 + + +def test_list_employees_limit_max_200(client: TestClient, store: _Store) -> None: + store.add_user("admin1", hash_password("Secret123!"), role="admin") + _login(client, "admin1", "Secret123!") + resp = client.get("/api/v1/team/employees", params={"limit": 500}) + assert resp.status_code == 422 + + +# --------------------------------------------------------------------------- +# CSRF defense-in-depth — Origin/Referer check on state-changing team routes +# --------------------------------------------------------------------------- + + +def test_create_employee_origin_mismatch_403(client: TestClient, store: _Store) -> None: + store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + _login(client, "mgr_a", "Secret123!") + + resp = client.post( + "/api/v1/team/employees", + json={"username": "emp_evil", "password": "Secret123!"}, + headers={"Origin": "https://evil.example"}, + ) + assert resp.status_code == 403 + assert "emp_evil" not in store.users + + +def test_create_employee_origin_allowed(client: TestClient, store: _Store) -> None: + store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + _login(client, "mgr_a", "Secret123!") + + allowed_origin = config.settings.cors_origins[0] + resp = client.post( + "/api/v1/team/employees", + json={"username": "emp_ok", "password": "Secret123!"}, + headers={"Origin": allowed_origin}, + ) + assert resp.status_code == 201, resp.text + + +def test_create_employee_no_origin_no_referer_allowed(client: TestClient, store: _Store) -> None: + """curl-смоук внутри контейнера не шлёт ни Origin, ни Referer — не должен ломаться.""" + store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + _login(client, "mgr_a", "Secret123!") + + resp = client.post( + "/api/v1/team/employees", + json={"username": "emp_curl", "password": "Secret123!"}, + ) + assert resp.status_code == 201, resp.text + + +def test_patch_employee_origin_mismatch_403(client: TestClient, store: _Store) -> None: + mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + emp_id = store.add_user( + "emp_a", hash_password("Secret123!"), role="employee", manager_id=mgr_id + ) + _login(client, "mgr_a", "Secret123!") + + resp = client.patch( + f"/api/v1/team/employees/{emp_id}", + json={"display_name": "hacked"}, + headers={"Referer": "https://evil.example/csrf.html"}, + ) + assert resp.status_code == 403 + assert store.users["emp_a"]["display_name"] != "hacked" + + # --------------------------------------------------------------------------- # GET /employees/{id}/history # --------------------------------------------------------------------------- From e7a658c7facdb74c576750227314bfe84c46f185 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 30 Jul 2026 21:56:16 +0300 Subject: [PATCH 11/41] =?UTF-8?q?feat(tradein/db):=20seed=20=D1=81=D1=83?= =?UTF-8?q?=D1=89=D0=B5=D1=81=D1=82=D0=B2=D1=83=D1=8E=D1=89=D0=B8=D1=85=20?= =?UTF-8?q?=D1=8E=D0=B7=D0=B5=D1=80=D0=BE=D0=B2=20+=20ASCII-CHECK=20=D0=BD?= =?UTF-8?q?=D0=B0=20username=20(#2557)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration 193: переносит org-карту (admin/kopylov/praktika/user1-10), утверждённую владельцем продукта, из auth/roles.yaml в tradein_users (Foundation — миграция 192). password_hash=NULL для всех — пароли админ проставит вручную через team-UI (#2556). ASCII-CHECK на username (deep-review #2561): rbac кодирует session-username через encode("latin-1","replace"), кириллические логины одинаковой длины схлопываются в общий downstream-identity (IDOR) — constraint запрещает это fail-closed. ON CONFLICT DO UPDATE защищает password_hash/is_active/display_name/org_name/email через COALESCE — повторный прогон (recovery / staging без _schema_migrations tracking) не затирает то, что менеджер поменял через UI. --- .../data/sql/193_tradein_users_seed.sql | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 tradein-mvp/backend/data/sql/193_tradein_users_seed.sql diff --git a/tradein-mvp/backend/data/sql/193_tradein_users_seed.sql b/tradein-mvp/backend/data/sql/193_tradein_users_seed.sql new file mode 100644 index 00000000..6181b0bb --- /dev/null +++ b/tradein-mvp/backend/data/sql/193_tradein_users_seed.sql @@ -0,0 +1,112 @@ +-- 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-инсертов (порядок важен: constraint должен успеть провалидировать +-- вставляемые строки, а не просто существовать после них). +-- 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, +-- is_active, display_name, org_name, email защищены COALESCE(текущее, EXCLUDED) — +-- если админ уже проставил пароль / поменял is_active / display_name вручную через +-- team-UI (#2556) между двумя прогонами этого файла (например ручной re-apply при +-- recovery — обычный auto-apply тречит filename в _schema_migrations и не запускает +-- файл дважды на одном окружении, но scratch/staging БД такого трекинга не имеют), +-- повторный прогон НЕ должен затереть это состояние NULL-ом / seed-дефолтом. +-- role/manager_id намеренно синкаются с EXCLUDED (не защищены) — это и есть источник +-- истины org-карты из этой миграции; если владелец продукта поправит эту таблицу +-- новой миграцией поверх, DO UPDATE-ветка должна донести исправление, а не заморозить +-- первый прогон навсегда. +-- +-- 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 — должен применяться ДО вставки seed-строк ниже. +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 = EXCLUDED.manager_id, + password_hash = COALESCE(tradein_users.password_hash, EXCLUDED.password_hash), + is_active = COALESCE(tradein_users.is_active, EXCLUDED.is_active), + 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), + updated_at = now(); + +COMMIT; From 2e05a16a14cad3bffbd239637d5ddc7a796071fc Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 30 Jul 2026 22:02:19 +0300 Subject: [PATCH 12/41] =?UTF-8?q?fix(tradein/team):=20=D1=83=D1=81=D1=82?= =?UTF-8?q?=D0=BE=D0=B9=D1=87=D0=B8=D0=B2=D0=B0=D1=8F=20=D1=81=D0=BE=D1=80?= =?UTF-8?q?=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=BA=D0=B0=20=D1=81=D0=BF=D0=B8?= =?UTF-8?q?=D1=81=D0=BA=D0=B0=20+=20=D1=81=D0=B5=D0=BC=D0=B0=D0=BD=D1=82?= =?UTF-8?q?=D0=B8=D0=BA=D0=B0=20unlimited=20=D0=B2=20=D0=B1=D0=B0=D1=82?= =?UTF-8?q?=D1=87-=D0=BA=D0=B2=D0=BE=D1=82=D0=B0=D1=85=20(#2554)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deep-review PR #2563 follow-up (после merge+deploy): 1. ORDER BY created_at DESC, id DESC в _LIST_EMPLOYEES_BY_MANAGER_SQL / _LIST_EMPLOYEES_ALL_SQL. created_at DEFAULT now() — время транзакции, bulk-seed (#2557) вставляет много юзеров одной транзакцией -> идентичный timestamp у N+ строк -> без тай-брейкера порядок между LIMIT/OFFSET страницами на PostgreSQL для строк-близнецов не гарантирован (сотрудники пропадали/дублировались бы при листании). id (BIGINT IDENTITY, монотонный) — детерминированный tie-break. 2. _batch_quota_status: unlimited теперь честно совпадает с account_quota.is_unlimited — override.unlimited=true честится ТОЛЬКО для username, присутствующего в roles.yaml (KeyError -> unlimited=False всегда, override даже не читается). Раньше батч всегда читал override независимо от roles.yaml -> список мог показать "unlimited" для квоты, которую реальный enforcement (check_and_raise/increment, тот же is_unlimited) не признаёт. Сегодня недостижимо (unlimited есть только у kopylov/praktika, оба в roles.yaml), но станет достижимым при расширении ролевки. --- tradein-mvp/backend/app/api/v1/team.py | 49 +++++++-- tradein-mvp/backend/tests/test_team_api.py | 112 ++++++++++++++++++++- 2 files changed, 150 insertions(+), 11 deletions(-) diff --git a/tradein-mvp/backend/app/api/v1/team.py b/tradein-mvp/backend/app/api/v1/team.py index f75b1624..e3e30ffc 100644 --- a/tradein-mvp/backend/app/api/v1/team.py +++ b/tradein-mvp/backend/app/api/v1/team.py @@ -205,10 +205,25 @@ def _batch_quota_status(db: Session, usernames: list[str]) -> dict[str, dict[str """Батч-версия `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`: - unlimited = admin-роль (roles.yaml, in-memory, без похода в БД) ИЛИ - `account_quota_overrides.unlimited=true`; limit = override.monthly_limit, - иначе глобальный `account_quota.MONTHLY_LIMIT`. + Семантика ИДЕНТИЧНА `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 {} @@ -250,10 +265,17 @@ def _batch_quota_status(db: Session, usernames: list[str]) -> dict[str, dict[str for username in usernames: override = override_by_username.get(username) try: - is_admin_role = get_role(username) == "admin" + role = get_role(username) except KeyError: - is_admin_role = False - unlimited = is_admin_role or bool(override is not None and override["unlimited"]) + 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 @@ -521,12 +543,21 @@ async def update_employee( # Два статических варианта 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, 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 + ORDER BY created_at DESC, id DESC LIMIT :limit OFFSET :offset """ ) @@ -536,7 +567,7 @@ _LIST_EMPLOYEES_ALL_SQL = text( SELECT id, username, display_name, org_name, email, is_active, manager_id, created_at FROM tradein_users WHERE role = 'employee' - ORDER BY created_at DESC + ORDER BY created_at DESC, id DESC LIMIT :limit OFFSET :offset """ ) diff --git a/tradein-mvp/backend/tests/test_team_api.py b/tradein-mvp/backend/tests/test_team_api.py index 57a2c6b7..6a446885 100644 --- a/tradein-mvp/backend/tests/test_team_api.py +++ b/tradein-mvp/backend/tests/test_team_api.py @@ -61,6 +61,7 @@ class _Store: display_name: str | None = None, org_name: str | None = None, email: str | None = None, + created_at: datetime | None = None, ) -> int: uid = self._next_id self._next_id += 1 @@ -74,7 +75,7 @@ class _Store: "org_name": org_name, "email": email, "is_active": is_active, - "created_at": datetime.now(UTC), + "created_at": created_at or datetime.now(UTC), } return uid @@ -259,7 +260,10 @@ class _FakeDB: rows = [u for u in s.users.values() if u["role"] == "employee"] if "manager_id" in p: rows = [u for u in rows if u["manager_id"] == p["manager_id"]] - rows = sorted(rows, key=lambda u: u["created_at"], reverse=True) + # Mirrors real SQL `ORDER BY created_at DESC, id DESC` — `id` tiebreak + # is REQUIRED for deterministic paging when created_at ties (follow-up + # review PR #2563 п.1, bulk-seed #2557 inserts many rows in one tx). + rows = sorted(rows, key=lambda u: (u["created_at"], u["id"]), reverse=True) offset, limit = p.get("offset", 0), p.get("limit", len(rows)) rows = rows[offset : offset + limit] return _Result( @@ -787,6 +791,51 @@ def test_list_employees_limit_max_200(client: TestClient, store: _Store) -> None assert resp.status_code == 422 +def test_list_employees_pagination_stable_with_identical_created_at( + client: TestClient, store: _Store +) -> None: + """Follow-up review PR #2563 п.1: `created_at DEFAULT now()` — время ТРАНЗАКЦИИ, + bulk-seed (#2557) вставляет много юзеров одной транзакцией → идентичный + timestamp у N+ строк. Без `id DESC` тай-брейкера порядок между страницами + на PostgreSQL для строк-«близнецов» не гарантирован — сотрудники пропадали/ + дублировались бы при постраничном листании. Вставляем 5 сотрудников с + ОДИНАКОВЫМ created_at, листаем limit=2 постранично — объединение страниц + обязано дать полный набор без дублей и пропусков.""" + store.add_user("admin1", hash_password("Secret123!"), role="admin") + mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + same_ts = datetime.now(UTC) + expected_usernames = set() + for i in range(5): + username = f"emp_tie_{i}" + store.add_user( + username, + hash_password("Secret123!"), + role="employee", + manager_id=mgr_id, + created_at=same_ts, + ) + expected_usernames.add(username) + + _login(client, "admin1", "Secret123!") + + seen: list[str] = [] + offset = 0 + while True: + resp = client.get("/api/v1/team/employees", params={"limit": 2, "offset": offset}) + assert resp.status_code == 200, resp.text + page = [e["username"] for e in resp.json()] + if not page: + break + seen.extend(page) + offset += 2 + + assert len(seen) == len(expected_usernames), ( + f"page union has {len(seen)} entries (dupes or gaps), expected " + f"{len(expected_usernames)}: {seen}" + ) + assert set(seen) == expected_usernames + + # --------------------------------------------------------------------------- # CSRF defense-in-depth — Origin/Referer check on state-changing team routes # --------------------------------------------------------------------------- @@ -846,6 +895,65 @@ def test_patch_employee_origin_mismatch_403(client: TestClient, store: _Store) - assert store.users["emp_a"]["display_name"] != "hacked" +# --------------------------------------------------------------------------- +# _batch_quota_status unlimited semantics — must match account_quota.is_unlimited +# --------------------------------------------------------------------------- + + +def test_batch_quota_unlimited_ignored_for_non_roles_yaml_username( + client: TestClient, store: _Store +) -> None: + """Follow-up review PR #2563 п.2: `account_quota.is_unlimited` short-circuits + to False for a username NOT in roles.yaml — it never even reads + `account_quota_overrides.unlimited`. The batch quota status used by + GET /employees must agree, or the list would show "unlimited" for a quota + that real enforcement (check_and_raise/increment, same is_unlimited) does + NOT honor — a misleading display. `emp_ghost_unlimited` is a fresh DB-only + username guaranteed absent from roles.yaml.""" + store.add_user("admin1", hash_password("Secret123!"), role="admin") + mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + store.add_user( + "emp_ghost_unlimited", hash_password("Secret123!"), role="employee", manager_id=mgr_id + ) + store.quota_overrides["emp_ghost_unlimited"] = { + "monthly_limit": 15, + "unlimited": True, + "note": "manual grant via SQL runbook (not through team-api)", + } + + _login(client, "admin1", "Secret123!") + resp = client.get("/api/v1/team/employees") + assert resp.status_code == 200, resp.text + entry = next(e for e in resp.json() if e["username"] == "emp_ghost_unlimited") + # DB override says unlimited=true, but username is NOT in roles.yaml — real + # enforcement would never see it, so the list must NOT claim "unlimited". + assert entry["quota"]["unlimited"] is False + assert entry["quota"]["limit"] == 15 + + +def test_batch_quota_unlimited_honored_for_roles_yaml_username( + client: TestClient, store: _Store +) -> None: + """Symmetric positive case: a username actually present in roles.yaml + (non-admin role) — `account_quota_overrides.unlimited=true` IS honored, same + as `account_quota.is_unlimited`. Uses `kopylov` — real prod pilot-role entry + in auth/roles.yaml (see app/core/auth.py module docstring).""" + store.add_user("admin1", hash_password("Secret123!"), role="admin") + mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + store.add_user("kopylov", hash_password("Secret123!"), role="employee", manager_id=mgr_id) + store.quota_overrides["kopylov"] = { + "monthly_limit": 999, + "unlimited": True, + "note": "existing prod grant", + } + + _login(client, "admin1", "Secret123!") + resp = client.get("/api/v1/team/employees") + assert resp.status_code == 200, resp.text + entry = next(e for e in resp.json() if e["username"] == "kopylov") + assert entry["quota"]["unlimited"] is True + + # --------------------------------------------------------------------------- # GET /employees/{id}/history # --------------------------------------------------------------------------- From f112a4affb78d51d13efd8550d295e703053837e Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 30 Jul 2026 22:02:41 +0300 Subject: [PATCH 13/41] =?UTF-8?q?feat(tradein/ui):=20team-=D0=B4=D0=B0?= =?UTF-8?q?=D1=88=D0=B1=D0=BE=D1=80=D0=B4=20=D0=BC=D0=B5=D0=BD=D0=B5=D0=B4?= =?UTF-8?q?=D0=B6=D0=B5=D1=80=D0=B0=20=E2=80=94=20=D1=81=D0=BE=D1=82=D1=80?= =?UTF-8?q?=D1=83=D0=B4=D0=BD=D0=B8=D0=BA=D0=B8,=20=D0=BA=D0=B2=D0=BE?= =?UTF-8?q?=D1=82=D1=8B,=20=D0=B8=D1=81=D1=82=D0=BE=D1=80=D0=B8=D1=8F=20(#?= =?UTF-8?q?2556)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Единая страница /team для ролей admin/manager (backend сам скоупит список по org-изоляции) — таблица сотрудников с пагинацией, создание сотрудника с ручным паролем, изменение месячной квоты + сброс пароля одним PATCH, drawer с историей оценок. Nav-пункт «Команда» в Topbar виден только admin/manager (доп. roleGate поверх isPathAllowed — legacy analyst-роль иначе тоже прошла бы path-фильтр). --- tradein-mvp/frontend/src/app/team/page.tsx | 123 +++++++ .../components/team/CreateEmployeeForm.tsx | 219 ++++++++++++ .../components/team/EmployeeHistoryDrawer.tsx | 94 ++++++ .../src/components/team/EmployeeTable.tsx | 188 +++++++++++ .../src/components/team/QuotaEditor.tsx | 144 ++++++++ .../src/components/trade-in/Topbar.tsx | 27 +- .../src/components/trade-in/trade-in.css | 319 ++++++++++++++++++ tradein-mvp/frontend/src/lib/team-api.ts | 189 +++++++++++ 8 files changed, 1300 insertions(+), 3 deletions(-) create mode 100644 tradein-mvp/frontend/src/app/team/page.tsx create mode 100644 tradein-mvp/frontend/src/components/team/CreateEmployeeForm.tsx create mode 100644 tradein-mvp/frontend/src/components/team/EmployeeHistoryDrawer.tsx create mode 100644 tradein-mvp/frontend/src/components/team/EmployeeTable.tsx create mode 100644 tradein-mvp/frontend/src/components/team/QuotaEditor.tsx create mode 100644 tradein-mvp/frontend/src/lib/team-api.ts diff --git a/tradein-mvp/frontend/src/app/team/page.tsx b/tradein-mvp/frontend/src/app/team/page.tsx new file mode 100644 index 00000000..bf0016de --- /dev/null +++ b/tradein-mvp/frontend/src/app/team/page.tsx @@ -0,0 +1,123 @@ +"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 её уже прячет). + */ + +import { useState } from "react"; + +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; + +export default function TeamPage() { + const meQ = useMe(); + const [offset, setOffset] = useState(0); + const employeesQ = useEmployees(PAGE_LIMIT, offset); + + const [showCreateForm, setShowCreateForm] = useState(false); + const [quotaEmployee, setQuotaEmployee] = useState(null); + const [historyEmployee, setHistoryEmployee] = useState(null); + + // Роль ещё не загружена — не решаем ничего, RouteGuard уже отрисовал + // страницу (401/403 верхнего уровня он ловит сам), здесь только role-gate. + if (meQ.isPending) { + return ( + <> + +
+

Загрузка…

+
+ + ); + } + + const role = meQ.data?.role; + const isAllowedRole = role === "admin" || role === "manager"; + + if (!isAllowedRole) { + return ; + } + + // Backend 403 регардлесс роли фронта (напр. session истекла между /me и + // /employees) — тот же fallback, что и в admin/audit. + if (employeesQ.error instanceof HTTPError && employeesQ.error.status === 403) { + return ; + } + + const employees = employeesQ.data ?? []; + const hasNextPage = employees.length === PAGE_LIMIT; + + return ( + <> + +
+

Команда

+

+ Сотрудники, их доступ и месячные квоты на оценку. История оценок — по клику + «История». +

+ +
+
+

Сотрудники

+ +
+ + {employeesQ.isPending ?

Загрузка…

: null} + {employeesQ.isError && !(employeesQ.error instanceof HTTPError && employeesQ.error.status === 403) ? ( +

+ {teamErrorMessage(employeesQ.error)} +

+ ) : null} + + {employeesQ.data ? ( + + ) : null} +
+
+ + {showCreateForm ? setShowCreateForm(false)} /> : null} + {quotaEmployee ? ( + setQuotaEmployee(null)} /> + ) : null} + {historyEmployee ? ( + setHistoryEmployee(null)} + /> + ) : null} + + ); +} diff --git a/tradein-mvp/frontend/src/components/team/CreateEmployeeForm.tsx b/tradein-mvp/frontend/src/components/team/CreateEmployeeForm.tsx new file mode 100644 index 00000000..dafbad4a --- /dev/null +++ b/tradein-mvp/frontend/src/components/team/CreateEmployeeForm.tsx @@ -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) { + 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 ( +
+
e.stopPropagation()} + > +

Сотрудник создан

+

+ Логин «{createMutation.data.username}» готов. Передайте пароль сотруднику — + он не сохраняется в системе и не отправляется автоматически. +

+
+ +
+
+
+ ); + } + + return ( +
+
e.stopPropagation()} + onSubmit={handleSubmit} + > +

Новый сотрудник

+

+ Пароль задаётся вручную — передайте его сотруднику лично, рассылки нет. +

+ + {createMutation.isError ? ( +

+ {teamErrorMessage(createMutation.error)} +

+ ) : null} + +
+ + setUsername(e.target.value)} + onBlur={() => setUsernameTouched(true)} + disabled={createMutation.isPending} + aria-invalid={usernameInvalid} + /> +

{USERNAME_HINT}

+ {usernameInvalid ? ( +

+ Логин не соответствует формату +

+ ) : null} +
+ +
+ + setPassword(e.target.value)} + disabled={createMutation.isPending} + /> + +
+ +
+ + setDisplayName(e.target.value)} + disabled={createMutation.isPending} + /> +
+ +
+ + setOrgName(e.target.value)} + disabled={createMutation.isPending} + /> +
+ +
+ + setEmail(e.target.value)} + disabled={createMutation.isPending} + /> +
+ +
+ + setMonthlyLimit(e.target.value)} + disabled={createMutation.isPending} + /> +
+ +
+ + +
+
+
+ ); +} diff --git a/tradein-mvp/frontend/src/components/team/EmployeeHistoryDrawer.tsx b/tradein-mvp/frontend/src/components/team/EmployeeHistoryDrawer.tsx new file mode 100644 index 00000000..0d55066e --- /dev/null +++ b/tradein-mvp/frontend/src/components/team/EmployeeHistoryDrawer.tsx @@ -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 ( + <> +
+ + + ); +} diff --git a/tradein-mvp/frontend/src/components/team/EmployeeTable.tsx b/tradein-mvp/frontend/src/components/team/EmployeeTable.tsx new file mode 100644 index 00000000..62439c5d --- /dev/null +++ b/tradein-mvp/frontend/src/components/team/EmployeeTable.tsx @@ -0,0 +1,188 @@ +"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; + } +} + +function QuotaCell({ quota }: { quota: Employee["quota"] }) { + if (quota.unlimited) { + return без лимита; + } + const low = quota.remaining <= 0; + return ( + + {quota.used}/{quota.limit} + + ); +} + +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(null); + const [rowError, setRowError] = useState<{ id: number; message: string } | null>(null); + + function handleToggleActive(employee: Employee) { + const nextActive = !employee.is_active; + const confirmed = window.confirm( + nextActive + ? `Разблокировать сотрудника «${employee.username}»?` + : `Заблокировать сотрудника «${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) }), + }, + ); + } + + if (employees.length === 0) { + return

Сотрудников пока нет.

; + } + + return ( + <> + + + + + + + + + + + + + {employees.map((employee) => ( + + + + + + + + + + {rowError?.id === employee.id ? ( + + + + ) : null} + + ))} + +
ЛогинИмяСтатусКвотаСозданДействия
{employee.username} + {employee.display_name ?? "—"} + {employee.org_name ? ` · ${employee.org_name}` : ""} + + + {employee.is_active ? "Активен" : "Заблокирован"} + + + + {formatDate(employee.created_at)} +
+ + + +
+
+

+ {rowError.message} +

+
+ +
+ + + {offset + 1}–{offset + employees.length} + + +
+ + ); +} diff --git a/tradein-mvp/frontend/src/components/team/QuotaEditor.tsx b/tradein-mvp/frontend/src/components/team/QuotaEditor.tsx new file mode 100644 index 00000000..3eabd3eb --- /dev/null +++ b/tradein-mvp/frontend/src/components/team/QuotaEditor.tsx @@ -0,0 +1,144 @@ +"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); + + const updateMutation = useUpdateEmployee(); + + function handleSubmit(e: FormEvent) { + 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 ( +
+
e.stopPropagation()} + onSubmit={handleSubmit} + > +

Изменить сотрудника

+

{employee.username}

+ + {updateMutation.isError ? ( +

+ {teamErrorMessage(updateMutation.error)} +

+ ) : null} + +
+ + setMonthlyLimit(e.target.value)} + disabled={updateMutation.isPending} + /> +

+ Сейчас использовано {employee.quota.used} + {employee.quota.unlimited + ? " · без лимита" + : ` из ${employee.quota.limit} (осталось ${employee.quota.remaining})`} + {employee.quota.unlimited + ? ". Заполнение поля снимет безлимитный статус." + : ""} +

+
+ +
+ + setNewPassword(e.target.value)} + disabled={updateMutation.isPending} + /> + +

+ Смена пароля завершает все текущие сессии сотрудника. +

+
+ +
+ + +
+
+
+ ); +} diff --git a/tradein-mvp/frontend/src/components/trade-in/Topbar.tsx b/tradein-mvp/frontend/src/components/trade-in/Topbar.tsx index 094dda63..c7b88064 100644 --- a/tradein-mvp/frontend/src/components/trade-in/Topbar.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/Topbar.tsx @@ -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; @@ -111,6 +113,13 @@ const NAV_ITEMS: Array<{ 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/**). @@ -162,6 +171,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 +195,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; diff --git a/tradein-mvp/frontend/src/components/trade-in/trade-in.css b/tradein-mvp/frontend/src/components/trade-in/trade-in.css index 6a7bcbfe..b4bc3ec7 100644 --- a/tradein-mvp/frontend/src/components/trade-in/trade-in.css +++ b/tradein-mvp/frontend/src/components/trade-in/trade-in.css @@ -2820,3 +2820,322 @@ 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); +} + +.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); +} diff --git a/tradein-mvp/frontend/src/lib/team-api.ts b/tradein-mvp/frontend/src/lib/team-api.ts new file mode 100644 index 00000000..0c0e4bb0 --- /dev/null +++ b/tradein-mvp/frontend/src/lib/team-api.ts @@ -0,0 +1,189 @@ +"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 = + "Латиница, цифры, точка, дефис, подчёркивание; 3–64 символа"; + +// ---- Types (mirror app/schemas/team.py) ------------------------------------ + +export interface QuotaStatus { + limit: number; + used: number; + remaining: number; + unlimited: boolean; +} + +export interface Employee { + id: number; + username: string; + 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 → все). + */ +export function useEmployees(limit: number, offset: number) { + return useQuery({ + queryKey: [...EMPLOYEES_LIST_KEY, limit, offset], + queryFn: () => + apiFetch(`${BASE}/employees?limit=${limit}&offset=${offset}`), + 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({ + queryKey: ["team", "employees", employeeId, "history", limit, offset], + queryFn: () => + apiFetch( + `${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({ + mutationFn: (input) => + apiFetch(`${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(`${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 "Не удалось выполнить запрос. Попробуйте ещё раз"; +} From f915e1ee4d28d4032a3e1b08eb06a67db898786e Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 30 Jul 2026 22:08:14 +0300 Subject: [PATCH 14/41] =?UTF-8?q?fix(tradein/db):=20=D0=BD=D0=B5=20=D0=B7?= =?UTF-8?q?=D0=B0=D1=82=D0=B8=D1=80=D0=B0=D1=82=D1=8C=20manager=5Fid=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=20=D0=BF=D0=BE=D0=B2=D1=82=D0=BE=D1=80=D0=BD?= =?UTF-8?q?=D0=BE=D0=BC=20=D0=BF=D1=80=D0=BE=D0=B3=D0=BE=D0=BD=D0=B5=20?= =?UTF-8?q?=D1=81=D0=B8=D0=B4=D0=B0=20(#2557)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deep-review #2564: manager_id синкался из EXCLUDED безусловно — повторный прогон сида тихо обнулял связь сотрудник->менеджер, назначенную через team-API (#2563), сотрудник выпадал из _LIST_EMPLOYEES_BY_MANAGER_SQL. Защищён COALESCE, как остальные UI-managed поля. is_active убран из ON CONFLICT DO UPDATE SET вовсе (не COALESCE — колонка NOT NULL DEFAULT true делала бы COALESCE-ветку недостижимой, мёртвый код вводил в заблуждение симметрией с реально работающими COALESCE-полями). Open/close доступа — решение владельца продукта через UI (#2556), не повторный прогон seed-файла. --- .../data/sql/193_tradein_users_seed.sql | 55 ++++++++++++------- 1 file changed, 35 insertions(+), 20 deletions(-) diff --git a/tradein-mvp/backend/data/sql/193_tradein_users_seed.sql b/tradein-mvp/backend/data/sql/193_tradein_users_seed.sql index 6181b0bb..7eea9d95 100644 --- a/tradein-mvp/backend/data/sql/193_tradein_users_seed.sql +++ b/tradein-mvp/backend/data/sql/193_tradein_users_seed.sql @@ -22,8 +22,9 @@ -- -- WHAT: -- 1. ASCII-CHECK: tradein_users_username_ascii_ck CHECK (username ~ '^[A-Za-z0-9._-]{3,64}$'). --- Добавлен ДО seed-инсертов (порядок важен: constraint должен успеть провалидировать --- вставляемые строки, а не просто существовать после них). +-- Добавлен ДО 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 (отдельный клиент) @@ -46,16 +47,27 @@ -- `ADD CONSTRAINT IF NOT EXISTS` для CHECK) — паттерн из -- 189_account_estimate_usage_nonnegative.sql. -- - INSERT ... ON CONFLICT (username) DO UPDATE, но НЕ безусловно: password_hash, --- is_active, display_name, org_name, email защищены COALESCE(текущее, EXCLUDED) — --- если админ уже проставил пароль / поменял is_active / display_name вручную через --- team-UI (#2556) между двумя прогонами этого файла (например ручной re-apply при --- recovery — обычный auto-apply тречит filename в _schema_migrations и не запускает --- файл дважды на одном окружении, но scratch/staging БД такого трекинга не имеют), --- повторный прогон НЕ должен затереть это состояние NULL-ом / seed-дефолтом. --- role/manager_id намеренно синкаются с EXCLUDED (не защищены) — это и есть источник --- истины org-карты из этой миграции; если владелец продукта поправит эту таблицу --- новой миграцией поверх, DO UPDATE-ветка должна донести исправление, а не заморозить --- первый прогон навсегда. +-- 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) и @@ -64,7 +76,7 @@ BEGIN; --- Часть 1: ASCII-CHECK — должен применяться ДО вставки seed-строк ниже. +-- Часть 1: ASCII-CHECK (immediate constraint — валидирует и вставляемые ниже строки). DO $$ BEGIN IF NOT EXISTS ( @@ -100,13 +112,16 @@ VALUES ('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 = EXCLUDED.manager_id, + 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), - is_active = COALESCE(tradein_users.is_active, EXCLUDED.is_active), - 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), - updated_at = now(); + 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; From df943ea1c77b391d301caea52b8deed634519a77 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 30 Jul 2026 22:10:52 +0300 Subject: [PATCH 15/41] =?UTF-8?q?fix(tradein/team):=20\Z=20=D0=B2=D0=BC?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=BE=20$=20=D0=B2=20username-regex=20?= =?UTF-8?q?=E2=80=94=20422=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20500=20?= =?UTF-8?q?=D0=BD=D0=B0=20trailing=20newline=20(#2554)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deep-review seed'а (#2564) нашёл смежный дефект в уже смерженном коде (#2563): Python `$` матчит перед trailing newline (re.match(r'...\$', 'admin\n') -> True), а Postgres `~` в CHECK tradein_users_username_ascii_ck (миграция 193) - False. username="admin\n" проходил Pydantic-валидацию и падал уже в БД -> 500 вместо честного 422. `\Z` - конец строки без поблажки на trailing newline, совпадает с семантикой Postgres `~`. Grep по app/schemas/ (pattern=/regex=/re.compile/re.match/re.fullmatch) - других regex-валидаторов с `$` в схемах trade-in нет, team.py - единственный файл с regex-based полем. --- tradein-mvp/backend/app/schemas/team.py | 9 ++++++++- tradein-mvp/backend/tests/test_team_api.py | 20 ++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/tradein-mvp/backend/app/schemas/team.py b/tradein-mvp/backend/app/schemas/team.py index a1022247..fe037389 100644 --- a/tradein-mvp/backend/app/schemas/team.py +++ b/tradein-mvp/backend/app/schemas/team.py @@ -16,7 +16,14 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator # (`app.core.rbac._propagate_authenticated_user` кодирует latin-1 с # errors="replace"), поэтому валидация формы обязательна на границе API, # а не только на уровне БД. -_USERNAME_RE = re.compile(r"^[A-Za-z0-9._-]{3,64}$") +# +# `\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): diff --git a/tradein-mvp/backend/tests/test_team_api.py b/tradein-mvp/backend/tests/test_team_api.py index 6a446885..2f1e40ce 100644 --- a/tradein-mvp/backend/tests/test_team_api.py +++ b/tradein-mvp/backend/tests/test_team_api.py @@ -519,6 +519,26 @@ def test_create_employee_non_ascii_username_422(client: TestClient, store: _Stor assert resp.status_code == 422 +@pytest.mark.parametrize("username", ["admin\n", "user1\n"]) +def test_create_employee_trailing_newline_username_422_not_500( + client: TestClient, store: _Store, username: str +) -> None: + """Deep-review seed #2564: Python `$` matches BEFORE a trailing newline + (`re.match(r'...\\$', 'admin\\n')` → True), but Postgres `~` (CHECK + tradein_users_username_ascii_ck, migration 193) does NOT — a username with a + trailing "\\n" used to pass Pydantic validation and crash in the DB (500) + instead of a clean 422. `_USERNAME_RE` now uses `\\Z`, matching Postgres `~` + semantics exactly.""" + store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + _login(client, "mgr_a", "Secret123!") + + resp = client.post( + "/api/v1/team/employees", + json={"username": username, "password": "Secret123!"}, + ) + assert resp.status_code == 422, resp.text + + def test_create_employee_duplicate_username_409(client: TestClient, store: _Store) -> None: store.add_user("mgr_a", hash_password("Secret123!"), role="manager") store.add_user("emp_dup", hash_password("Secret123!"), role="employee") From 1ed0338b956e8bc0e701398081caa53828470a2a Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 30 Jul 2026 22:12:18 +0300 Subject: [PATCH 16/41] =?UTF-8?q?fix(tradein/ui):=20=D0=BF=D0=B0=D0=B3?= =?UTF-8?q?=D0=B8=D0=BD=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BD=D0=B0=20=D0=B3?= =?UTF-8?q?=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D0=B5=20=D1=81=D1=82=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=86=D1=8B=20+=20=D0=B7=D0=B0=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D1=81=20=D1=81=D0=BF=D0=B8=D1=81=D0=BA=D0=B0=20=D1=82=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=B4=D0=BB=D1=8F=20admin/manager?= =?UTF-8?q?=20(#2556)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EmployeeTable: убран ранний return при пустом списке — на offset>0 (ровно 50/100/150 сотрудников) пейджер и кнопка «Назад» теперь остаются доступны; текст различает «сотрудников вообще нет» (offset=0) и «страница за концом списка» (offset>0). team/page.tsx: useEmployees получает enabled=isAllowedRole, вычисленный ДО вызова хука — прямой заход employee/analyst/pilot на /team больше не шлёт обречённый GET до отрисовки role-gate. --- tradein-mvp/frontend/src/app/team/page.tsx | 12 +- .../src/components/team/EmployeeTable.tsx | 177 ++++++++++-------- tradein-mvp/frontend/src/lib/team-api.ts | 8 +- 3 files changed, 112 insertions(+), 85 deletions(-) diff --git a/tradein-mvp/frontend/src/app/team/page.tsx b/tradein-mvp/frontend/src/app/team/page.tsx index bf0016de..b42ef0da 100644 --- a/tradein-mvp/frontend/src/app/team/page.tsx +++ b/tradein-mvp/frontend/src/app/team/page.tsx @@ -29,8 +29,15 @@ const PAGE_LIMIT = 50; export default function TeamPage() { const meQ = useMe(); + // Вычисляем ДО 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); + const employeesQ = useEmployees(PAGE_LIMIT, offset, isAllowedRole); const [showCreateForm, setShowCreateForm] = useState(false); const [quotaEmployee, setQuotaEmployee] = useState(null); @@ -49,9 +56,6 @@ export default function TeamPage() { ); } - const role = meQ.data?.role; - const isAllowedRole = role === "admin" || role === "manager"; - if (!isAllowedRole) { return ; } diff --git a/tradein-mvp/frontend/src/components/team/EmployeeTable.tsx b/tradein-mvp/frontend/src/components/team/EmployeeTable.tsx index 62439c5d..ff2177b8 100644 --- a/tradein-mvp/frontend/src/components/team/EmployeeTable.tsx +++ b/tradein-mvp/frontend/src/components/team/EmployeeTable.tsx @@ -82,91 +82,102 @@ export function EmployeeTable({ ); } - if (employees.length === 0) { - return

Сотрудников пока нет.

; - } + const isEmpty = employees.length === 0; + // Пустая ПЕРВАЯ страница = сотрудников вообще нет (предлагаем создать). + // Пустая страница при offset>0 = перешли за границу списка (напр. ровно + // 50/100/150 сотрудников и клик «Далее» после последней полной страницы) + // — это НЕ «сотрудников нет», нужен путь назад, а не тупик без пейджера. + const isEmptyPastEnd = isEmpty && offset > 0; return ( <> - - - - - - - - - - - - - {employees.map((employee) => ( - - - - - - - - + + + ) : null} + + ))} + +
ЛогинИмяСтатусКвотаСозданДействия
{employee.username} - {employee.display_name ?? "—"} - {employee.org_name ? ` · ${employee.org_name}` : ""} - - - {employee.is_active ? "Активен" : "Заблокирован"} - - - - {formatDate(employee.created_at)} -
- - -
+

+ {rowError.message} +

+
+ )}
- - {offset + 1}–{offset + employees.length} - -
diff --git a/tradein-mvp/frontend/src/lib/team-api.ts b/tradein-mvp/frontend/src/lib/team-api.ts index 0c0e4bb0..cbe43b1a 100644 --- a/tradein-mvp/frontend/src/lib/team-api.ts +++ b/tradein-mvp/frontend/src/lib/team-api.ts @@ -81,12 +81,18 @@ 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) { +export function useEmployees(limit: number, offset: number, enabled = true) { return useQuery({ queryKey: [...EMPLOYEES_LIST_KEY, limit, offset], queryFn: () => apiFetch(`${BASE}/employees?limit=${limit}&offset=${offset}`), + enabled, staleTime: 15_000, retry: false, }); From 01bf4a91e44ee47ef4b539b35ab399c8ff617316 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 31 Jul 2026 00:05:33 +0300 Subject: [PATCH 17/41] =?UTF-8?q?feat(tradein/caddy):=20=D1=81=D0=BD=D1=8F?= =?UTF-8?q?=D1=82=D1=8C=20basic=5Fauth=20=D1=81=20/trade-in=20=E2=80=94=20?= =?UTF-8?q?=D1=81=D0=B2=D0=BE=D1=8F=20=D0=B0=D0=B2=D1=82=D0=BE=D1=80=D0=B8?= =?UTF-8?q?=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20(#2558)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Оба trade-in блока (`handle /trade-in/api/*`, `@tradein`) + связанный `@saleshare` редирект перенесены ВЫШЕ `import caddy/users.caddy.snippet` в route{} — Caddy матчит handle-блоки сверху вниз, поэтому они теперь short-circuit'ят до basic_auth-гейта (тот же механизм, что уже применён к /health, /preview/*, @uipreview). Site Finder (/, /api/*, /analytics) остаётся под basic_auth без изменений. Trade-in уже имеет собственную авторизацию (форма входа + opaque session-cookie, #2552) поверх RBAC внутри tradein-backend — Caddy basic_auth для него стал избыточным легаси-слоем. Security: после переноса плейсхолдер {http.auth.user.id} для этих блоков никогда не резолвится (basic_auth больше не выполняется на пути к ним). Вместо `header_up X-Authenticated-User {http.auth.user.id}` (Set-операция с пустым значением — недокументированно надёжна как удаление заголовка) используется явное `header_up -X-Authenticated-User` (документированная delete-семантика `-`). Это исключает проход клиентского X-Authenticated-User насквозь к tradein-backend, который в auth_mode="dual" всё ещё принимает legacy trusted-header путь — без явного удаления внешний запрос с подделанным заголовком мог бы выдать себя за другого пользователя. X-Internal-Auth-Secret не тронут (#2213, всегда перезаписывается из env). Провалидировано: `caddy validate --config Caddyfile --adapter caddyfile` (caddy:2 образ, локальный docker) → "Valid configuration". --- Caddyfile | 61 ++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 47 insertions(+), 14 deletions(-) diff --git a/Caddyfile b/Caddyfile index 7e7ce21a..c29dad19 100644 --- a/Caddyfile +++ b/Caddyfile @@ -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 @@ -73,23 +80,41 @@ gendsgn.ru { reverse_proxy tradein-frontend:3000 } - # 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}` + # никогда не резолвится Caddy'ем (плейсхолдер пуст) — а поведение + # `header_up ` при пустом value НЕ задокументировано как + # надёжное удаление заголовка (это Set-операция; нет гарантии, что Caddy + # трактует пустую строку как "не отправлять поле" во всех версиях). + # Полагаться на это было бы небезопасно: если бы Caddy просто НЕ + # устанавливал заголовок (leave as-is) при пустом value, то ЛЮБОЙ + # клиентский `X-Authenticated-User: someone` прошёл бы НАСКВОЗЬ до + # tradein-backend — а backend в auth_mode="dual" (`app/core/config.py`) + # всё ещё принимает legacy trusted-header путь (`rbac.py:186`) → внешняя + # подмена личности без единого валидного пароля. `-Field` — единственная + # ФОРМА С ЯВНО ЗАДОКУМЕНТИРОВАННОЙ семантикой "удалить заголовок" + # (Caddyfile reverse_proxy directive: `-` = delete), поэтому + # используем её вместо 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 +123,10 @@ 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, который теперь публичен без basic_auth; оставлять + # короткий /sale-share под basic_auth, а канонический адрес назначения без + # него — несогласованно (короткий URL был бы строже целевой страницы). @saleshare path /sale-share /sale-share/ handle @saleshare { redir /trade-in/sale-share permanent @@ -110,13 +139,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} From a3628843c3b6b6a84de2aadd7111eaf9a8c47900 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 31 Jul 2026 00:13:46 +0300 Subject: [PATCH 18/41] =?UTF-8?q?fix(tradein/ui):=20=D0=B2=D1=85=D0=BE?= =?UTF-8?q?=D0=B4=20=D0=B2=20=C2=AB=D0=9A=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4?= =?UTF-8?q?=D1=83=C2=BB=20=D0=B8=D0=B7=20v2-=D0=BD=D0=B0=D0=B2=D0=B8=D0=B3?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B8=20+=20=D0=BF=D0=BE=D0=BD=D1=8F=D1=82?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20401=20(#2556)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/trade-in/` редиректит на `/trade-in/v2`, а v2-навигация (TopNav.tsx) не знала про team-дашборд вообще — пункт «Команда» был только в legacy Topbar.tsx (NAV_ITEMS), который на v2-страницах не рендерится. Дашборд существовал, но был недостижим кликом. - app/v2/page.tsx: showTeamNavItem — тот же двойной гейт, что и Topbar.NAV_ITEMS "team" (isPathAllowed(/api/v1/team) + роль admin/manager), передаётся в TopNav. - v2/TopNav.tsx: пункт «Команда» в user-меню рядом с «Выйти» (не таб SectionOverlay — /team отдельный роут, а не секция текущей страницы). Второй дефект: team-API требует session-cookie, легаси Caddy-роль (/me 200) через него не проходит → GET /api/v1/team/employees отдаёт 401, UI показывал невнятную красную плашку. app/team/page.tsx теперь ловит 401 отдельно от 403 и уводит на /login?next=/team (тот же redirect-паттерн, что RouteGuard.tsx для 401 от /me; в dev — сообщение с кнопкой «Войти» вместо авто-редиректа, тот же NODE_ENV-гейт что и в RouteGuard). --- tradein-mvp/frontend/src/app/team/page.tsx | 55 ++++++++++++++++++- tradein-mvp/frontend/src/app/v2/page.tsx | 17 ++++++ .../src/components/trade-in/v2/TopNav.tsx | 49 +++++++++++++++++ 3 files changed, 120 insertions(+), 1 deletion(-) diff --git a/tradein-mvp/frontend/src/app/team/page.tsx b/tradein-mvp/frontend/src/app/team/page.tsx index b42ef0da..d425cc6b 100644 --- a/tradein-mvp/frontend/src/app/team/page.tsx +++ b/tradein-mvp/frontend/src/app/team/page.tsx @@ -9,9 +9,19 @@ * доом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 { useState } from "react"; +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; import "@/components/trade-in/trade-in.css"; import { NoAccessScreen } from "@/components/auth/NoAccessScreen"; @@ -26,9 +36,11 @@ 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. Порядок хуков не меняется — это просто @@ -43,6 +55,23 @@ export default function TeamPage() { const [quotaEmployee, setQuotaEmployee] = useState(null); const [historyEmployee, setHistoryEmployee] = useState(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) { @@ -60,6 +89,30 @@ export default function TeamPage() { return ; } + if (sessionRequired) { + // Prod: редирект уже запущен эффектом выше — не мигаем error-текстом, + // пока навигация не завершится (тот же приём, что RouteGuard.tsx). + if (process.env.NODE_ENV === "production") return null; + return ( + <> + +
+

Команда

+

+ Войдите в систему, чтобы управлять командой. +

+ + Войти + +
+ + ); + } + // Backend 403 регардлесс роли фронта (напр. session истекла между /me и // /employees) — тот же fallback, что и в admin/audit. if (employeesQ.error instanceof HTTPError && employeesQ.error.status === 403) { diff --git a/tradein-mvp/frontend/src/app/v2/page.tsx b/tradein-mvp/frontend/src/app/v2/page.tsx index 7979ac71..fa443482 100644 --- a/tradein-mvp/frontend/src/app/v2/page.tsx +++ b/tradein-mvp/frontend/src/app/v2/page.tsx @@ -53,6 +53,7 @@ import type { TradeInEstimateInput, } from "@/types/trade-in"; import { HTTPError } from "@/lib/api"; +import { isPathAllowed } from "@/lib/isPathAllowed"; import { useEstimate, useEstimateHistory, @@ -666,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). @@ -978,6 +994,7 @@ export default function TradeInV2Page() { reports={reportsCount ?? 0} user={topNavUser} onLogout={() => logoutMutation.mutate()} + showTeamLink={showTeamNavItem} />
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 ( + + ); +} + // Профиль / Настройки 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({ Помощь + {/* «Команда» (#2556, эпик #2549) — team-дашборд (`/team`) отдельный + роут, не таб SectionOverlay, поэтому это ссылка в user-меню + рядом с «Выйти», а не пункт в tnav-tab-ряду выше: v2-табы — + это разделы ОДНОЙ страницы (переключают `active`/onNavigate), + а «Команда» уводит на другой URL. Гейт (showTeamLink) считает + страница из useMe() — здесь только рендер. */} + {showTeamLink && ( + setUserOpen(false)} + > + + Команда + + )} +
Date: Fri, 31 Jul 2026 00:19:26 +0300 Subject: [PATCH 19/41] =?UTF-8?q?docs(tradein/caddy):=20=D0=B8=D1=81=D0=BF?= =?UTF-8?q?=D1=80=D0=B0=D0=B2=D0=B8=D1=82=D1=8C=20=D0=BE=D0=B1=D0=BE=D1=81?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20scrub=20+=20scrub?= =?UTF-8?q?=20=D0=B2=20ui-preview=20(#2558)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deep-review PR #2569 (echo-стенд на caddy:2, caddy adapt): комментарий у /trade-in/api* и @tradein утверждал, что старая Set-форма (header_up X-Authenticated-User {http.auth.user.id}) пропустила бы клиентский заголовок насквозь — измерено, что это неверно. Caddy подставляет неразрешённый плейсхолдер как литеральную строку (ReplaceKnown), т.е. старая форма была бы fail-closed-but-сломанной (get_role("{http.auth.user.id}") -> 403 для всех), а не bypass. Решение (-Field вместо Set) остаётся правильным по другой причине: это единственная форма с документированной delete- семантикой, не зависящая от трактовки пустого/нерезолвленного плейсхолдера. Также: @uipreview (единственный блок в trade-in-периметре без scrub) теперь тоже явно удаляет X-Authenticated-User - единообразие периметра, хоть сейчас и инертно (статичная страница без секрета). И: комментарий у @saleshare уточнён - снятие Caddy basic_auth с /trade-in/sale-share не делает страницу публичной, она по-прежнему за RouteGuard + session-required API (редирект на /login без валидной сессии). Провалидировано: caddy validate --config Caddyfile --adapter caddyfile (caddy:2, локальный docker) -> "Valid configuration". --- Caddyfile | 52 +++++++++++++++++++++++++++++++++++----------------- 1 file changed, 35 insertions(+), 17 deletions(-) diff --git a/Caddyfile b/Caddyfile index c29dad19..84f137de 100644 --- a/Caddyfile +++ b/Caddyfile @@ -77,7 +77,15 @@ 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 + } } # #2558: Trade-In MVP subproject (tradein-mvp/) — gendesign-tradein docker @@ -91,19 +99,23 @@ gendsgn.ru { # X-Authenticated-User — ЯВНОЕ УДАЛЕНИЕ (`header_up -X-Authenticated-User`), # НЕ `header_up X-Authenticated-User {http.auth.user.id}`. Причина: этот # блок больше не идёт ПОСЛЕ basic_auth, поэтому `{http.auth.user.id}` - # никогда не резолвится Caddy'ем (плейсхолдер пуст) — а поведение - # `header_up ` при пустом value НЕ задокументировано как - # надёжное удаление заголовка (это Set-операция; нет гарантии, что Caddy - # трактует пустую строку как "не отправлять поле" во всех версиях). - # Полагаться на это было бы небезопасно: если бы Caddy просто НЕ - # устанавливал заголовок (leave as-is) при пустом value, то ЛЮБОЙ - # клиентский `X-Authenticated-User: someone` прошёл бы НАСКВОЗЬ до - # tradein-backend — а backend в auth_mode="dual" (`app/core/config.py`) - # всё ещё принимает legacy trusted-header путь (`rbac.py:186`) → внешняя - # подмена личности без единого валидного пароля. `-Field` — единственная - # ФОРМА С ЯВНО ЗАДОКУМЕНТИРОВАННОЙ семантикой "удалить заголовок" - # (Caddyfile reverse_proxy directive: `-` = delete), поэтому - # используем её вместо Set с пустым значением — гарантия, а не догадка. + # никогда не резолвится авторизованным юзером на этом пути. + # Проверено эмпирически (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: `-` = + # delete) — корректное поведение не должно зависеть от того, как именно + # Caddy трактует нерезолвленный/пустой плейсхолдер в Set-операции. # X-Internal-Auth-Secret НЕ трогаем — #2213-секрет всегда перезаписывается # из env (Set-операция с непустым значением, никак не связана с auth-гейтом # basic_auth), это единственное, что теперь отсекает подделку заголовков @@ -124,9 +136,15 @@ gendsgn.ru { # (тот же tradein-frontend контейнер; query-string сохраняется). True vanity-URL # в адресной строке требует отдельного Next-app с basePath=/sale-share. # #2558: перенесён ВЫШЕ auth-import вместе с trade-in — редирект ведёт на - # /trade-in/sale-share, который теперь публичен без basic_auth; оставлять - # короткий /sale-share под basic_auth, а канонический адрес назначения без - # него — несогласованно (короткий URL был бы строже целевой страницы). + # /trade-in/sale-share, для которого теперь нет Caddy basic_auth (как и + # для остального /trade-in). Это НЕ делает страницу публичной: она всё + # ещё за собственной авторизацией trade-in — `RouteGuard` во фронте + # (`app/layout.tsx`, «Доступ: pilot + admin») и сессия для + # `/api/v1/buildings/sale-share*` на бэке; без валидной сессии юзер + # получит редирект на /login, а не контент. Смысл переноса — не открыть + # страницу всем, а убрать несогласованность: короткий URL не должен быть + # строже (Caddy basic_auth) целевого адреса, к которому и так уже нет + # basic_auth-барьера (только собственный login trade-in). @saleshare path /sale-share /sale-share/ handle @saleshare { redir /trade-in/sale-share permanent From 5817e0b31aa36ea0cd75630c9d7253728879c6ab Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 31 Jul 2026 15:51:50 +0300 Subject: [PATCH 20/41] =?UTF-8?q?fix(tradein/team):=20admin=20=D1=83=D0=BF?= =?UTF-8?q?=D1=80=D0=B0=D0=B2=D0=BB=D1=8F=D0=B5=D1=82=20=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D0=B5=D0=B4=D0=B6=D0=B5=D1=80=D0=B0=D0=BC=D0=B8=20=E2=80=94=20?= =?UTF-8?q?=D1=81=D0=B1=D1=80=D0=BE=D1=81=20=D0=BF=D0=B0=D1=80=D0=BE=D0=BB?= =?UTF-8?q?=D1=8F=20=D0=B8=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=B8=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=BA=D0=B0=20(#2556)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit После cutover'а на DB-auth (#2558) аккаунты `kopylov` и `praktika` живут с `role='manager'`, а team-API жёстко фильтровал `role='employee'` — сбросить менеджеру пароль или заблокировать его было НЕЧЕМ, кроме ручного psql на проде. Всплыло 2026-07-31: «Практика» весь день билась в логин (5 failed, 0 успешных), а восстановить доступ через UI админ не мог. Что меняется: - `_fetch_employee_row` берёт actor: admin → `role IN ('employee','manager')`, manager → по-прежнему только `role='employee'` + свои по `manager_id`. - `GET /employees` без фильтра отдаёт admin'у и менеджеров (`?manager_id=` — без изменений, только сотрудники этого менеджера). - `EmployeeOut.role` — новое поле, UI показывает бейдж «менеджер» и склоняет тексты («Заблокировать менеджера ...» вместо «сотрудника»). Инвариант self-lockout сохранён и усилен тестом: строки `role='admin'` недостижимы через этот роутер ни для кого, включая самого админа, поэтому ни block, ни смена пароля с `revoke_user_sessions` не могут вырубить действующего админа. Раздача роли admin остаётся вне API. Тесты: 6 новых (список с менеджерами, сброс пароля менеджеру + отзыв сессий, блокировка, manager не достаёт до чужого менеджера, admin не достаёт до admin-строки), 3 существующих обновлены под новое ожидание списка. --- tradein-mvp/backend/app/api/v1/team.py | 98 +++++++++--- tradein-mvp/backend/app/schemas/team.py | 10 +- tradein-mvp/backend/tests/test_team_api.py | 146 ++++++++++++++++-- .../src/components/team/EmployeeTable.tsx | 23 ++- .../src/components/team/QuotaEditor.tsx | 9 +- .../src/components/trade-in/trade-in.css | 13 ++ tradein-mvp/frontend/src/lib/team-api.ts | 7 + 7 files changed, 265 insertions(+), 41 deletions(-) diff --git a/tradein-mvp/backend/app/api/v1/team.py b/tradein-mvp/backend/app/api/v1/team.py index e3e30ffc..592f4f13 100644 --- a/tradein-mvp/backend/app/api/v1/team.py +++ b/tradein-mvp/backend/app/api/v1/team.py @@ -18,6 +18,25 @@ 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 @@ -140,22 +159,40 @@ def _require_same_origin(request: Request) -> None: # --------------------------------------------------------------------------- -def _fetch_employee_row(db: Session, employee_id: int) -> RowMapping | None: - return ( - db.execute( - text( - """ - SELECT id, username, display_name, org_name, email, is_active, - manager_id, created_at - FROM tradein_users - WHERE id = :id AND role = 'employee' - """ - ), - {"id": employee_id}, - ) - .mappings() - .fetchone() - ) +# Два статических варианта — НЕ динамическая сборка 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: @@ -163,6 +200,11 @@ def _authorize_employee(actor: TeamActor, row: RowMapping | None) -> RowMapping: 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") @@ -304,6 +346,7 @@ 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"], @@ -374,7 +417,7 @@ async def create_employee( VALUES (:username, :password_hash, 'employee', :manager_id, :display_name, :org_name, :email, true) - RETURNING id, username, display_name, org_name, email, is_active, + RETURNING id, username, role, display_name, org_name, email, is_active, manager_id, created_at """ ), @@ -441,7 +484,7 @@ async def update_employee( долго, а не «до TTL». `revoke_user_sessions` сам называет смену пароля своим use-case — см. его докстринг. """ - row = _fetch_employee_row(db, employee_id) + row = _fetch_employee_row(db, employee_id, actor) row = _authorize_employee(actor, row) new_password_hash: str | None = None @@ -481,7 +524,8 @@ async def update_employee( # Обязательно ПОСЛЕ UPDATE, ДО финального commit — revoke_user_sessions # коммитит сам (см. app.services.auth_session), это флашит и наш # предшествующий UPDATE/quota-upsert в той же сессии. Self-lockout - # невозможен: _fetch_employee_row фильтрует role='employee', actor + # невозможен: _fetch_employee_row не отдаёт строки с role='admin' + # НИКОМУ, а manager'у — ещё и только role='employee'; т.е. actor # (admin|manager) никогда не может патчить сам себя через этот роут. revoke_user_sessions(db, employee_id) @@ -529,7 +573,7 @@ async def update_employee( }, ) - updated_row = _fetch_employee_row(db, employee_id) + 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) @@ -554,7 +598,7 @@ async def update_employee( # намерение DESC-сортировки по времени). _LIST_EMPLOYEES_BY_MANAGER_SQL = text( """ - SELECT id, username, display_name, org_name, email, is_active, manager_id, created_at + 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 @@ -562,11 +606,17 @@ _LIST_EMPLOYEES_BY_MANAGER_SQL = text( """ ) +# Admin-ветка: сюда попадают И менеджеры (см. модульный docstring — иначе admin +# не видит в UI строку, которой должен уметь сбросить пароль). `role='admin'` +# по-прежнему невидим и неуправляем. Сортировка по (created_at, id) общая для +# обеих ролей — намеренно: seed (#2557) вставил всех одной транзакцией, так что +# группировка «сначала менеджеры» дала бы ложное ощущение иерархии там, где её +# в данных нет; роль показывается колонкой (`EmployeeOut.role`). _LIST_EMPLOYEES_ALL_SQL = text( """ - SELECT id, username, display_name, org_name, email, is_active, manager_id, created_at + SELECT id, username, role, display_name, org_name, email, is_active, manager_id, created_at FROM tradein_users - WHERE role = 'employee' + WHERE role IN ('employee', 'manager') ORDER BY created_at DESC, id DESC LIMIT :limit OFFSET :offset """ @@ -631,7 +681,7 @@ async def employee_history( Та же org-проверка что и в PATCH: чужой employee_id → 404. """ - row = _fetch_employee_row(db, employee_id) + row = _fetch_employee_row(db, employee_id, actor) row = _authorize_employee(actor, row) rows = ( diff --git a/tradein-mvp/backend/app/schemas/team.py b/tradein-mvp/backend/app/schemas/team.py index fe037389..6ed84249 100644 --- a/tradein-mvp/backend/app/schemas/team.py +++ b/tradein-mvp/backend/app/schemas/team.py @@ -1,7 +1,8 @@ """Pydantic-схемы team-management API (#2554, эпик #2549). -CRUD сотрудников (`tradein_users.role = 'employee'`), квоты, история оценок. -Org-изоляция (manager видит/меняет только своих employee) реализована в +CRUD управляемых аккаунтов (`tradein_users.role IN ('employee','manager')` — +manager'ы доступны только actor'у-admin, см. `app.api.v1.team`), квоты, история +оценок. Org-изоляция (manager видит/меняет только своих employee) реализована в `app.api.v1.team`, эти схемы — только форма запросов/ответов. """ @@ -9,6 +10,7 @@ from __future__ import annotations import re from datetime import datetime +from typing import Literal from pydantic import BaseModel, ConfigDict, Field, field_validator @@ -79,6 +81,10 @@ class EmployeeOut(BaseModel): 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 diff --git a/tradein-mvp/backend/tests/test_team_api.py b/tradein-mvp/backend/tests/test_team_api.py index 2f1e40ce..3487e2c3 100644 --- a/tradein-mvp/backend/tests/test_team_api.py +++ b/tradein-mvp/backend/tests/test_team_api.py @@ -255,9 +255,19 @@ class _FakeDB: match = user is not None and user["role"] == "manager" return _Result([{"id": user["id"]}] if match else []) - # ---- tradein_users: list employees (has explicit ORDER BY) ---- - if "role = 'employee'" in sql and "ORDER BY created_at DESC" in sql: - rows = [u for u in s.users.values() if u["role"] == "employee"] + # ---- tradein_users: list managed rows (has explicit ORDER BY) ---- + # Две ветки реального кода: `role = 'employee'` (manager, либо admin с + # ?manager_id=) и `role IN ('employee','manager')` (admin без фильтра — + # ему нужны и менеджеры, иначе некому сбросить пароль, см. team.py). + if ("role = 'employee'" in sql or "role IN ('employee', 'manager')" in sql) and ( + "ORDER BY created_at DESC" in sql + ): + managed = ( + ("employee", "manager") + if "role IN ('employee', 'manager')" in sql + else ("employee",) + ) + rows = [u for u in s.users.values() if u["role"] in managed] if "manager_id" in p: rows = [u for u in rows if u["manager_id"] == p["manager_id"]] # Mirrors real SQL `ORDER BY created_at DESC, id DESC` — `id` tiebreak @@ -271,6 +281,7 @@ class _FakeDB: { "id": u["id"], "username": u["username"], + "role": u["role"], "display_name": u["display_name"], "org_name": u["org_name"], "email": u["email"], @@ -282,16 +293,22 @@ class _FakeDB: ] ) - # ---- tradein_users: fetch single employee by id ---- - if "role = 'employee'" in sql: + # ---- tradein_users: fetch single managed row by id ---- + if "role = 'employee'" in sql or "role IN ('employee', 'manager')" in sql: + managed = ( + ("employee", "manager") + if "role IN ('employee', 'manager')" in sql + else ("employee",) + ) user = s.user_by_id(p["id"]) - if user is None or user["role"] != "employee": + if user is None or user["role"] not in managed: return _Result([]) return _Result( [ { "id": user["id"], "username": user["username"], + "role": user["role"], "display_name": user["display_name"], "org_name": user["org_name"], "email": user["email"], @@ -577,6 +594,112 @@ def test_employee_role_403_on_all_team_routes(client: TestClient, store: _Store) assert client.get("/api/v1/team/employees/1/history").status_code == 403 +# --------------------------------------------------------------------------- +# Admin управляет менеджерами (инцидент 2026-07-31: kopylov/praktika — role +# 'manager', сбросить им пароль через UI было нечем) +# --------------------------------------------------------------------------- + + +def test_admin_list_includes_managers(client: TestClient, store: _Store) -> None: + store.add_user("admin1", hash_password("Secret123!"), role="admin") + mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + store.add_user("emp_a", hash_password("Secret123!"), role="employee", manager_id=mgr_id) + + _login(client, "admin1", "Secret123!") + resp = client.get("/api/v1/team/employees") + assert resp.status_code == 200 + by_username = {e["username"]: e for e in resp.json()} + # Менеджер виден; сам admin — нет (role='admin' не отдаётся никогда). + assert set(by_username) == {"mgr_a", "emp_a"} + assert by_username["mgr_a"]["role"] == "manager" + assert by_username["emp_a"]["role"] == "employee" + + +def test_admin_resets_manager_password_and_revokes_sessions( + client: TestClient, store: _Store +) -> None: + """Главный сценарий инцидента: admin выдаёт менеджеру новый пароль.""" + store.add_user("admin1", hash_password("Secret123!"), role="admin") + mgr_id = store.add_user("mgr_a", hash_password("OldSecret1!"), role="manager") + + # У менеджера есть живая сессия — после сброса она обязана умереть. + _login(client, "mgr_a", "OldSecret1!") + assert any(sess["user_id"] == mgr_id for sess in store.sessions.values()) + client.cookies.clear() + + _login(client, "admin1", "Secret123!") + resp = client.patch(f"/api/v1/team/employees/{mgr_id}", json={"new_password": "NewSecret1!"}) + assert resp.status_code == 200 + assert resp.json()["role"] == "manager" + assert not any(sess["user_id"] == mgr_id for sess in store.sessions.values()) + + # Новый пароль реально работает, старый — нет. + client.cookies.clear() + assert ( + client.post( + "/api/v1/auth/login", json={"username": "mgr_a", "password": "OldSecret1!"} + ).status_code + == 401 + ) + assert ( + client.post( + "/api/v1/auth/login", json={"username": "mgr_a", "password": "NewSecret1!"} + ).status_code + == 200 + ) + + +def test_admin_blocks_manager(client: TestClient, store: _Store) -> None: + store.add_user("admin1", hash_password("Secret123!"), role="admin") + mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + + _login(client, "admin1", "Secret123!") + resp = client.patch(f"/api/v1/team/employees/{mgr_id}", json={"is_active": False}) + assert resp.status_code == 200 + assert resp.json()["is_active"] is False + + +def test_manager_cannot_patch_another_manager_404(client: TestClient, store: _Store) -> None: + """Расширение прав дано ТОЛЬКО admin'у — manager до чужой строки не достаёт.""" + store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + mgr_b_id = store.add_user("mgr_b", hash_password("Secret123!"), role="manager") + + _login(client, "mgr_a", "Secret123!") + resp = client.patch(f"/api/v1/team/employees/{mgr_b_id}", json={"new_password": "Hacked123!"}) + assert resp.status_code == 404 + # И в списке чужого менеджера тоже нет. + assert client.get("/api/v1/team/employees").json() == [] + + +def test_admin_cannot_patch_admin_row_404(client: TestClient, store: _Store) -> None: + """Инвариант отсутствия self-lockout: строки role='admin' недостижимы.""" + admin_id = store.add_user("admin1", hash_password("Secret123!"), role="admin") + other_admin_id = store.add_user("admin2", hash_password("Secret123!"), role="admin") + + _login(client, "admin1", "Secret123!") + assert ( + client.patch(f"/api/v1/team/employees/{admin_id}", json={"is_active": False}).status_code + == 404 + ) + assert ( + client.patch( + f"/api/v1/team/employees/{other_admin_id}", json={"new_password": "Nope12345!"} + ).status_code + == 404 + ) + + +def test_manager_cannot_see_managers_in_own_list(client: TestClient, store: _Store) -> None: + mgr_a = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + store.add_user("mgr_b", hash_password("Secret123!"), role="manager") + store.add_user("emp_a", hash_password("Secret123!"), role="employee", manager_id=mgr_a) + + _login(client, "mgr_a", "Secret123!") + rows = client.get("/api/v1/team/employees").json() + assert {e["username"] for e in rows} == {"emp_a"} + assert {e["role"] for e in rows} == {"employee"} + + # --------------------------------------------------------------------------- # Org isolation — manager A vs manager B # --------------------------------------------------------------------------- @@ -766,8 +889,11 @@ def test_admin_sees_all_employees_and_filters_by_manager(client: TestClient, sto _login(client, "admin1", "Secret123!") resp_all = client.get("/api/v1/team/employees") assert resp_all.status_code == 200 - assert {e["username"] for e in resp_all.json()} == {"emp_a", "emp_b"} + # Без фильтра admin видит и менеджеров — иначе им нечем сбросить пароль + # (инцидент 2026-07-31). Сам admin в выдачу не попадает. + assert {e["username"] for e in resp_all.json()} == {"emp_a", "emp_b", "mgr_a", "mgr_b"} + # ?manager_id= — по-прежнему ТОЛЬКО сотрудники этого менеджера. resp_filtered = client.get("/api/v1/team/employees", params={"manager_id": mgr_a}) assert resp_filtered.status_code == 200 assert {e["username"] for e in resp_filtered.json()} == {"emp_a"} @@ -786,7 +912,7 @@ def test_list_employees_query_count_is_not_n_plus_1(client: TestClient, store: _ store.query_count = 0 # reset after login's own DB traffic resp = client.get("/api/v1/team/employees") assert resp.status_code == 200 - assert len(resp.json()) == 10 + assert len(resp.json()) == 11 # 10 сотрудников + mgr_a (admin видит менеджеров) # rbac_guard session lookup (1) + current_team_actor session lookup (1) + # list query (1) + 2 batch quota queries = 5, flat regardless of N. assert store.query_count <= 5, f"expected O(1) queries for N=10, got {store.query_count}" @@ -824,7 +950,9 @@ def test_list_employees_pagination_stable_with_identical_created_at( store.add_user("admin1", hash_password("Secret123!"), role="admin") mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") same_ts = datetime.now(UTC) - expected_usernames = set() + # Менеджер тоже в выдаче admin'а (см. test_admin_list_includes_managers) — + # он такая же строка для пейджинга, тай-брейкер обязан покрывать и её. + expected_usernames = {"mgr_a"} for i in range(5): username = f"emp_tie_{i}" store.add_user( diff --git a/tradein-mvp/frontend/src/components/team/EmployeeTable.tsx b/tradein-mvp/frontend/src/components/team/EmployeeTable.tsx index ff2177b8..1b454fb3 100644 --- a/tradein-mvp/frontend/src/components/team/EmployeeTable.tsx +++ b/tradein-mvp/frontend/src/components/team/EmployeeTable.tsx @@ -27,6 +27,15 @@ function formatDate(iso: string): string { } } +/** + * Винительный падеж для confirm-текста: список admin'а содержит и менеджеров + * (см. `Employee.role`), а «Заблокировать сотрудника «praktika»?» про владельца + * организации — прямая дезинформация в самом опасном диалоге дашборда. + */ +function roleAccusative(role: Employee["role"]): string { + return role === "manager" ? "менеджера" : "сотрудника"; +} + function QuotaCell({ quota }: { quota: Employee["quota"] }) { if (quota.unlimited) { return без лимита; @@ -64,10 +73,11 @@ export function EmployeeTable({ function handleToggleActive(employee: Employee) { const nextActive = !employee.is_active; + const who = roleAccusative(employee.role); const confirmed = window.confirm( nextActive - ? `Разблокировать сотрудника «${employee.username}»?` - : `Заблокировать сотрудника «${employee.username}»? Все его текущие сессии будут завершены.`, + ? `Разблокировать ${who} «${employee.username}»?` + : `Заблокировать ${who} «${employee.username}»? Все его текущие сессии будут завершены.`, ); if (!confirmed) return; @@ -113,7 +123,14 @@ export function EmployeeTable({ {employees.map((employee) => ( - {employee.username} + + {employee.username} + {employee.role === "manager" ? ( + + менеджер + + ) : null} + {employee.display_name ?? "—"} {employee.org_name ? ` · ${employee.org_name}` : ""} diff --git a/tradein-mvp/frontend/src/components/team/QuotaEditor.tsx b/tradein-mvp/frontend/src/components/team/QuotaEditor.tsx index 3eabd3eb..94b2db38 100644 --- a/tradein-mvp/frontend/src/components/team/QuotaEditor.tsx +++ b/tradein-mvp/frontend/src/components/team/QuotaEditor.tsx @@ -29,6 +29,9 @@ export function QuotaEditor({ employee, onClose }: QuotaEditorProps) { ); const [newPassword, setNewPassword] = useState(""); const [showPassword, setShowPassword] = useState(false); + // Список admin'а смешанный (сотрудники + менеджеры организаций) — «Изменить + // сотрудника» над карточкой владельца организации вводит в заблуждение. + const who = employee.role === "manager" ? "менеджера" : "сотрудника"; const updateMutation = useUpdateEmployee(); @@ -67,11 +70,11 @@ export function QuotaEditor({ employee, onClose }: QuotaEditorProps) { className="team-modal" role="dialog" aria-modal="true" - aria-label={`Изменить сотрудника ${employee.username}`} + aria-label={`Изменить ${who} ${employee.username}`} onClick={(e) => e.stopPropagation()} onSubmit={handleSubmit} > -

Изменить сотрудника

+

Изменить {who}

{employee.username}

{updateMutation.isError ? ( @@ -121,7 +124,7 @@ export function QuotaEditor({ employee, onClose }: QuotaEditorProps) { {showPassword ? "Скрыть" : "Показать"}

- Смена пароля завершает все текущие сессии сотрудника. + Смена пароля завершает все текущие сессии {who}.

diff --git a/tradein-mvp/frontend/src/components/trade-in/trade-in.css b/tradein-mvp/frontend/src/components/trade-in/trade-in.css index b4bc3ec7..a7c6e9e0 100644 --- a/tradein-mvp/frontend/src/components/trade-in/trade-in.css +++ b/tradein-mvp/frontend/src/components/trade-in/trade-in.css @@ -2849,6 +2849,19 @@ html, body { overflow-x: clip; } 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; diff --git a/tradein-mvp/frontend/src/lib/team-api.ts b/tradein-mvp/frontend/src/lib/team-api.ts index cbe43b1a..3f60ec10 100644 --- a/tradein-mvp/frontend/src/lib/team-api.ts +++ b/tradein-mvp/frontend/src/lib/team-api.ts @@ -36,6 +36,13 @@ export interface QuotaStatus { 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; From 40fc94ee910d65d01e76a840c079799f241a26c9 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 31 Jul 2026 16:04:51 +0300 Subject: [PATCH 21/41] =?UTF-8?q?feat(tradein/support):=20=D1=87=D0=B0?= =?UTF-8?q?=D1=82=20=D0=BF=D0=BE=D0=B4=D0=B4=D0=B5=D1=80=D0=B6=D0=BA=D0=B8?= =?UTF-8?q?=20=D0=B1=D0=B5=D0=B7=20=D0=B2=D1=85=D0=BE=D0=B4=D0=B0=20?= =?UTF-8?q?=E2=80=94=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=20=D0=BB=D0=BE=D0=B3?= =?UTF-8?q?=D0=B8=D0=BD=D0=B0=20=D0=B8=20=C2=AB=D0=B4=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D1=83=D0=BF=D0=B0=20=D0=BD=D0=B5=D1=82=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit После cutover'а на свою авторизацию (#2558) единственным каналом в поддержку остался чат ЗА логином, а самая частая причина писать в поддержку — как раз «не могу войти». 2026-07-31 это выстрелило: «Практика» весь день билась в форму входа (5 неудачных попыток с трёх разных IP, ни одной успешной) и сообщить об этом из продукта не могла ничем — на /login не было ни чата, ни контакта. Backend — 4 ручки /api/v1/trade-in/support/anon/* (public в rbac_guard): - Идентичность анонима — opaque-токен в httpOnly+Secure куке; тред живёт в тех же web_support_threads под ключом `anon:`. Двоеточие делает коллизию с реальным логином структурно невозможной (CHECK миграции 193 разрешает только `^[A-Za-z0-9._-]{3,64}$`) — аноним не может попасть в чужой тред. - Изоляция та же, что у авторизованной ветки: thread_id снаружи не принимается ни в каком виде, тред резолвится ИСКЛЮЧИТЕЛЬНО из куки. - Форма куки валидируется — мусор из браузера не становится ключом треда. - В Telegram-топик уходит не токен (это bearer треда), а `anon-<6 hex sha256>`; зеркало помечено «[С САЙТА · БЕЗ ВХОДА]» — оператору важно, что аккаунта нет. - Анти-абуз: два бюджета — per-token (12/мин) и per-IP (10/10мин). Второй ловит обход ротацией куки, без него публичная ручка записи в общий топик беззащитна. - Кука и запись в БД — только после успешного sendMessage (порядок операций H1), неудачная отправка не закрепляет за посетителем пустой тред. Frontend: - `SupportScope = "auth" | "anon"` в useSupportChat: scope выбирает базовый путь и входит в ключ кэша (иначе после логина в панели висела бы переписка анонима). Дефолт "auth" — существующие места монтирования не меняются. - `AnonSupportWidget` монтируется на /login и в NoAccessScreen — обе точки тупики, из которых пользователю больше некуда идти. На /login добавлена подсказка. Ответы оператора маршрутизируются без изменений в bridge.py: реплай резолвится по topic_message_id → thread_id, кто автор треда — там неважно. Тесты: 13 новых на анонимную ветку + 2 на границу public/authed в rbac_guard. 62 passed (test_support + test_rbac). --- tradein-mvp/backend/app/api/v1/support.py | 233 +++++++++++++++++- tradein-mvp/backend/app/core/rbac.py | 15 ++ tradein-mvp/backend/tests/test_rbac.py | 28 +++ tradein-mvp/backend/tests/test_support.py | 221 ++++++++++++++++- tradein-mvp/frontend/src/app/login/page.tsx | 17 ++ .../src/components/auth/AnonSupportWidget.tsx | 32 +++ .../src/components/auth/NoAccessScreen.tsx | 12 + .../components/trade-in/v2/SupportButton.tsx | 16 +- .../trade-in/v2/SupportChatPanel.tsx | 16 +- .../frontend/src/lib/useSupportChat.ts | 50 ++-- 10 files changed, 604 insertions(+), 36 deletions(-) create mode 100644 tradein-mvp/frontend/src/components/auth/AnonSupportWidget.tsx diff --git a/tradein-mvp/backend/app/api/v1/support.py b/tradein-mvp/backend/app/api/v1/support.py index 0a8c1914..bc8af578 100644 --- a/tradein-mvp/backend/app/api/v1/support.py +++ b/tradein-mvp/backend/app/api/v1/support.py @@ -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:`. Двоеточие +делает коллизию с реальным логином структурно невозможной: `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() diff --git a/tradein-mvp/backend/app/core/rbac.py b/tradein-mvp/backend/app/core/rbac.py index ffaf1a04..eb9ec090 100644 --- a/tradein-mvp/backend/app/core/rbac.py +++ b/tradein-mvp/backend/app/core/rbac.py @@ -53,6 +53,15 @@ _ADMIN_API_RE = re.compile(r"^/api/v1/admin/") # 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", @@ -61,6 +70,12 @@ _PUBLIC_PATHS = frozenset( "/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) перед diff --git a/tradein-mvp/backend/tests/test_rbac.py b/tradein-mvp/backend/tests/test_rbac.py index c0648e32..6fec305c 100644 --- a/tradein-mvp/backend/tests/test_rbac.py +++ b/tradein-mvp/backend/tests/test_rbac.py @@ -73,6 +73,16 @@ def _build_test_app() -> FastAPI: 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 @@ -253,6 +263,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", diff --git a/tradein-mvp/backend/tests/test_support.py b/tradein-mvp/backend/tests/test_support.py index 4b87d446..f89370ad 100644 --- a/tradein-mvp/backend/tests/test_support.py +++ b/tradein-mvp/backend/tests/test_support.py @@ -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:`, а не логином).""" + 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 diff --git a/tradein-mvp/frontend/src/app/login/page.tsx b/tradein-mvp/frontend/src/app/login/page.tsx index b3529e83..8bc61556 100644 --- a/tradein-mvp/frontend/src/app/login/page.tsx +++ b/tradein-mvp/frontend/src/app/login/page.tsx @@ -21,6 +21,7 @@ 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; @@ -282,7 +283,23 @@ export default function LoginPage() { "Войти" )} + +

+ Не получается войти? Напишите нам — кнопка «Поддержка» в правом нижнем + углу. Отвечаем без входа в систему. +

+ + {/* Инцидент 2026-07-31: без этого тот, кто не может залогиниться, не + может и сообщить об этом — единственный чат был за логином. */} +
); } diff --git a/tradein-mvp/frontend/src/components/auth/AnonSupportWidget.tsx b/tradein-mvp/frontend/src/components/auth/AnonSupportWidget.tsx new file mode 100644 index 00000000..aa4e7423 --- /dev/null +++ b/tradein-mvp/frontend/src/components/auth/AnonSupportWidget.tsx @@ -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 ( + + + + ); +} diff --git a/tradein-mvp/frontend/src/components/auth/NoAccessScreen.tsx b/tradein-mvp/frontend/src/components/auth/NoAccessScreen.tsx index 7d13b55a..c8ab4e1f 100644 --- a/tradein-mvp/frontend/src/components/auth/NoAccessScreen.tsx +++ b/tradein-mvp/frontend/src/components/auth/NoAccessScreen.tsx @@ -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) { )}
+ + {/* Второй тупик, из которого пользователю некуда идти (первый — /login). + Анонимный scope, а не авторизованный: на variant="session"/"error" + идентити уже нет, а разное поведение чата на соседних вариантах одного + экрана — лишняя развилка на ровном месте. Инцидент 2026-07-31. */} +
); } diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/SupportButton.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/SupportButton.tsx index e3cd0b8b..9b7f7b65 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/SupportButton.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/SupportButton.tsx @@ -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() { )} - + , document.body, ); diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/SupportChatPanel.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/SupportChatPanel.tsx index dc00586a..e652e594 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/SupportChatPanel.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/SupportChatPanel.tsx @@ -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(null); const inputRef = useRef(null); const listEndRef = useRef(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 diff --git a/tradein-mvp/frontend/src/lib/useSupportChat.ts b/tradein-mvp/frontend/src/lib/useSupportChat.ts index 478f10ea..537a6370 100644 --- a/tradein-mvp/frontend/src/lib/useSupportChat.ts +++ b/tradein-mvp/frontend/src/lib/useSupportChat.ts @@ -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({ - queryKey: SUPPORT_MESSAGES_KEY, - queryFn: () => apiFetch(`${BASE}/messages?since=0`), + queryKey: messagesKey(scope), + queryFn: () => + apiFetch(`${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({ - queryKey: SUPPORT_UNREAD_KEY, - queryFn: () => apiFetch(`${BASE}/unread`), + queryKey: unreadKey(scope), + queryFn: () => apiFetch(`${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({ mutationFn: (text) => - apiFetch(`${BASE}/messages`, { + apiFetch(`${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({ mutationFn: async () => { - await apiFetch<{ status: string }>(`${BASE}/read`, { method: "POST" }); + await apiFetch<{ status: string }>(`${scopeBase(scope)}/read`, { + method: "POST", + }); }, onSuccess: () => { - queryClient.setQueryData(SUPPORT_UNREAD_KEY, { unread: 0 }); + queryClient.setQueryData(unreadKey(scope), { unread: 0 }); }, }); } From 10442d0187b4bf3836f68ea4517e38c8f5fcde72 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 31 Jul 2026 16:33:04 +0300 Subject: [PATCH 22/41] =?UTF-8?q?fix(tradein/avito):=20=D0=BE=D0=B1=D0=BE?= =?UTF-8?q?=D0=B3=D0=B0=D1=89=D0=B0=D1=82=D1=8C=20=D0=B4=D0=B5=D1=82=D0=B0?= =?UTF-8?q?=D0=BB=D1=8F=D0=BC=D0=B8=20=D0=BB=D0=B8=D1=81=D1=82=D0=B8=D0=BD?= =?UTF-8?q?=D0=B3=D0=B8=20=D0=B2=D1=81=D0=B5=D0=B9=20=D0=BE=D0=B1=D0=BB?= =?UTF-8?q?=D0=B0=D1=81=D1=82=D0=B8,=20=D0=BD=D0=B5=20=D1=82=D0=BE=D0=BB?= =?UTF-8?q?=D1=8C=D0=BA=D0=BE=20=D0=95=D0=9A=D0=91=20(#2576)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/tasks/avito_detail_backfill.py | 93 ++++++++++++++----- .../tests/tasks/test_avito_detail_backfill.py | 64 ++++++++++++- 2 files changed, 132 insertions(+), 25 deletions(-) diff --git a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py index 5e3374c9..92b5b4a8 100644 --- a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py @@ -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,16 @@ __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-городов. +_OBLAST_AVITO_URL_PATTERNS = tuple(f"%/{loc.avito_slug}/%" for loc in CITY_LOCATIONS.values()) + @dataclass class AvitoDetailBackfillResult: @@ -102,7 +113,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 +127,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 +206,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 +273,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, diff --git a/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py b/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py index eae34c4a..599e3308 100644 --- a/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py +++ b/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py @@ -14,6 +14,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 +354,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 +380,60 @@ 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).""" + 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 test_oblast_avito_url_patterns_include_oblast_and_ekb_exclude_foreign_region() -> None: + """#2576 DoD: листинг города области и екатеринбургский листинг проходят + scope-фильтр; листинг чужого региона (Москва/СПб) — нет. + + Постгресовый `LIKE '%pat%'` эквивалентен fnmatch с `%` -> `*` (сам паттерн + без иных SQL-метасимволов) — реплицируем ту же семантику локально, чтобы + проверить реальные продовые паттерны (_OBLAST_AVITO_URL_PATTERNS) без + поднятия БД (юнит-тесты этого файла её не используют).""" + import fnmatch + + def _in_scope(source_url: str) -> bool: + if fnmatch.fnmatchcase(source_url, "*/ekaterinburg/*"): + return True + return any( + fnmatch.fnmatchcase(source_url, pat.replace("%", "*")) + for pat in _OBLAST_AVITO_URL_PATTERNS + ) + + # Область (Каменск-Уральский, #2576 — реальный кейс из тикета) -- проходит. + assert _in_scope("https://www.avito.ru/kamensk-uralskiy/kvartiry/prodam_123") + # ЕКБ — по-прежнему проходит (не деградировал). + assert _in_scope("https://www.avito.ru/ekaterinburg/kvartiry/prodam_456") + # Чужой регион — НЕ проходит (иначе поехали бы Москва/СПб/Тюмень legacy-строки). + assert not _in_scope("https://www.avito.ru/moskva/kvartiry/prodam_789") + assert not _in_scope("https://www.avito.ru/sankt-peterburg/kvartiry/prodam_000") @pytest.mark.asyncio From f44ed4043cb719ce634813ebb190bad0c9aa4fe5 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 31 Jul 2026 16:33:20 +0300 Subject: [PATCH 23/41] =?UTF-8?q?feat(tradein/db):=20=D0=B0=D0=B2=D1=82?= =?UTF-8?q?=D0=BE-refresh=20=D1=86=D0=B5=D0=BD=D0=BE=D0=B2=D1=8B=D1=85=20?= =?UTF-8?q?=D0=B1=D1=8D=D0=BD=D0=B4=D0=BE=D0=B2=20=D0=BF=D0=BE=20=D0=B3?= =?UTF-8?q?=D0=BE=D1=80=D0=BE=D0=B4=D0=B0=D0=BC=20+=20=D0=BF=D0=BE=D1=80?= =?UTF-8?q?=D0=BE=D0=B3=20=D0=B4=D0=BB=D1=8F=20=D0=BC=D0=B0=D0=BB=D1=8B?= =?UTF-8?q?=D1=85=20=D0=B3=D0=BE=D1=80=D0=BE=D0=B4=D0=BE=D0=B2=20(#2576)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Миграция 178 засеяла deal_city_price_bands разово (N>=30 сделок), без scheduler'а на refresh. Город без строки падал на глобальный DEAL_MIN_PPM2=50000 (ЕКБ-калибровка) — для дешёвых городов области это не anti-outlier guard, а cut-off легитимного рынка (Североуральск median ~21.7k). Замер по прод-данным: 289 из 369 не-ЕКБ городов (1265 сделок) не имели строки и падали на ЕКБ-порог. - 194_deal_city_price_bands_tiers.sql — трёхуровневая схема (tier колонка): full (N>=30, own p1/p99, unchanged) / rough (N 10-29, own p1 floor + фикс. ceiling 800000) / region_fallback (N 1-9, pooled областной p1=15263 вместо ЕКБ-порога). Екатеринбург по-прежнему не в таблице — estimator fallback byte-identical. - 195_scrape_schedules_seed_deal_city_price_bands_refresh.sql — scrape_schedules row, окно 07:00-08:00 UTC (после rosreestr_dkp_import + asking_to_sold_ratio_refresh). - app/tasks/deal_city_price_bands_refresh.py — периодический re-derive (kit-scheduler, byte-identical 194 derivation), без DELETE (множество городов монотонно растёт). - app/services/product_handlers.py — регистрация Handler для нового source. Валидация: scratch-БД (syntax_check) в прод-контейнере, synthetic данные на границах тиров (N=9/10/29/30) + Екатеринбург/non-rosreestr/NULL exclusion, оба файла применены дважды (идемпотентность подтверждена), scratch-БД удалена. --- .../backend/app/services/product_handlers.py | 15 +- .../tasks/deal_city_price_bands_refresh.py | 161 ++++++++++++++++++ .../sql/194_deal_city_price_bands_tiers.sql | 150 ++++++++++++++++ ...les_seed_deal_city_price_bands_refresh.sql | 62 +++++++ 4 files changed, 387 insertions(+), 1 deletion(-) create mode 100644 tradein-mvp/backend/app/tasks/deal_city_price_bands_refresh.py create mode 100644 tradein-mvp/backend/data/sql/194_deal_city_price_bands_tiers.sql create mode 100644 tradein-mvp/backend/data/sql/195_scrape_schedules_seed_deal_city_price_bands_refresh.sql diff --git a/tradein-mvp/backend/app/services/product_handlers.py b/tradein-mvp/backend/app/services/product_handlers.py index d6c70869..5503852b 100644 --- a/tradein-mvp/backend/app/services/product_handlers.py +++ b/tradein-mvp/backend/app/services/product_handlers.py @@ -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"), diff --git a/tradein-mvp/backend/app/tasks/deal_city_price_bands_refresh.py b/tradein-mvp/backend/app/tasks/deal_city_price_bands_refresh.py new file mode 100644 index 00000000..5e2322f9 --- /dev/null +++ b/tradein-mvp/backend/app/tasks/deal_city_price_bands_refresh.py @@ -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 diff --git a/tradein-mvp/backend/data/sql/194_deal_city_price_bands_tiers.sql b/tradein-mvp/backend/data/sql/194_deal_city_price_bands_tiers.sql new file mode 100644 index 00000000..2a6e51ad --- /dev/null +++ b/tradein-mvp/backend/data/sql/194_deal_city_price_bands_tiers.sql @@ -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; diff --git a/tradein-mvp/backend/data/sql/195_scrape_schedules_seed_deal_city_price_bands_refresh.sql b/tradein-mvp/backend/data/sql/195_scrape_schedules_seed_deal_city_price_bands_refresh.sql new file mode 100644 index 00000000..a043fb34 --- /dev/null +++ b/tradein-mvp/backend/data/sql/195_scrape_schedules_seed_deal_city_price_bands_refresh.sql @@ -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; From 4b1a282234323dc9a2d1f26aec7ffd979f1b7404 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 31 Jul 2026 16:42:29 +0300 Subject: [PATCH 24/41] =?UTF-8?q?fix(tradein/geocoder):=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B4=D1=81=D1=82=D0=B0=D0=B2=D0=BB=D1=8F=D1=82?= =?UTF-8?q?=D1=8C=20=D0=95=D0=BA=D0=B0=D1=82=D0=B5=D1=80=D0=B8=D0=BD=D0=B1?= =?UTF-8?q?=D1=83=D1=80=D0=B3=20=D0=BC=D0=BE=D0=BB=D1=87=D0=B0=20=E2=80=94?= =?UTF-8?q?=20=D1=8F=D0=B2=D0=BD=D1=8B=D0=B9=20city=5Fhint=20(#2576)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раньше _yandex_lookup/_yandex_suggest/_nominatim_suggest молча подставляли "Екатеринбург, " в запрос, если в адресе не было маркера города/области. Житель Нижнего Тагила, вводя «Ленина, 1», получал уверенно неверную цену по екатеринбургской улице Ленина (обе улицы называются одинаково) — фронт город вообще не передаёт. - geocode()/suggest() принимают опциональный city_hint: str | None; без него внешние тиры больше НЕ подставляют город, а bias (ll/spn) смещается на всю область (OBLAST66_VIEWBOX) вместо ЕКБ-центра. Явный маркер города в адресе или city_hint сохраняют прежнее поведение (ЕКБ-путь не деградирует). - GeocodeResult.city_ambiguous — честный флаг «город определил провайдер, а не пользователь» (не эвристика на корректность), проброшен в AggregatedEstimate.target_city_ambiguous (ephemeral, не персистится). - Cache-ключ geocode_cache учитывает city_hint (address|city=...) — без hint'а формат не меняется (backward-compat), с hint'ом разные города для одного текста адреса больше не делят одну запись. - API: /api/v1/geocode/lookup, /suggest и POST /trade-in/estimate получили опциональный city_hint — контракт не ломается (default None). 23 новых теста в test_geocoder_city_hint.py; проверено что они падают (ImportError на _cache_key) на коде до фикса через git stash. --- tradein-mvp/backend/app/api/v1/geocode.py | 28 +- tradein-mvp/backend/app/schemas/trade_in.py | 12 + tradein-mvp/backend/app/services/estimator.py | 7 +- tradein-mvp/backend/app/services/geocoder.py | 219 ++++++++-- .../backend/tests/test_geocoder_city_hint.py | 413 ++++++++++++++++++ 5 files changed, 637 insertions(+), 42 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_geocoder_city_hint.py diff --git a/tradein-mvp/backend/app/api/v1/geocode.py b/tradein-mvp/backend/app/api/v1/geocode.py index d581f401..7b981887 100644 --- a/tradein-mvp/backend/app/api/v1/geocode.py +++ b/tradein-mvp/backend/app/api/v1/geocode.py @@ -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( diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index 69dee79d..c2e42560 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -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 самого свежего парсинга аналогов diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index d533bb6e..250a1ad5 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -3180,8 +3180,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 +3884,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, diff --git a/tradein-mvp/backend/app/services/geocoder.py b/tradein-mvp/backend/app/services/geocoder.py index 1bf0817f..1ec06c79 100644 --- a/tradein-mvp/backend/app/services/geocoder.py +++ b/tradein-mvp/backend/app/services/geocoder.py @@ -16,7 +16,7 @@ from __future__ import annotations import asyncio import logging import re -from dataclasses import dataclass +from dataclasses import dataclass, replace from typing import Literal import httpx @@ -38,6 +38,12 @@ class GeocodeResult: full_address: str provider: Literal["nominatim", "yandex", "cache"] confidence: Literal["exact", "approximate", "locality"] = "approximate" + # #2576: True если город НЕ был указан пользователем (ни в тексте адреса, ни + # через `city_hint`) — т.е. итоговый город результата определил провайдер + # (или локальный ЕКБ-тир), а не вызывающий код. Не эвристика на «правильность» + # результата — честный сигнал «доверяй, но проверяй», чтобы вызывающий код мог + # понизить confidence / переспросить город у пользователя. См. `_resolve_city_for_geocode`. + city_ambiguous: bool = False # ── EKB bounding boxes ─────────────────────────────────────────────────────── @@ -191,6 +197,53 @@ def _has_oblast_marker(text_lower: str) -> bool: return False +def _resolve_city_for_geocode(address: str, city_hint: str | None) -> tuple[str | None, bool]: + """Определяет, какой город подставлять в запрос внешнему провайдеру (Yandex/ + Nominatim), когда сам текст адреса города не называет. + + Приоритет: + 1. Адрес уже содержит маркер города/области региона 66 (`_has_oblast_marker`) + → город уже указан пользователем в тексте адреса, ничего подставлять не + нужно. Возвращает (None, True). + 2. `city_hint` передан вызывающим кодом (например, фронт знает выбранный + город из предыдущего шага UI) → подставляем его. Возвращает (city, True). + 3. Ни то, ни другое → раньше (#2576) здесь молча подставлялся "Екатеринбург" + — для жителей других городов области это давало уверенно неверную цену + («Ленина, 1» в Нижнем Тагиле снапалось на екатеринбургскую улицу Ленина, + обе улицы называются одинаково). Теперь НЕ подставляем никакой город — + провайдер ищет по OBLAST66 viewbox/bbox (см. `_yandex_bias`, + `OBLAST66_VIEWBOX`), без привязки к конкретному городу. Возвращает + (None, False) — второй элемент False сигнализирует, что город + пользователь НЕ указывал (источник `GeocodeResult.city_ambiguous`). + + Returns: + (city_or_none, city_specified_by_user). + """ + if _has_oblast_marker(address.lower()): + return None, True + hint = (city_hint or "").strip() + if hint: + return hint, True + return None, False + + +def _yandex_bias(address: str, city_hint: str | None) -> dict[str, str]: + """ll/spn soft-bias для Yandex Geocoder. + + ЕКБ-центр (`EKB_BBOX`) — ТОЛЬКО если контекст однозначно про Екатеринбург + (явное слово в адресе либо `city_hint`). Иначе — центр всей области + (`OBLAST66_VIEWBOX`): раньше bias всегда указывал на ЕКБ независимо от + того, назвал ли пользователь город (#2576) — молчаливый перекос в пользу + ЕКБ даже без текстового префикса "Екатеринбург, ". + """ + normalized = " ".join(address.lower().split()) + if _EKATERINBURG_RE.search(normalized): + return EKB_BBOX + if city_hint and _EKATERINBURG_RE.search(" ".join(city_hint.lower().split())): + return EKB_BBOX + return OBLAST66_VIEWBOX + + # Города региона 66 КРОМЕ Екатеринбурга — используется чтобы отсечь EKB-only # локальные тиры (geoportal/cadastral, см. `geocode()`) от адреса другого # города области. re.escape на элементах SVERDLOVSK_OBLAST_CITIES-{ekb}. @@ -240,6 +293,29 @@ def normalize_address(address: str) -> str: return " ".join(address.lower().strip().split()) +def _cache_key(address_norm: str, city_hint: str | None) -> str: + """Ключ `geocode_cache.address_normalized` — адрес, дополненный городом, + если он известен вызывающему коду. + + #2576: раньше ключ был просто нормализованный адрес — одинаковый для + «Ленина, 1» независимо от того, кто спрашивает (ЕКБ или Нижний Тагил). + Т.к. геокодер раньше молча предполагал ЕКБ, оба города писали/читали ОДНУ + и ту же строку кэша → взаимная порча (первый запрос «застолбил» город для + второго). С `city_hint` разные города для одного текста адреса больше не + делят один ключ. + + БЕЗ `city_hint` формат ключа не меняется (backward-compatible с уже + накопленным кэшем) — коллизия между городами для запросов без hint'а + остаётся возможной (структурно неизбежно, пока вызывающий код не начнёт + передавать city_hint повсеместно), но `city_ambiguous` на результате + честно сигнализирует об этом вызывающему. + """ + city_norm = " ".join((city_hint or "").lower().strip().split()) + if not city_norm: + return address_norm + return f"{address_norm}|city={city_norm}" + + # Согласные, которые часто пишут с одной буквой вместо двух (RU typos). _DOUBLE_CONSONANTS = "лнмссккттпп" @@ -451,17 +527,24 @@ def _yandex_region_ok(geo_object: dict) -> bool | None: # ── Provider: Yandex Geocoder (требует key, лучшее покрытие РФ) ───────────── @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8)) -async def _yandex_lookup(address: str, api_key: str) -> GeocodeResult | None: +async def _yandex_lookup( + address: str, api_key: str, city_hint: str | None = None +) -> GeocodeResult | None: """Yandex Geocoder — 25K req/day free для самопод, лучше РФ. Docs: https://yandex.ru/dev/maps/geocoder/doc/desc/concepts/input_params.html - Запрашиваем с ll+spn (центр ЕКБ) для приоритизации местных результатов, - но БЕЗ rspn — чтобы fuzzy matching работал при опечатках. + Запрашиваем с ll+spn (центр ЕКБ, если контекст ЕКБ, иначе центр всей + области — см. `_yandex_bias`) для приоритизации местных результатов, но + БЕЗ rspn — чтобы fuzzy matching работал при опечатках. """ - # Не навязываем "Екатеринбург, " если в адресе уже есть город/область региона 66 - # (типичный кейс из suggest, либо явный запрос по другому городу области). - geocode_query = address if _has_oblast_marker(address.lower()) else f"Екатеринбург, {address}" + # Город в запрос подставляем ТОЛЬКО если он известен (адрес уже называет + # город/область региона 66, либо явный `city_hint`) — раньше (#2576) сюда + # молча подставлялся "Екатеринбург" при отсутствии обоих, что давало + # уверенно неверную цену жителям других городов области. + city, _ = _resolve_city_for_geocode(address, city_hint) + geocode_query = f"{city}, {address}" if city else address + bias = _yandex_bias(address, city_hint) async with httpx.AsyncClient(timeout=10.0) as client: response = await client.get( "https://geocode-maps.yandex.ru/1.x/", @@ -471,8 +554,8 @@ async def _yandex_lookup(address: str, api_key: str) -> GeocodeResult | None: "format": "json", "results": 5, # берем top-5, отфильтруем по ЕКБ bbox ниже "lang": "ru_RU", - "ll": EKB_BBOX["ll"], - "spn": EKB_BBOX["spn"], + "ll": bias["ll"], + "spn": bias["spn"], }, ) response.raise_for_status() @@ -641,17 +724,27 @@ async def _dadata_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion] async def _yandex_geocode_request( - client: httpx.AsyncClient, api_key: str, query: str, limit: int, bounded: bool + client: httpx.AsyncClient, + api_key: str, + query: str, + limit: int, + bounded: bool, + bias: dict[str, str] | None = None, ) -> list[dict]: - """Single Yandex Geocoder request — bounded=True → строго в ЕКБ через rspn=1.""" + """Single Yandex Geocoder request — bounded=True → строго внутри `bias` bbox через rspn=1. + + `bias` — ll/spn (`EKB_BBOX` или `OBLAST66_VIEWBOX`). По умолчанию `EKB_BBOX` + (backward-compat для вызовов без явного bias). + """ + b = bias or EKB_BBOX params: dict[str, str] = { "apikey": api_key, "geocode": query, "format": "json", "results": str(limit), "lang": "ru_RU", - "ll": EKB_BBOX["ll"], - "spn": EKB_BBOX["spn"], + "ll": b["ll"], + "spn": b["spn"], } if bounded: params["rspn"] = "1" @@ -662,40 +755,50 @@ async def _yandex_geocode_request( @retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4)) -async def _yandex_suggest(query: str, api_key: str, limit: int = 8) -> list[GeocodeSuggestion]: +async def _yandex_suggest( + query: str, api_key: str, limit: int = 8, city_hint: str | None = None +) -> list[GeocodeSuggestion]: """Yandex Geocoder с авто-fallback на typo-tolerant режим. - Tier 1: bounded ЕКБ (rspn=1) — быстрый путь для основного (ЕКБ) трафика. - Tier 2: bounded ЕКБ на typo-variants (удвоение согласных). + Tier 1: bounded (rspn=1) — быстрый путь. Bounded на ЕКБ, если контекст + однозначно про ЕКБ (текст адреса/`city_hint`), иначе bounded на ВСЮ область + (`OBLAST66_VIEWBOX`) — раньше (#2576) Tier 1/2 всегда форсили bounded-ЕКБ + с "Екатеринбург, "-префиксом даже когда пользователь не называл город, из-за + чего автокомплит для жителей других городов области либо не находил ничего, + либо подсовывал ЕКБ-варианты вместо нужного города. + Tier 2: bounded на typo-variants (удвоение согласных), тот же bias. Tier 3: без rspn — fuzzy по всей стране, фильтр результатов по bbox области (region 66) — ловит легитимные Нижний Тагил/Серов/etc, которые Tier 1/2 - (bounded строго ЕКБ) структурно вернуть не могут. + (bounded) структурно вернуть не могут при неверном bias. """ - prefixed_query = query if _has_oblast_marker(query.lower()) else f"Екатеринбург, {query}" + city, _ = _resolve_city_for_geocode(query, city_hint) + prefixed_query = f"{city}, {query}" if city else query + bias = _yandex_bias(query, city_hint) async with httpx.AsyncClient(timeout=8.0) as client: - # Tier 1: strict bounded на оригинал (ЕКБ fast path) + # Tier 1: strict bounded на оригинал members = await _yandex_geocode_request( client, api_key, prefixed_query, limit, bounded=True, + bias=bias, ) results = _parse_yandex_members(members) if results: return results - # Tier 2: bounded на typo-варианты (тот же ЕКБ fast path) + # Tier 2: bounded на typo-варианты (тот же bias) for variant in _typo_variants(query, limit=4): - variant_query = ( - variant if _has_oblast_marker(variant.lower()) else f"Екатеринбург, {variant}" - ) + variant_city, _ = _resolve_city_for_geocode(variant, city_hint) + variant_query = f"{variant_city}, {variant}" if variant_city else variant members = await _yandex_geocode_request( client, api_key, variant_query, limit, bounded=True, + bias=bias, ) results = _parse_yandex_members(members) if results: @@ -708,6 +811,7 @@ async def _yandex_suggest(query: str, api_key: str, limit: int = 8) -> list[Geoc prefixed_query, limit, bounded=False, + bias=bias, ) results = _parse_yandex_members(members) in_oblast = [r for r in results if is_within_oblast66_bbox(r.lat, r.lon)] @@ -734,18 +838,26 @@ async def _nominatim_query_multi(client: httpx.AsyncClient, query: str, limit: i @retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4)) -async def _nominatim_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion]: +async def _nominatim_suggest( + query: str, limit: int = 8, city_hint: str | None = None +) -> list[GeocodeSuggestion]: """Nominatim в режиме suggest. С typo-fallback (для случаев когда Yandex недоступен). - Суффикс ", Екатеринбург" навязывается ТОЛЬКО если в запросе ещё нет города/области - региона 66 — иначе не режем явные запросы по другим городам области. + Суффикс города навязывается ТОЛЬКО если он известен: адрес уже называет + город/область региона 66, либо передан явный `city_hint`. Раньше (#2576) + при отсутствии обоих сюда молча подставлялся суффикс ", Екатеринбург" — + географию поиска это не расширяло/не сужало (`_nominatim_query_multi` и + так bounded=1 по ВСЕЙ области `OBLAST66_VIEWBOX`), но текстовый суффикс + смещал ранжирование Nominatim в пользу ЕКБ-совпадений даже для адресов + из других городов области. """ headers = { "User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})", "Accept": "application/json", "Accept-Language": "ru,en;q=0.8", } - suffixed_query = query if _has_oblast_marker(query.lower()) else f"{query}, Екатеринбург" + city, _ = _resolve_city_for_geocode(query, city_hint) + suffixed_query = f"{query}, {city}" if city else query async with httpx.AsyncClient(timeout=8.0, headers=headers) as client: # Tier 1: оригинальный query data = await _nominatim_query_multi(client, suffixed_query, limit) @@ -754,9 +866,8 @@ async def _nominatim_suggest(query: str, limit: int = 8) -> list[GeocodeSuggesti if not data: for variant in _typo_variants(query, limit=3): await asyncio.sleep(1.0) # Nominatim 1 req/sec - variant_query = ( - variant if _has_oblast_marker(variant.lower()) else f"{variant}, Екатеринбург" - ) + variant_city, _ = _resolve_city_for_geocode(variant, city_hint) + variant_query = f"{variant}, {variant_city}" if variant_city else variant data = await _nominatim_query_multi(client, variant_query, limit) if data: logger.info("nominatim suggest typo-fixed: %s → %s", query, variant) @@ -1086,13 +1197,20 @@ def _cadastral_reverse_sync(db: Session, lat: float, lon: float, radius_m: int = return str(row.readable_address) -async def suggest(query: str, db: Session | None = None, limit: int = 8) -> list[GeocodeSuggestion]: +async def suggest( + query: str, db: Session | None = None, limit: int = 8, city_hint: str | None = None +) -> list[GeocodeSuggestion]: """Автокомплит адресов в Свердловской области (region 66; ЕКБ — основной трафик, остаётся быстрым fast-path). Cadastral FDW → DaData → Yandex → Nominatim → []. db: если передан — cadastral lookup через gendesign_cad_buildings (первый tier). + city_hint: город, если известен вызывающему коду (#2576) — прокидывается в + Yandex/Nominatim тиры, чтобы НЕ подставлять "Екатеринбург" молча, когда + пользователь его не называл. Опционально, backward-compatible (None — + прежнее поведение минус молчаливый EKB-дефолт, см. `_resolve_city_for_geocode`). DaData /suggest (PR Q2) — token-only, 10k/день, заменяет Yandex который - заблокирован (1k/день demo limit исчерпан). + заблокирован (1k/день demo limit исчерпан). DaData region-constraint уже + охватывает всю область (не только ЕКБ) — city_hint ей не нужен. Без кэша (дешёво, провайдеры толерируют автокомплит-запросы). """ if not query or len(query.strip()) < 2: @@ -1131,7 +1249,9 @@ async def suggest(query: str, db: Session | None = None, limit: int = 8) -> list # Tier 3: Yandex (legacy — оставляем как fallback, если key есть) if settings.yandex_geocoder_api_key: try: - results = await _yandex_suggest(query, settings.yandex_geocoder_api_key, limit) + results = await _yandex_suggest( + query, settings.yandex_geocoder_api_key, limit, city_hint=city_hint + ) if results: return results except Exception: @@ -1139,33 +1259,46 @@ async def suggest(query: str, db: Session | None = None, limit: int = 8) -> list # Tier 4: Nominatim (последний fallback — OSM, без ключа) try: - return await _nominatim_suggest(query, limit) + return await _nominatim_suggest(query, limit, city_hint=city_hint) except Exception: logger.exception("nominatim suggest failed") return [] # ── Public API ─────────────────────────────────────────────────────────────── -async def geocode(address: str, db: Session) -> GeocodeResult | None: +async def geocode(address: str, db: Session, city_hint: str | None = None) -> GeocodeResult | None: """Геокодинг с кэшем. Cadastral FDW → Yandex → Nominatim → None. Args: address: пользовательский ввод (может быть грязным — нормализуем). db: сессия Postgres для cache lookup/write и cadastral FDW lookup. + city_hint: город, если известен вызывающему коду (#2576) — например + выбран пользователем на предыдущем шаге UI. Опциональный, не + ломает существующий контракт. Прокидывается в Yandex/Nominatim + внешние тиры вместо молчаливой подстановки "Екатеринбург" и + участвует в cache-ключе (см. `_cache_key`), чтобы ответы для + разных городов по одному и тому же тексту адреса не перезатирали + друг друга. Returns: GeocodeResult или None если ни один провайдер не отвечает. + `result.city_ambiguous=True`, если ни адрес, ни `city_hint` не + называли город явно — итоговый город определил провайдер/локальный + тир, а не пользователь (честный сигнал, не эвристика на корректность). """ if not address or len(address.strip()) < 3: return None - addr_norm = normalize_address(address) + _, city_specified = _resolve_city_for_geocode(address, city_hint) + city_ambiguous = not city_specified + + addr_norm = _cache_key(normalize_address(address), city_hint) # 1. Cache (sync DB-IO → offload в threadpool, чтобы не блокировать event loop) cached = await asyncio.to_thread(_cache_get, db, addr_norm) if cached is not None: logger.info("geocode cache hit: %s", addr_norm) - return cached + return replace(cached, city_ambiguous=city_ambiguous) # 2. Локальные источники по street+house (без внешнего API). parsed = _parse_street_house(address.strip()) @@ -1191,6 +1324,7 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None: full_address=hit.full_address, provider="cache", confidence="exact", + city_ambiguous=city_ambiguous, ) await asyncio.to_thread(_cache_put, db, addr_norm, result) logger.info( @@ -1215,6 +1349,7 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None: full_address=hit.full_address, provider="nominatim", # treat as "local" — same confidence as nominatim confidence="exact", + city_ambiguous=city_ambiguous, ) await asyncio.to_thread(_cache_put, db, addr_norm, result) logger.info( @@ -1238,6 +1373,7 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None: full_address=s.full_address, provider="nominatim", # treat as "local" — same confidence as nominatim confidence="exact", + city_ambiguous=city_ambiguous, ) await asyncio.to_thread(_cache_put, db, addr_norm, result) logger.info( @@ -1248,22 +1384,26 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None: # 3. Yandex (если есть key) с typo-fallback if settings.yandex_geocoder_api_key: try: - result = await _yandex_lookup(address, settings.yandex_geocoder_api_key) + result = await _yandex_lookup(address, settings.yandex_geocoder_api_key, city_hint) # Если результат вне области (region 66) — пробуем typo-варианты in_oblast = result is not None and is_within_oblast66_bbox(result.lat, result.lon) if result is not None and in_oblast: + result = replace(result, city_ambiguous=city_ambiguous) await asyncio.to_thread(_cache_put, db, addr_norm, result) logger.info("geocode yandex: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon) return result # Tier 2: typo-variants for variant in _typo_variants(address, limit=4): try: - result = await _yandex_lookup(variant, settings.yandex_geocoder_api_key) + result = await _yandex_lookup( + variant, settings.yandex_geocoder_api_key, city_hint + ) except Exception: continue if result is None: continue if is_within_oblast66_bbox(result.lat, result.lon): + result = replace(result, city_ambiguous=city_ambiguous) await asyncio.to_thread(_cache_put, db, addr_norm, result) logger.info( "geocode yandex typo-fixed: %s → %s → (%.5f, %.5f)", @@ -1280,6 +1420,7 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None: try: result = await _nominatim_lookup(address) if result is not None: + result = replace(result, city_ambiguous=city_ambiguous) await asyncio.to_thread(_cache_put, db, addr_norm, result) logger.info("geocode nominatim: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon) # Nominatim rate-limit policy: 1 req/sec — спим после успешного запроса diff --git a/tradein-mvp/backend/tests/test_geocoder_city_hint.py b/tradein-mvp/backend/tests/test_geocoder_city_hint.py new file mode 100644 index 00000000..fd2159e8 --- /dev/null +++ b/tradein-mvp/backend/tests/test_geocoder_city_hint.py @@ -0,0 +1,413 @@ +"""Тесты #2576 — geocoder больше НЕ подставляет "Екатеринбург" молча. + +Проблема (issue #2576 / эпик расширения на область): `_yandex_lookup`, +`_yandex_suggest`, `_nominatim_suggest` при отсутствии маркера города/области в +самом адресе всегда молча подставляли "Екатеринбург" — житель Нижнего Тагила, +вводя «Ленина, 1», получал уверенно неверную цену по екатеринбургской улице +Ленина (обе улицы называются одинаково). + +Покрывают: +- `_resolve_city_for_geocode` — приоритет: маркер в адресе > `city_hint` > None. +- `_yandex_lookup` — без города НЕ получает "Екатеринбург, "-префикс и bias + смещён на всю область (не форсит ЕКБ-центр); с `city_hint` — префикс из hint'а; + с явным "Екатеринбург" в адресе — поведение НЕ изменилось (как раньше). +- `_yandex_suggest` (Tier 1 bounded) — то же самое, плюс bias/rspn. +- `_nominatim_suggest` — то же самое (суффикс города, не префикс). +- `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 httpx +import pytest + +from app.services.geocoder import ( + EKB_BBOX, + OBLAST66_VIEWBOX, + GeocodeResult, + _cache_key, + _nominatim_suggest, + _resolve_city_for_geocode, + _yandex_lookup, + _yandex_suggest, + geocode, +) + +# ── _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", "нижний тагил ") + + +# ── _yandex_lookup — query string + bias ───────────────────────────────────── + +_REAL_ASYNC_CLIENT = httpx.AsyncClient + + +def _yandex_client_factory(transport: httpx.MockTransport): + def factory(*_: object, **__: object) -> httpx.AsyncClient: + return _REAL_ASYNC_CLIENT(transport=transport) + + return factory + + +def _empty_yandex_payload() -> dict: + return {"response": {"GeoObjectCollection": {"featureMember": []}}} + + +async def test_yandex_lookup_no_city_no_prefix_and_oblast_bias() -> None: + """#2576: без города в адресе/hint — Yandex-запрос БЕЗ "Екатеринбург, "-префикса, + bias смещён на всю область (не форсит ЕКБ-центр по умолчанию).""" + captured: dict[str, str | None] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["geocode"] = request.url.params.get("geocode") + captured["ll"] = request.url.params.get("ll") + captured["spn"] = request.url.params.get("spn") + return httpx.Response(200, json=_empty_yandex_payload()) + + transport = httpx.MockTransport(handler) + with patch("app.services.geocoder.httpx.AsyncClient", _yandex_client_factory(transport)): + result = await _yandex_lookup("Ленина, 1", "fake-key") + + assert result is None # пустой featureMember + assert captured["geocode"] == "Ленина, 1" + assert "Екатеринбург" not in (captured["geocode"] or "") + assert captured["ll"] == OBLAST66_VIEWBOX["ll"] + assert captured["spn"] == OBLAST66_VIEWBOX["spn"] + + +async def test_yandex_lookup_city_hint_prefix() -> None: + """city_hint="Нижний Тагил" → запрос получает префикс из hint'а, не "Екатеринбург".""" + captured: dict[str, str | None] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["geocode"] = request.url.params.get("geocode") + captured["ll"] = request.url.params.get("ll") + return httpx.Response(200, json=_empty_yandex_payload()) + + transport = httpx.MockTransport(handler) + with patch("app.services.geocoder.httpx.AsyncClient", _yandex_client_factory(transport)): + await _yandex_lookup("Ленина, 1", "fake-key", city_hint="Нижний Тагил") + + assert captured["geocode"] == "Нижний Тагил, Ленина, 1" + # Тагил — не ЕКБ-контекст → bias не форсит ЕКБ-центр. + assert captured["ll"] == OBLAST66_VIEWBOX["ll"] + + +async def test_yandex_lookup_explicit_ekaterinburg_unchanged() -> None: + """Явное "Екатеринбург" в адресе → поведение НЕ изменилось (как до фикса).""" + captured: dict[str, str | None] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["geocode"] = request.url.params.get("geocode") + captured["ll"] = request.url.params.get("ll") + return httpx.Response(200, json=_empty_yandex_payload()) + + transport = httpx.MockTransport(handler) + with patch("app.services.geocoder.httpx.AsyncClient", _yandex_client_factory(transport)): + await _yandex_lookup("Екатеринбург, Малышева 30", "fake-key") + + assert captured["geocode"] == "Екатеринбург, Малышева 30" + assert captured["ll"] == EKB_BBOX["ll"] + + +# ── _yandex_suggest (Tier 1 bounded) ───────────────────────────────────────── + + +async def test_yandex_suggest_no_city_uses_oblast_bounded() -> None: + """#2576: автокомплит без города — bounded по ВСЕЙ области, без city-префикса + (раньше Tier 1 всегда форсил bounded-ЕКБ с "Екатеринбург, ").""" + calls: list[tuple[str, bool, dict[str, str] | None]] = [] + + async def fake_request(client, api_key, query, limit, bounded, bias=None): + calls.append((query, bounded, bias)) + return [] + + with patch( + "app.services.geocoder._yandex_geocode_request", new=AsyncMock(side_effect=fake_request) + ): + result = await _yandex_suggest("Ленина, 1", "fake-key") + + assert result == [] + assert calls, "expected at least one Yandex request" + first_query, first_bounded, first_bias = calls[0] + assert first_query == "Ленина, 1" + assert "Екатеринбург" not in first_query + assert first_bounded is True + assert first_bias == OBLAST66_VIEWBOX + + +async def test_yandex_suggest_city_hint_prefix_bounded() -> None: + calls: list[tuple[str, bool, dict[str, str] | None]] = [] + + async def fake_request(client, api_key, query, limit, bounded, bias=None): + calls.append((query, bounded, bias)) + return [] + + with patch( + "app.services.geocoder._yandex_geocode_request", new=AsyncMock(side_effect=fake_request) + ): + await _yandex_suggest("Ленина, 1", "fake-key", city_hint="Нижний Тагил") + + first_query, _, first_bias = calls[0] + assert first_query == "Нижний Тагил, Ленина, 1" + assert first_bias == OBLAST66_VIEWBOX + + +async def test_yandex_suggest_explicit_ekb_unchanged() -> None: + calls: list[tuple[str, bool, dict[str, str] | None]] = [] + + async def fake_request(client, api_key, query, limit, bounded, bias=None): + calls.append((query, bounded, bias)) + return [] + + with patch( + "app.services.geocoder._yandex_geocode_request", new=AsyncMock(side_effect=fake_request) + ): + await _yandex_suggest("Екатеринбург, Малышева 30", "fake-key") + + first_query, _, first_bias = calls[0] + assert first_query == "Екатеринбург, Малышева 30" + assert first_bias == EKB_BBOX + + +# ── _nominatim_suggest ─────────────────────────────────────────────────────── + + +async def test_nominatim_suggest_no_city_no_suffix() -> None: + """#2576: без города — Nominatim-запрос БЕЗ ", Екатеринбург"-суффикса. + + Географию не расширяет/не сужает (`_nominatim_query_multi` уже bounded=1 + по всей области `OBLAST66_VIEWBOX`) — но суффикс раньше смещал ранжирование + Nominatim в пользу ЕКБ-совпадений для адресов из других городов области. + + NB: с пустым результатом (как здесь) `_nominatim_suggest` уходит дальше в + typo-fallback Tier 2 (несколько доп. вызовов) — берём ПЕРВЫЙ вызов (Tier 1, + оригинальный query), не последний. + """ + 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) + ): + result = await _nominatim_suggest("Ленина, 1") + + assert result == [] + assert calls[0] == "Ленина, 1" + assert "Екатеринбург" not in calls[0] + + +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" + + +# ── geocode() — city_ambiguous flag ────────────────────────────────────────── + + +def _geocode_patches(yandex_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._yandex_lookup", new=AsyncMock(return_value=yandex_result)), + ) + + +async def test_geocode_city_ambiguous_true_when_no_city_known() -> None: + """Ни адрес, ни city_hint не называют город → city_ambiguous=True.""" + db = MagicMock() + yandex_result = GeocodeResult(lat=56.838, lon=60.605, full_address="что-то", provider="yandex") + with patch("app.services.geocoder.settings") as mock_settings: + mock_settings.yandex_geocoder_api_key = "fake" + with contextlib.ExitStack() as stack: + for cm in _geocode_patches(yandex_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() + yandex_result = GeocodeResult( + lat=56.838, lon=60.605, full_address="Екатеринбург, Малышева, 30", provider="yandex" + ) + with patch("app.services.geocoder.settings") as mock_settings: + mock_settings.yandex_geocoder_api_key = "fake" + with contextlib.ExitStack() as stack: + for cm in _geocode_patches(yandex_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() + yandex_result = GeocodeResult( + lat=57.905, lon=59.950, full_address="Нижний Тагил, Ленина, 1", provider="yandex" + ) + with patch("app.services.geocoder.settings") as mock_settings: + mock_settings.yandex_geocoder_api_key = "fake" + with contextlib.ExitStack() as stack: + for cm in _geocode_patches(yandex_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_yandex_lookup(address, api_key, city_hint=None): + if city_hint == "Нижний Тагил": + return GeocodeResult( + lat=57.905, lon=59.950, full_address="Нижний Тагил, Ленина, 1", provider="yandex" + ) + return GeocodeResult( + lat=56.838, lon=60.605, full_address="Екатеринбург, Ленина, 1", provider="yandex" + ) + + db = MagicMock() + with patch("app.services.geocoder.settings") as mock_settings: + mock_settings.yandex_geocoder_api_key = "fake" + 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._yandex_lookup", + new=AsyncMock(side_effect=fake_yandex_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 From 3595cb603f7a661414698bc60122e14d284f297a Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 31 Jul 2026 16:56:16 +0300 Subject: [PATCH 25/41] =?UTF-8?q?test(tradein):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D1=82=D1=8C=20deal=5Fcity=5Fprice=5Fbands=5Frefres?= =?UTF-8?q?h=20=D0=B2=20=D0=BF=D0=B0=D1=80=D0=B8=D1=82=D0=B5=D1=82-=D1=82?= =?UTF-8?q?=D0=B5=D1=81=D1=82=20=D0=BF=D0=BB=D0=B0=D0=BD=D0=B8=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D1=89=D0=B8=D0=BA=D0=B0=20(#2576)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _PRODUCT_SOURCES в test_scraper_kit_scheduler_parity.py — ручной canonical-список (файл сам предписывает обновлять его при изменении product_handlers.py, #2397 Part C уже чинил ровно эту протухшесть). Новый source из PR #2579 забыт — без него test_real_build_product_handlers_covers_all_scheduled_sources не покрывает регистрацию Handler'а: пропажа entry в product_handlers.py осталась бы незамечена, а kit-scheduler логировал бы "unknown source, skip" каждый тик без выполнения задачи. Проверено: тест падает при временном откате Handler-registration (AssertionError misses source=deal_city_price_bands_refresh), проходит после восстановления. --- tradein-mvp/backend/tests/test_scraper_kit_scheduler_parity.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tradein-mvp/backend/tests/test_scraper_kit_scheduler_parity.py b/tradein-mvp/backend/tests/test_scraper_kit_scheduler_parity.py index a4499362..ceb107ce 100644 --- a/tradein-mvp/backend/tests/test_scraper_kit_scheduler_parity.py +++ b/tradein-mvp/backend/tests/test_scraper_kit_scheduler_parity.py @@ -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", From 5a66c2df516ac02d08ffc1f11863eb7adaf26466 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 31 Jul 2026 17:02:14 +0300 Subject: [PATCH 26/41] =?UTF-8?q?fix(tradein/avito):=20=D1=8D=D0=BA=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=5F=20?= =?UTF-8?q?=D0=B2=20LIKE-=D0=BF=D0=B0=D1=82=D1=82=D0=B5=D1=80=D0=BD=D0=B0?= =?UTF-8?q?=D1=85=20+=20=D1=87=D0=B5=D1=81=D1=82=D0=BD=D0=B0=D1=8F=20?= =?UTF-8?q?=D1=8D=D0=BC=D1=83=D0=BB=D1=8F=D1=86=D0=B8=D1=8F=20LIKE=20?= =?UTF-8?q?=D0=B2=20=D1=82=D0=B5=D1=81=D1=82=D0=B5=20(#2576)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/tasks/avito_detail_backfill.py | 17 ++- .../tests/tasks/test_avito_detail_backfill.py | 110 ++++++++++++++---- 2 files changed, 105 insertions(+), 22 deletions(-) diff --git a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py index 92b5b4a8..55336a29 100644 --- a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py @@ -79,7 +79,22 @@ __all__ = [ # города (может отличаться от нашего city_slug: kamensk-uralskiy через дефис, # verhnyaya_pyshma без "kh") -- дублировать список тут вместо импорта было бы # risk дрейфа при добавлении новых oblast-городов. -_OBLAST_AVITO_URL_PATTERNS = tuple(f"%/{loc.avito_slug}/%" for loc in CITY_LOCATIONS.values()) +# +# #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 diff --git a/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py b/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py index 599e3308..84e58798 100644 --- a/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py +++ b/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import fnmatch import os import sys from unittest.mock import AsyncMock, MagicMock, patch @@ -398,42 +399,109 @@ 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).""" + 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 "%/nizhniy\\_tagil/%" in _OBLAST_AVITO_URL_PATTERNS assert "%/pervouralsk/%" in _OBLAST_AVITO_URL_PATTERNS - assert "%/verhnyaya_pyshma/%" 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 '%pat%'` эквивалентен fnmatch с `%` -> `*` (сам паттерн - без иных SQL-метасимволов) — реплицируем ту же семантику локально, чтобы - проверить реальные продовые паттерны (_OBLAST_AVITO_URL_PATTERNS) без - поднятия БД (юнит-тесты этого файла её не используют).""" - import fnmatch - - def _in_scope(source_url: str) -> bool: - if fnmatch.fnmatchcase(source_url, "*/ekaterinburg/*"): - return True - return any( - fnmatch.fnmatchcase(source_url, pat.replace("%", "*")) - for pat in _OBLAST_AVITO_URL_PATTERNS - ) - + Использует корректную LIKE-эмуляцию (_like_pattern_to_fnmatch), а не наивный + `%` -> `*` replace (#2578 review — тот не различал бы `_`-семантику).""" # Область (Каменск-Уральский, #2576 — реальный кейс из тикета) -- проходит. - assert _in_scope("https://www.avito.ru/kamensk-uralskiy/kvartiry/prodam_123") + assert _in_oblast_or_ekb_scope("https://www.avito.ru/kamensk-uralskiy/kvartiry/prodam_123") # ЕКБ — по-прежнему проходит (не деградировал). - assert _in_scope("https://www.avito.ru/ekaterinburg/kvartiry/prodam_456") + assert _in_oblast_or_ekb_scope("https://www.avito.ru/ekaterinburg/kvartiry/prodam_456") # Чужой регион — НЕ проходит (иначе поехали бы Москва/СПб/Тюмень legacy-строки). - assert not _in_scope("https://www.avito.ru/moskva/kvartiry/prodam_789") - assert not _in_scope("https://www.avito.ru/sankt-peterburg/kvartiry/prodam_000") + 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 From 2779ef5e79f4cb6b01ff67e34b65b2f22ee32b4c Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 31 Jul 2026 17:08:46 +0300 Subject: [PATCH 27/41] =?UTF-8?q?feat(tradein/ui):=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B4=D0=B0=D0=B2=D0=B0=D1=82=D1=8C=20=D0=B3=D0=BE=D1=80?= =?UTF-8?q?=D0=BE=D0=B4=20=D0=B2=20=D0=B3=D0=B5=D0=BE=D0=BA=D0=BE=D0=B4?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20+=20=D1=87?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=BD=D1=8B=D0=B9=20=D0=BF=D1=80=D0=B8=D0=B7?= =?UTF-8?q?=D0=BD=D0=B0=D0=BA=20=D0=BD=D0=B5=D0=BE=D0=BF=D1=80=D0=B5=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D1=91=D0=BD=D0=BD=D0=BE=D1=81=D1=82=D0=B8=20(#2576?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раньше интерфейс город вообще не передавал — backend (#2580) больше не подставляет "Екатеринбург" молча, из-за чего житель Нижнего Тагила, вводя «Ленина, 1», получал бы результат по одноимённой екатеринбургской улице. - Новый справочник src/lib/city-registry.ts (растущий список городов области, сейчас: Екатеринбург, Нижний Тагил, Каменск-Уральский, Первоуральск, Верхняя Пышма, Серов) — DEFAULT_CITY = Екатеринбург, чтобы ЕКБ-сценарий не требовал никаких лишних действий. - ParamsPanel: компактный дропдаун «Город» рядом с лейблом адреса (переиспользует существующий
HUD-комбобокс) + автоопределение города из набранного текста/выбранной подсказки (detectCityInText, word-boundary safe — не путает "Серов" с "ул. Серова" в ЕКБ). city_hint уходит в geocode/suggest и в POST /trade-in/estimate. - useGeocodeSuggest(query, cityHint, limit) — city_hint в query-параметрах и в queryKey, чтобы смена города рефетчила подсказки. - Честная подсказка в ParamsPanel, когда estimate.target_city_ambiguous===true: спокойный (не danger) текст «Город определён автоматически — результат может относиться к другому населённому пункту области. Если это не {city}, выберите верный город выше и повторите оценку.» — не блокирует форму. - types/trade-in.ts: TradeInEstimateInput.city_hint, AggregatedEstimate.target_city_ambiguous (зеркалит backend PR #2580, ещё не смёржен — codegen не запускался, поля добавлены вручную по контракту схемы). tsc --noEmit / next lint / next build — чисто (только 2 pre-existing warning в несвязанных файлах). --- tradein-mvp/frontend/src/app/v2/page.tsx | 3 + .../components/trade-in/v2/ParamsPanel.tsx | 115 ++++++++++++++++-- tradein-mvp/frontend/src/lib/city-registry.ts | 68 +++++++++++ tradein-mvp/frontend/src/lib/trade-in-api.ts | 39 ++++-- tradein-mvp/frontend/src/types/trade-in.ts | 13 ++ 5 files changed, 218 insertions(+), 20 deletions(-) create mode 100644 tradein-mvp/frontend/src/lib/city-registry.ts diff --git a/tradein-mvp/frontend/src/app/v2/page.tsx b/tradein-mvp/frontend/src/app/v2/page.tsx index fa443482..61400634 100644 --- a/tradein-mvp/frontend/src/app/v2/page.tsx +++ b/tradein-mvp/frontend/src/app/v2/page.tsx @@ -1029,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 diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx index 251e66e3..753d3949 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx @@ -38,6 +38,11 @@ import { type MapMarker, } from "./mappers"; import { useGeocodeSuggest } from "@/lib/trade-in-api"; +import { + CITY_LABELS, + DEFAULT_CITY, + detectCityInText, +} from "@/lib/city-registry"; import type { GeocodeSuggestion, HouseType, @@ -45,7 +50,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 +528,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
HUD panel (surface.w98 + soft blue // shadow), sized to the narrow radius trigger and dropped just beneath it. const radiusPanel: CSSProperties = { @@ -551,6 +568,12 @@ interface ParamsPanelProps { error?: string | null; /** Prefill for restore-by-id (?id=) — maps API enums back to RU dropdown labels. */ initialValues?: Partial; + /** + * #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 +601,14 @@ function initRepairLabel(rs: RepairState | undefined): string { return rs ? REPAIR_RU[rs] : "Не указано"; } +// Город формы (#2576). Restore-by-id / re-estimate: best-effort detect из +// сохранённого адреса (иначе сброс на Екатеринбург молча увёл бы восстановленный +// отчёт другого города обратно на ЕКБ-подсказки). Новая форма без initialValues +// → DEFAULT_CITY ("Екатеринбург") — ЕКБ-сценарий без лишних действий. +function initCityLabel(address: string | undefined): string { + return detectCityInText(address) ?? DEFAULT_CITY.label; +} + // РАДИУС 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 +634,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 +647,10 @@ 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)); const [area, setArea] = useState( initialValues?.area_m2 != null ? String(initialValues.area_m2) : "", ); @@ -676,7 +712,11 @@ export default function ParamsPanel({ // БАЛКОН radiogroup focus targets (roving tabindex). const balNoRef = useRef(null); const balYesRef = useRef(null); - const suggest = useGeocodeSuggest(addressQuery); + // #2576: подсказки скопированы по выбранному городу (city_hint) — без него + // геокодер больше не подставляет Екатеринбург молча, а без hint'а + // автокомплит для остальной области смещается на всю область вместо + // конкретного города. Переключение city обязано рефетчить (city в queryKey). + const suggest = useGeocodeSuggest(addressQuery, city); const handleAddressChange = (v: string) => { setAddress(v); @@ -686,6 +726,12 @@ export default function ParamsPanel({ setAddressActive(-1); // a new query invalidates the previous highlight if (fieldErrors.address) setFieldErrors((prev) => ({ ...prev, address: undefined })); + // Autodetect (#2576): если пользователь сам печатает город в тексте + // адреса ("Нижний Тагил, Ленина 1"), подхватываем его в дропдаун сразу — + // не ждём выбора подсказки. Не дёргаем setCity без совпадения (город + // остаётся тем, что выбран ранее — обычно ЕКБ по умолчанию). + const detected = detectCityInText(v); + if (detected) setCity(detected); if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => setAddressQuery(v), 200); }; @@ -697,6 +743,21 @@ export default function ParamsPanel({ setFias(s.fias_id ?? null); setSuggestOpen(false); setAddressActive(-1); + // Autodetect (#2576): подсказка может называть город, отличный от + // текущего выбора (bias мягкий, не жёсткий фильтр — см. + // backend/app/services/geocoder.py::_yandex_bias) — подхватываем его. + const detected = detectCityInText(s.full_address); + if (detected) setCity(detected); + }; + + // Явный выбор города из дропдауна. Сбрасывает ранее выбранные + // координаты/ФИАС — тот же текст адреса под другим городом это уже другая + // точка (зеркалит handleAddressChange's stale-guard для ручной правки). + const selectCity = (v: string) => { + setCity(v); + setOpenDd(null); + setCoords(null); + setFias(null); }; // Address combobox keyboard (M6). aria-activedescendant pattern: focus STAYS on @@ -830,6 +891,10 @@ export default function ParamsPanel({ lon: coords?.lon ?? null, // Детерминированный ключ дома — только из выбранной подсказки (иначе null). target_fias_id: fias, + // #2576 — город, выбранный/автоопределённый в форме (city-registry.ts). + // Дефолт "Екатеринбург" воспроизводит прежнее молчаливое поведение + // геокодера, так что ЕКБ-сценарий не меняется. + city_hint: city, // "Авто" → null → backend keeps its two-tier default (1000 m primary / // 2000 m fallback). A fixed value overrides both. (Не отправлять 500 по // умолчанию — это сузило бы поиск аналогов и убрало fallback.) @@ -1384,12 +1449,38 @@ export default function ParamsPanel({ }} >
- + + {/* Город (#2576) — компактный дропдаун рядом с лейблом адреса, не + отдельная строка: не отнимает вертикальное место у и так плотной + панели. Дефолт "Екатеринбург" — тот же город, что геокодер + раньше подставлял молча, так что ЕКБ-сценарий не требует + никакого лишнего действия. Список растёт — см. city-registry.ts. */} +
+
toggle("city")} + value={city} + options={CITY_LABELS} + onSelect={selectCity} + mono={false} + triggerFontSize={11} + optionFontSize={11.5} + triggerHeight={22} + variant="dashed" + ariaLabel="Город" + /> + +
{/* Address autocomplete (geocode suggest, ЕКБ viewbox). Typed text is debounced into `addressQuery`; the dropdown below mirrors the @@ -1508,6 +1599,16 @@ export default function ParamsPanel({ {fieldErrors.address}
)} + {/* #2576 — честная, не блокирующая подсказка: последняя оценка + вернулась с target_city_ambiguous, город определил геокодер + сам. role="status" (не alert) — это информация, не ошибка. */} + {cityAmbiguous && !fieldErrors.address && ( +
+ Город определён автоматически — результат может относиться к + другому населённому пункту области. Если это не {city}, + выберите верный город выше и повторите оценку. +
+ )} diff --git a/tradein-mvp/frontend/src/lib/city-registry.ts b/tradein-mvp/frontend/src/lib/city-registry.ts new file mode 100644 index 00000000..752fe558 --- /dev/null +++ b/tradein-mvp/frontend/src/lib/city-registry.ts @@ -0,0 +1,68 @@ +/** + * city-registry — единый источник правды по городам Свердловской области, + * поддержанным сбором «Меры» (#2576 этап B: расширение на область). + * + * Раньше геокодер молча подставлял "Екатеринбург, " в любой запрос без явного + * города в тексте адреса — житель Нижнего Тагила, вводя «Ленина, 1», получал + * уверенно неверную цену по одноимённой екатеринбургской улице (backend fix + * #2576, PR #2580: `city_hint` query-параметр + `TradeInEstimateInput.city_hint` + * + честный `AggregatedEstimate.target_city_ambiguous`). + * + * Список городов растёт по мере расширения покрытия скрейперами — не хардкодь + * города россыпью по компонентам, добавляй сюда (мирроит паттерн source-registry.ts). + */ + +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: "Серов" }, +]; + +/** + * Дефолт формы — Екатеринбург. Держит основной сценарий без дополнительных + * действий пользователя: `city_hint` отправляется всегда, но для ЕКБ это тот + * же текст, что геокодер раньше подставлял молча, так что happy path не + * меняется ни на пиксель. + */ +export const DEFAULT_CITY: OblastCity = OBLAST_CITIES[0]; + +/** 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"); +} diff --git a/tradein-mvp/frontend/src/lib/trade-in-api.ts b/tradein-mvp/frontend/src/lib/trade-in-api.ts index b3982e11..9e9a4dc0 100644 --- a/tradein-mvp/frontend/src/lib/trade-in-api.ts +++ b/tradein-mvp/frontend/src/lib/trade-in-api.ts @@ -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({ - queryKey: ["trade-in", "geocode-suggest", q, limit], - queryFn: () => - apiFetch( - `${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( + `${GEOCODE_BASE}/suggest?${params.toString()}`, + ); + }, select: (r) => r.items, enabled: q.length >= 3, staleTime: 5 * 60_000, diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts index 296882dc..10f63135 100644 --- a/tradein-mvp/frontend/src/types/trade-in.ts +++ b/tradein-mvp/frontend/src/types/trade-in.ts @@ -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: старые From 23b8816863a144e6eb844ea82f060d468eb2e6a8 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 31 Jul 2026 17:39:00 +0300 Subject: [PATCH 28/41] =?UTF-8?q?fix(tradein/estimator):=20=D0=B3=D0=B5?= =?UTF-8?q?=D0=BE-=D0=BE=D0=B3=D1=80=D0=B0=D0=BD=D0=B8=D1=87=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D1=8F=D0=BA=D0=BE=D1=80=D0=BD=D0=BE=D0=B3=D0=BE?= =?UTF-8?q?=20=D1=82=D0=B8=D1=80=D0=B0=20=E2=80=94=20=D0=BD=D0=B5=20=D0=B1?= =?UTF-8?q?=D1=80=D0=B0=D1=82=D1=8C=20=D1=87=D1=83=D0=B6=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=B3=D0=BE=D1=80=D0=BE=D0=B4=D0=B0=20(#2581)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier A (_fetch_anchor_comps, "тот же дом") матчил по normalized street+house (_normalize_building_key намеренно дропает город) БЕЗ единого гео-предиката — единственный запрос к listings в файле без ST_DWithin/города/house_id_fk/LIMIT. "Серов, ул. Ленина, 5" получал якорь по ЕКБ-объявлениям (~40 191 из ~40 200 активных листингов — ЕКБ) с самым доверенным тиром 'A', завышая цену в 4-5х; хуже — anchor_tier='A' блокировал честный #oblast-D deals-headline-fallback (гейт `anchor_tier is None`), так что область не могла получить даже резервную ДКП-оценку. Фикс мирроит уже одобренный geo-bound для Tier S (f9ae6f0c, #oblast-D): ST_DWithin(geom::geography, subject_point, ANCHOR_TIER_A_RADIUS_M) от lat/lon субъекта, radius = DEFAULT_RADIUS_M (1000м, тот же константа что и Tier S) — строковый match уже устанавливает "та же улица+дом", радиус нужен только чтобы отсечь РЕАЛЬНО кросс-городские коллизии (ЕКБ/Серов — сотни км), не для внутридомовой точности. Tier A целиком гейтится на lat/lon субъекта (как Tier C) — без них геопредикат невозможен; в проде geo всегда есть (_empty_estimate возвращается раньше при неудачном geocode). Объявления без geom: `AND geom IS NOT NULL` + ST_DWithin (NULL → exclude, не blind-include) — >99.97% листингов имеют координаты (см. f9ae6f0c), исключение погрешности не создаёт. _normalize_building_key НЕ тронут (город по-прежнему не входит в ключ) — добавление городского токена сломало бы матчинг источников без города в адресе (Avito anonymous) и внесло бы новый normalization-риск; кросс- городская коллизия закрыта на SQL-уровне надёжнее. Поправлен ложный комментарий _band_haircut (:1900-1908): "same-building anchor pool для oblast не формируется" был неверен даже ДО фикса (Tier A не имел гео-фильтра вовсе — либо матчил ЕКБ, либо честно матчил местные листинги, если они были). Тест-страж test_estimator_deals_headline_fallback_oblast_d.py использовал голый db=MagicMock() для _fetch_anchor_comps — db.execute(...).mappings().all() на unconfigured MagicMock тривиально возвращает [] (MagicMock __iter__ default), так что anchor_tier никогда не мог стать 'A' и регрессия было бы невидима. Пропатчено явно (документирует допущение) + добавлен новый тест, который НЕ мокает _fetch_anchor_comps — гоняет реальную SQL-логику через фейковый db.execute, симулирующий настоящую ST_DWithin-фильтрацию по haversine- дистанции. На pre-fix коде (git stash) даёт headline=186 400 ₽/м² вместо честных 30 000 (deal corridor) — воспроизводит репортнутый баг buквально. EKB control-тест подтверждает: несколько объявлений в одном доме ЕКБ по-прежнему формируют Tier A anchor (n=4, ~145k ₽/м²) — не деградировало. test_tier_a_primary_1774.py: позитивные Tier A тесты теперь передают lat/lon субъекта (гейт требует их) — тесты на python-side novostroyki- гейтинг (ожидающие tier=None) оставлены с lat=None (конечный результат не меняется: Tier A целиком пропускается без lat/lon, как и Tier C раньше). Full suite: 2842 passed, 9 skipped, 1 pre-existing failure (test_search_cache_hit, не связан). Ruff чист. --- tradein-mvp/backend/app/services/estimator.py | 70 ++++- ...imator_deals_headline_fallback_oblast_d.py | 274 ++++++++++++++++++ .../backend/tests/test_tier_a_primary_1774.py | 32 +- 3 files changed, 358 insertions(+), 18 deletions(-) diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index d533bb6e..45c2d552 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -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 diff --git a/tradein-mvp/backend/tests/test_estimator_deals_headline_fallback_oblast_d.py b/tradein-mvp/backend/tests/test_estimator_deals_headline_fallback_oblast_d.py index 93b7f502..2315dd61 100644 --- a/tradein-mvp/backend/tests/test_estimator_deals_headline_fallback_oblast_d.py +++ b/tradein-mvp/backend/tests/test_estimator_deals_headline_fallback_oblast_d.py @@ -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 diff --git a/tradein-mvp/backend/tests/test_tier_a_primary_1774.py b/tradein-mvp/backend/tests/test_tier_a_primary_1774.py index 262fc372..373ab2b0 100644 --- a/tradein-mvp/backend/tests/test_tier_a_primary_1774.py +++ b/tradein-mvp/backend/tests/test_tier_a_primary_1774.py @@ -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 From b3c76c8c6263784c65b3b9b12eb3eac0150c655d Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 31 Jul 2026 17:55:18 +0300 Subject: [PATCH 29/41] =?UTF-8?q?fix(tradein/geocoder):=20city=5Fhint=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=BB=D0=B6=D0=B5=D0=BD=20=D0=B4=D0=BE=D1=85=D0=BE?= =?UTF-8?q?=D0=B4=D0=B8=D1=82=D1=8C=20=D0=B4=D0=BE=20=D0=BB=D0=BE=D0=BA?= =?UTF-8?q?=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D1=85=20=D1=82=D0=B8=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=20+=20=D0=BD=D0=B5=20=D1=82=D0=B5=D1=80=D1=8F=D1=82?= =?UTF-8?q?=D1=8C=20=D0=95=D0=9A=D0=91-=D0=BF=D1=80=D0=B8=D0=B2=D1=8F?= =?UTF-8?q?=D0=B7=D0=BA=D1=83=20=D0=B2=20Tier=204=20(#2576)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deep-review PR #2580 нашёл два блокера в предыдущем фиксе (#2576): C1 — city_hint не участвовал в гейте локальных ЕКБ-only тиров (geoportal/cadastral, `use_local_ekb` в geocode() и Tier 1 в suggest()). Явный city_hint="Нижний Тагил" для "Ленина 1" всё равно попадал на ЕКБ-only базы, получал "точный" ЕКБ-хит и при этом city_ambiguous=False (хинт ведь был) — то есть система теперь ложно-уверенно утверждала неверный город. Фикс: city_hint участвует в той же проверке _names_non_ekb_city (гейт/ gazetteer #2582 не трогаю — только добавляю вход). C2 — снятие суффикса ", Екатеринбург" в _nominatim_suggest для случая "город неизвестен" регрессило часть реальных ЕКБ-адресов: без текстовой подсказки о городе Nominatim иногда предпочитает street-level матч в соседнем городе-спутнике (эмпирика ревьюера: "Победы 20" без суффикса → Верхняя Пышма вместо ЖК "Парк Победы" в Екатеринбурге). Решение — dual-query: bare (честный, без города) И ЕКБ-suffixed запросы объединяются (не заменяют друг друга), оба честных кандидата остаются в подсказках, пользователь выбирает сам. Extra round-trip только для последнего fallback-тира (cadastral/DaData/Yandex уже не сработали) — не задевает основной трафик. Заодно (🟠, дешёвая правка): city_hint прокинут в _nominatim_lookup — с тех пор как Yandex-ключ недействителен (#2585), это единственный живой внешний провайдер, и его tie-break (предпочитает tight-ЕКБ bbox) без города не различает одноимённые улицы внутри региона. 4 новых теста (C1×2, C2×2 + dedupe) — проверено что все 4 падают на коде до этого коммита через git stash (только geocoder.py, тесты оставлены). --- tradein-mvp/backend/app/services/geocoder.py | 122 +++++++++-- .../backend/tests/test_geocoder_city_hint.py | 199 ++++++++++++++++-- 2 files changed, 288 insertions(+), 33 deletions(-) diff --git a/tradein-mvp/backend/app/services/geocoder.py b/tradein-mvp/backend/app/services/geocoder.py index 1ec06c79..4e0bd190 100644 --- a/tradein-mvp/backend/app/services/geocoder.py +++ b/tradein-mvp/backend/app/services/geocoder.py @@ -462,12 +462,21 @@ async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | No @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8)) -async def _nominatim_lookup(address: str) -> GeocodeResult | None: +async def _nominatim_lookup(address: str, city_hint: str | None = None) -> GeocodeResult | None: """OSM Nominatim — бесплатно, без ключа, 1 req/sec policy. Бан-policy: User-Agent с email обязателен. Tier 1: bounded область (region 66) на оригинальный адрес. Tier 2: bounded область (region 66) на typo-варианты (Цвилинга → Цвиллинга). + + #2580 (C): city_hint, если известен, подставляется в текст запроса — без + него `_nominatim_query` полагается ТОЛЬКО на oblast66-bbox фильтр + tie-break + (предпочитает tight-ЕКБ bbox), который для одноимённых улиц ВНУТРИ региона + (напр. "Ленина" — и в Екатеринбурге, и в с. Свердловское) не различает город. + Эмпирически подтверждено: "Ленина 1" без города → случайное село внутри + области; "Нижний Тагил, Ленина 1" → корректно резолвится. Раз Yandex-ключ + сейчас недействителен (#2585), это единственный живой внешний провайдер — + city_hint должен реально влиять на его результат, не только на кэш-ключ. """ headers = { "User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})", @@ -475,15 +484,19 @@ async def _nominatim_lookup(address: str) -> GeocodeResult | None: "Accept-Language": "ru,en;q=0.8", "Referer": "https://tradein-mvp.local/", } + city, _ = _resolve_city_for_geocode(address, city_hint) + query = f"{city}, {address}" if city else address async with httpx.AsyncClient(timeout=10.0, headers=headers) as client: # Tier 1: оригинал - item = await _nominatim_query(client, address) + item = await _nominatim_query(client, query) # Tier 2: typo-variants if item is None: for variant in _typo_variants(address, limit=4): await asyncio.sleep(1.0) # Nominatim 1 req/sec policy - item = await _nominatim_query(client, variant) + variant_city, _ = _resolve_city_for_geocode(variant, city_hint) + variant_query = f"{variant_city}, {variant}" if variant_city else variant + item = await _nominatim_query(client, variant_query) if item is not None: logger.info("nominatim typo-fixed: %s → %s", address, variant) break @@ -837,38 +850,97 @@ async def _nominatim_query_multi(client: httpx.AsyncClient, query: str, limit: i return data if isinstance(data, list) else [] +def _dedupe_nominatim_items(*item_lists: list[dict]) -> list[dict]: + """Объединяет несколько списков raw Nominatim items в один, без дублей. + + Дедуп по `place_id` (если есть), иначе по округлённым координатам. Порядок + сохраняется: элементы из более раннего списка идут первыми (приоритет). + """ + seen: set[tuple[object, ...]] = set() + out: list[dict] = [] + for items in item_lists: + for item in items: + place_id = item.get("place_id") + key: tuple[object, ...] + if place_id is not None: + key = ("place_id", place_id) + else: + try: + key = ("latlon", round(float(item["lat"]), 5), round(float(item["lon"]), 5)) + except (KeyError, ValueError, TypeError): + key = ("raw", item.get("display_name")) + if key in seen: + continue + seen.add(key) + out.append(item) + return out + + +async def _nominatim_query_city_aware( + client: httpx.AsyncClient, query: str, city: str | None, city_specified: bool, limit: int +) -> list[dict]: + """Строит и выполняет Nominatim-запрос(ы) с учётом того, известен ли город. + + Три случая (см. `_resolve_city_for_geocode`): + 1. `city` не None (`city_hint` подставлен) → один suffixed-запрос с ним. + 2. `city` is None, но `city_specified=True` (маркер УЖЕ в тексте адреса, + например "Екатеринбург, Малышева 30") → запрос БЕЗ доп. суффикса — город + уже есть в тексте, дублировать его нельзя (иначе "X, Екатеринбург, + Екатеринбург" ломает матчинг). + 3. `city` is None и `city_specified=False` — город НЕизвестен вообще (#2580 / + C2, regression test "Победы 20"): один bare-запрос БЕЗ текстового суффикса + неожиданно теряет часть настоящих ЕКБ-адресов — Nominatim без подсказки о + городе иногда предпочитает street-level матч в соседнем городе-спутнике + (напр. "Победы 20" без суффикса → улица Победы, Верхняя Пышма) более + специфичному named-place матчу в ЕКБ ("Парк Победы" ЖК, Екатеринбург). + Поэтому делаем ДВА запроса — bare (честный oblast-wide поиск, не теряет + реальные адреса других городов) И ЕКБ-suffixed (majority трафика) — и + ОБЪЕДИНЯЕМ результаты (не заменяем один другим): оба честных кандидата + остаются в списке, пользователь выбирает нужный сам из подсказок. + ЕКБ-кандидаты идут первыми (majority-случай, привычный порядок). + """ + if city: + return await _nominatim_query_multi(client, f"{query}, {city}", limit) + if city_specified: + return await _nominatim_query_multi(client, query, limit) + ekb_data = await _nominatim_query_multi(client, f"{query}, Екатеринбург", limit) + await asyncio.sleep(1.0) # Nominatim 1 req/sec policy — два запроса подряд + bare_data = await _nominatim_query_multi(client, query, limit) + return _dedupe_nominatim_items(ekb_data, bare_data)[:limit] + + @retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4)) async def _nominatim_suggest( query: str, limit: int = 8, city_hint: str | None = None ) -> list[GeocodeSuggestion]: """Nominatim в режиме suggest. С typo-fallback (для случаев когда Yandex недоступен). - Суффикс города навязывается ТОЛЬКО если он известен: адрес уже называет - город/область региона 66, либо передан явный `city_hint`. Раньше (#2576) - при отсутствии обоих сюда молча подставлялся суффикс ", Екатеринбург" — - географию поиска это не расширяло/не сужало (`_nominatim_query_multi` и - так bounded=1 по ВСЕЙ области `OBLAST66_VIEWBOX`), но текстовый суффикс - смещал ранжирование Nominatim в пользу ЕКБ-совпадений даже для адресов - из других городов области. + Суффикс города навязывается, только если он известен: адрес уже называет + город/область региона 66, либо передан явный `city_hint`. Если город + НЕизвестен — см. `_nominatim_query_city_aware` (dual-query, C2): раньше + (#2576) здесь молча подставлялся суффикс ", Екатеринбург" всегда; чистое + удаление суффикса (без dual-query) регрессило часть реальных ЕКБ-адресов + (см. C2 в #2580) — поэтому оба честных варианта объединяются, не заменяют + друг друга. """ headers = { "User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})", "Accept": "application/json", "Accept-Language": "ru,en;q=0.8", } - city, _ = _resolve_city_for_geocode(query, city_hint) - suffixed_query = f"{query}, {city}" if city else query + city, city_specified = _resolve_city_for_geocode(query, city_hint) async with httpx.AsyncClient(timeout=8.0, headers=headers) as client: # Tier 1: оригинальный query - data = await _nominatim_query_multi(client, suffixed_query, limit) + data = await _nominatim_query_city_aware(client, query, city, city_specified, limit) # Tier 2: typo-варианты если оригинал пустой if not data: for variant in _typo_variants(query, limit=3): await asyncio.sleep(1.0) # Nominatim 1 req/sec - variant_city, _ = _resolve_city_for_geocode(variant, city_hint) - variant_query = f"{variant}, {variant_city}" if variant_city else variant - data = await _nominatim_query_multi(client, variant_query, limit) + variant_city, variant_specified = _resolve_city_for_geocode(variant, city_hint) + data = await _nominatim_query_city_aware( + client, variant, variant_city, variant_specified, limit + ) if data: logger.info("nominatim suggest typo-fixed: %s → %s", query, variant) break @@ -1221,7 +1293,10 @@ async def suggest( # другой город области, иначе не-ЕКБ автокомплит может всплыть ЕКБ-домом # с совпадающими улица+дом. Внешние тиры (2/3/4 ниже) не гейтим — они уже # oblast-aware. - if db is not None and not _names_non_ekb_city(query): + # #2580 (C1): city_hint тоже гейтит — иначе он мёртвый параметр для этого + # тира (см. `geocode()` use_local_ekb выше — тот же принцип). + hint_names_non_ekb = bool(city_hint) and _names_non_ekb_city(city_hint) + if db is not None and not (_names_non_ekb_city(query) or hint_names_non_ekb): # 1a. Anchored house-match: парсим street+house → точный матч по дом-маркеру. # Решает кейс «Серова 27» где raw-ILIKE по readable_address давал 0 hits. parsed = _parse_street_house(query.strip()) @@ -1307,7 +1382,16 @@ async def geocode(address: str, db: Session, city_hint: str | None = None) -> Ge # адрес другого города области — иначе улица+дом, коллизящие с ЕКБ-домом # (напр. "проспект Ленина 1" есть и в Нижнем Тагиле, и в ЕКБ), снапаются в # ЕКБ. Пропускаем сразу к oblast-aware внешним провайдерам ниже (3/4). - use_local_ekb = not _names_non_ekb_city(address) + # #2580 (C1): city_hint ДОЛЖЕН участвовать в этом гейте — иначе вызывающий + # код, явно передавший city_hint="Нижний Тагил" для "Ленина 1" (в самом + # тексте адреса города нет), всё равно попадает на geoportal/cadastral + # (ЕКБ-only базы), получает "точный" ЕКБ-хит и city_ambiguous=False (хинт + # ведь был!) — т.е. систему, которая раньше просто не знала город, теперь + # ложно-уверенно утверждает неверный. `_names_non_ekb_city` НЕ переписываем + # (гейт/gazetteer — отдельный дефект #2582), только добавляем city_hint + # на вход той же самой проверки. + hint_names_non_ekb = bool(city_hint) and _names_non_ekb_city(city_hint) + use_local_ekb = not (_names_non_ekb_city(address) or hint_names_non_ekb) # 2a. Геопортал ЕКБ — ПЕРВЫЙ локальный tier (полнее cad_buildings ~на 70%). if use_local_ekb and parsed is not None: @@ -1418,7 +1502,7 @@ async def geocode(address: str, db: Session, city_hint: str | None = None) -> Ge # 4. Nominatim fallback try: - result = await _nominatim_lookup(address) + result = await _nominatim_lookup(address, city_hint) if result is not None: result = replace(result, city_ambiguous=city_ambiguous) await asyncio.to_thread(_cache_put, db, addr_norm, result) diff --git a/tradein-mvp/backend/tests/test_geocoder_city_hint.py b/tradein-mvp/backend/tests/test_geocoder_city_hint.py index fd2159e8..5a5666c2 100644 --- a/tradein-mvp/backend/tests/test_geocoder_city_hint.py +++ b/tradein-mvp/backend/tests/test_geocoder_city_hint.py @@ -34,12 +34,14 @@ from app.services.geocoder import ( EKB_BBOX, OBLAST66_VIEWBOX, GeocodeResult, + GeocodeSuggestion, _cache_key, _nominatim_suggest, _resolve_city_for_geocode, _yandex_lookup, _yandex_suggest, geocode, + suggest, ) # ── _resolve_city_for_geocode ──────────────────────────────────────────────── @@ -227,16 +229,11 @@ async def test_yandex_suggest_explicit_ekb_unchanged() -> None: # ── _nominatim_suggest ─────────────────────────────────────────────────────── -async def test_nominatim_suggest_no_city_no_suffix() -> None: - """#2576: без города — Nominatim-запрос БЕЗ ", Екатеринбург"-суффикса. - - Географию не расширяет/не сужает (`_nominatim_query_multi` уже bounded=1 - по всей области `OBLAST66_VIEWBOX`) — но суффикс раньше смещал ранжирование - Nominatim в пользу ЕКБ-совпадений для адресов из других городов области. - - NB: с пустым результатом (как здесь) `_nominatim_suggest` уходит дальше в - typo-fallback Tier 2 (несколько доп. вызовов) — берём ПЕРВЫЙ вызов (Tier 1, - оригинальный query), не последний. +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] = [] @@ -244,14 +241,18 @@ async def test_nominatim_suggest_no_city_no_suffix() -> None: calls.append(query) return [] - with patch( - "app.services.geocoder._nominatim_query_multi", new=AsyncMock(side_effect=fake_query_multi) + 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 calls[0] == "Ленина, 1" - assert "Екатеринбург" not in calls[0] + assert "Ленина, 1" in calls # bare — честный, без города + assert "Ленина, 1, Екатеринбург" in calls # ЕКБ-вариант — не потерян async def test_nominatim_suggest_city_hint_suffix() -> None: @@ -284,6 +285,81 @@ async def test_nominatim_suggest_explicit_ekb_unchanged() -> None: 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 ────────────────────────────────────────── @@ -298,6 +374,101 @@ def _geocode_patches(yandex_result: GeocodeResult | None): ) +# ── 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="yandex" + ) + with patch("app.services.geocoder.settings") as mock_settings: + mock_settings.yandex_geocoder_api_key = "fake" + 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._yandex_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 + mock_settings.yandex_geocoder_api_key = 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() From a8d29fa031fedb83945aa83b882fb9126d765578 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 31 Jul 2026 18:15:40 +0300 Subject: [PATCH 30/41] =?UTF-8?q?fix(tradein/ui):=20=D0=BD=D0=B5=20=D1=81?= =?UTF-8?q?=D0=BB=D0=B0=D1=82=D1=8C=20city=5Fhint=20=D0=BF=D0=BE=20=D1=83?= =?UTF-8?q?=D0=BC=D0=BE=D0=BB=D1=87=D0=B0=D0=BD=D0=B8=D1=8E=20=E2=80=94=20?= =?UTF-8?q?=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D1=8F=D0=B2=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=20=D0=B8=D0=BB=D0=B8=20?= =?UTF-8?q?=D0=B3=D0=BE=D1=80=D0=BE=D0=B4=20=D0=B8=D0=B7=20=D0=B0=D0=B4?= =?UTF-8?q?=D1=80=D0=B5=D1=81=D0=B0=20(#2576)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deep-review R2 на #2580/#2576: предыдущий коммит слал city_hint="Екатеринбург" даже когда дропдаун не тронут — бэкенд трактует ЛЮБОЙ city_hint как «пользователь назвал город» (city_specified=True), так что target_city_ambiguous становился false практически всегда, а необнаруженный житель Нижнего Тагила («Ленина, 1» без явного упоминания города) молча резолвился бы в Екатеринбург — ровно баг, который чинил backend, только переехавший из geocoder.py в city-registry.ts. Вариант A (по рекомендации ревьюера): город реально известен (и поэтому отправляется в city_hint) ТОЛЬКО когда пользователь явно выбрал его в дропдауне ИЛИ detectCityInText нашёл совпадение в наборном тексте / выбранной подсказке. Нетронутый дефолт → city_hint не уходит вовсе (ни в geocode/suggest, ни в POST /estimate) — тогда backend честно возвращает target_city_ambiguous и не форсит ЕКБ-bias без запроса. - Новое состояние `cityConfirmed` (ParamsPanel.tsx) — гейт на отправку, раздельный от `city` (best-guess для отображения/текста плашки). true после explicit dropdown pick ИЛИ автодетекта из текста/подсказки; sticky — мелкая правка адреса без нового совпадения его не сбрасывает. - До подтверждения дропдаун показывает `UNCONFIRMED_CITY_LABEL` ("Определить автоматически"), не статичное "Екатеринбург" — не выдаёт внутренний best-guess за подтверждённый пользователем выбор. - useGeocodeSuggest получает city_hint только при cityConfirmed=true — для нетронутой формы автокомплит тоже больше не форсит ЕКБ-bias молча, а видит кандидатов из всей области (в т.ч. Нижний Тагил) — это и есть тот сценарий из заголовка эпика. - ЕКБ happy path не усложнён: как только пользователь печатает город в адресе или (обычный путь) выбирает любую подсказку из автокомплита, detectCityInText почти всегда находит "Екатеринбург" в full_address (провайдер возвращает город как часть резолвленного адреса независимо от того, был ли отправлен hint) — дропдаун сам переключается на "Екатеринбург" и cityConfirmed становится true без отдельного клика. Требует лишнего действия только редкий путь "напечатал произвольный адрес без города и нажал Enter, не выбрав ни одной подсказки". - city-registry.ts: явный комментарий-ссылка на бэкендовый гэзеттир `SVERDLOVSK_OBLAST_CITIES` (tradein-mvp/backend/app/services/geocoder.py) — parity-риск при добавлении нового города остаётся видимым с фронтовой стороны (backend/тесты не трогаю — другой PR, вне моего scope). tsc --noEmit / next lint / next build — чисто (только 2 pre-existing warning в несвязанных файлах, как и в предыдущем коммите). --- .../components/trade-in/v2/ParamsPanel.tsx | 106 +++++++++++++----- tradein-mvp/frontend/src/lib/city-registry.ts | 35 +++++- 2 files changed, 109 insertions(+), 32 deletions(-) diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx index 753d3949..0555150b 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx @@ -41,6 +41,7 @@ import { useGeocodeSuggest } from "@/lib/trade-in-api"; import { CITY_LABELS, DEFAULT_CITY, + UNCONFIRMED_CITY_LABEL, detectCityInText, } from "@/lib/city-registry"; import type { @@ -601,14 +602,31 @@ function initRepairLabel(rs: RepairState | undefined): string { return rs ? REPAIR_RU[rs] : "Не указано"; } -// Город формы (#2576). Restore-by-id / re-estimate: best-effort detect из -// сохранённого адреса (иначе сброс на Екатеринбург молча увёл бы восстановленный -// отчёт другого города обратно на ЕКБ-подсказки). Новая форма без initialValues -// → DEFAULT_CITY ("Екатеринбург") — ЕКБ-сценарий без лишних действий. +// Город формы (#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. @@ -651,6 +669,12 @@ export default function ParamsPanel({ // 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) : "", ); @@ -712,11 +736,15 @@ export default function ParamsPanel({ // БАЛКОН radiogroup focus targets (roving tabindex). const balNoRef = useRef(null); const balYesRef = useRef(null); - // #2576: подсказки скопированы по выбранному городу (city_hint) — без него - // геокодер больше не подставляет Екатеринбург молча, а без hint'а - // автокомплит для остальной области смещается на всю область вместо - // конкретного города. Переключение city обязано рефетчить (city в queryKey). - const suggest = useGeocodeSuggest(addressQuery, city); + // #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); @@ -728,10 +756,16 @@ export default function ParamsPanel({ setFieldErrors((prev) => ({ ...prev, address: undefined })); // Autodetect (#2576): если пользователь сам печатает город в тексте // адреса ("Нижний Тагил, Ленина 1"), подхватываем его в дропдаун сразу — - // не ждём выбора подсказки. Не дёргаем setCity без совпадения (город - // остаётся тем, что выбран ранее — обычно ЕКБ по умолчанию). + // не ждём выбора подсказки. Найденное совпадение — реальное подтверждение + // (пользователь сам его написал), поэтому это ЕДИНСТВЕННОЕ место, где + // cityConfirmed включается без явного клика по дропдауну. Отсутствие + // совпадения НЕ трогает city/cityConfirmed — предыдущее подтверждение + // (если было) остаётся в силе при мелкой правке текста. const detected = detectCityInText(v); - if (detected) setCity(detected); + if (detected) { + setCity(detected); + setCityConfirmed(true); + } if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => setAddressQuery(v), 200); }; @@ -743,18 +777,27 @@ export default function ParamsPanel({ setFias(s.fias_id ?? null); setSuggestOpen(false); setAddressActive(-1); - // Autodetect (#2576): подсказка может называть город, отличный от - // текущего выбора (bias мягкий, не жёсткий фильтр — см. - // backend/app/services/geocoder.py::_yandex_bias) — подхватываем его. + // Autodetect (#2576): выбор реального гео-резолвленного адреса — сильный + // сигнал подтверждения (даже сильнее, чем просто набранный текст). + // full_address обычно содержит город независимо от того, был ли отправлен + // city_hint (провайдер возвращает его как часть резолвленного адреса) — + // так первый же pick из НЕподтверждённого поиска (bias на всю область) + // подтверждает город без отдельного клика по дропдауну. const detected = detectCityInText(s.full_address); - if (detected) setCity(detected); + if (detected) { + setCity(detected); + setCityConfirmed(true); + } }; - // Явный выбор города из дропдауна. Сбрасывает ранее выбранные - // координаты/ФИАС — тот же текст адреса под другим городом это уже другая - // точка (зеркалит handleAddressChange's stale-guard для ручной правки). + // Явный выбор города из дропдауна — прямое подтверждение пользователем + // (deep-review R2: единственный способ гарантированно включить cityConfirmed + // независимо от текста адреса). Сбрасывает ранее выбранные координаты/ФИАС — + // тот же текст адреса под другим городом это уже другая точка (зеркалит + // handleAddressChange's stale-guard для ручной правки). const selectCity = (v: string) => { setCity(v); + setCityConfirmed(true); setOpenDd(null); setCoords(null); setFias(null); @@ -891,10 +934,12 @@ export default function ParamsPanel({ lon: coords?.lon ?? null, // Детерминированный ключ дома — только из выбранной подсказки (иначе null). target_fias_id: fias, - // #2576 — город, выбранный/автоопределённый в форме (city-registry.ts). - // Дефолт "Екатеринбург" воспроизводит прежнее молчаливое поведение - // геокодера, так что ЕКБ-сценарий не меняется. - city_hint: city, + // #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.) @@ -1462,14 +1507,21 @@ export default function ParamsPanel({ {/* Город (#2576) — компактный дропдаун рядом с лейблом адреса, не отдельная строка: не отнимает вертикальное место у и так плотной - панели. Дефолт "Екатеринбург" — тот же город, что геокодер - раньше подставлял молча, так что ЕКБ-сценарий не требует - никакого лишнего действия. Список растёт — см. city-registry.ts. */} + панели. Explicit pick сразу подтверждает город (city_hint + отправляется дальше). До подтверждения показывает + UNCONFIRMED_CITY_LABEL, НЕ "Екатеринбург" (deep-review R2) — + иначе дропдаун выдавал бы наш внутренний best-guess за + подтверждённый пользователем выбор, хотя ничего ещё не + отправлялось. Для ЕКБ-сценария это не лишний клик: как только + пользователь печатает/выбирает адрес, detectCityInText обычно + находит город сам (см. handleAddressChange/pickSuggestion) и + лейбл сменится на «Екатеринбург» автоматически. Список городов + растёт — см. city-registry.ts. */}
toggle("city")} - value={city} + value={cityConfirmed ? city : UNCONFIRMED_CITY_LABEL} options={CITY_LABELS} onSelect={selectCity} mono={false} diff --git a/tradein-mvp/frontend/src/lib/city-registry.ts b/tradein-mvp/frontend/src/lib/city-registry.ts index 752fe558..b8cd75f2 100644 --- a/tradein-mvp/frontend/src/lib/city-registry.ts +++ b/tradein-mvp/frontend/src/lib/city-registry.ts @@ -8,8 +8,25 @@ * #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). + * города россыпью по компонентам, добавляй сюда (мирроит паттерн + * 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 { @@ -35,13 +52,21 @@ export const OBLAST_CITIES: readonly OblastCity[] = [ ]; /** - * Дефолт формы — Екатеринбург. Держит основной сценарий без дополнительных - * действий пользователя: `city_hint` отправляется всегда, но для ЕКБ это тот - * же текст, что геокодер раньше подставлял молча, так что happy path не - * меняется ни на пиксель. + * Дефолт формы — Екатеринбург (визуальный/best-guess, для текста подсказки и + * подстановки значения ПОСЛЕ подтверждения). НЕ означает "отправляется по + * умолчанию" — см. предупреждение в шапке файла: пока город не подтверждён + * (`cityConfirmed` в ParamsPanel.tsx), в запрос ничего не уходит. */ export const DEFAULT_CITY: OblastCity = OBLAST_CITIES[0]; +/** + * Лейбл дропдауна ДО подтверждения города (свежая форма, город ещё не + * выбран/определён). Честнее статичного "Екатеринбург": не выдаёт наш + * best-guess за подтверждённый выбор пользователя (deep-review R2 — + * `city_hint` не должен отправляться в этом состоянии). + */ +export const UNCONFIRMED_CITY_LABEL = "Определить автоматически"; + /** RU-лейблы для дропдауна — тот же формат (string[]), что dropdownOptions.* в v2/fixtures.ts. */ export const CITY_LABELS: string[] = OBLAST_CITIES.map((c) => c.label); From abb9398f3fec260f31079119084b6563a1e106a7 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 31 Jul 2026 18:20:03 +0300 Subject: [PATCH 31/41] =?UTF-8?q?fix(tradein/rbac):=20=D1=81=D0=BA=D1=80?= =?UTF-8?q?=D1=8B=D1=82=D1=8C=20=C2=AB=D0=94=D0=BE=D0=BB=D1=8F=20=D0=B2=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B4=D0=B0=D0=B6=D0=B5=C2=BB=20=D0=B8=20?= =?UTF-8?q?=C2=AB=D0=9A=D1=8D=D1=88=C2=BB=20=D0=BE=D1=82=20=D0=BA=D0=BB?= =?UTF-8?q?=D0=B8=D0=B5=D0=BD=D1=82=D1=81=D0=BA=D0=B8=D1=85=20=D0=B0=D0=BA?= =?UTF-8?q?=D0=BA=D0=B0=D1=83=D0=BD=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Аккаунт praktika (DB-роль manager) видел оба пункта в топбаре на /trade-in/team. Это внутренние инструменты — аналитика рынка и состояние кэшей/скраперов, — клиентские аккаунты их видеть не должны (решение владельца продукта). Гейт один — deny-список роли, потому что все три места сверяются с ним через общий матчер: пункт меню (Topbar по scopePath из /me), страница (RouteGuard) и серверные ручки (rbac_guard). Правка только фронта спрятала бы пункт, оставив прямой URL и API открытыми. Закрыто для employee/manager (DB_ROLE_PATHS) и для legacy pilot (roles.yaml): /trade-in/sale-share/** /trade-in/cache/** /trade-in/api/v1/buildings/** /trade-in/api/v1/trade-in/cache-stats/** У cache-stats ГЛОБ, а не точный путь: точный паттерн — строгое равенство, его обходит трейлинг-слэш ('…/cache-stats/' → allowed=True), и защита держалась бы на Starlette redirect_slashes, а не на RBAC. Замерено после правки: все варианты (слэш, %2f, ./, ../) дают 403, утечек нет. Основной продукт не задет: buildings.py обслуживает ТОЛЬКО sale-share, секция «Продажи в доме» на экране оценки питается estimate-хендлерами. admin и analyst сознательно вне deny — запиннено тестом, иначе «синхронизация» списков закрыла бы их молча. Заодно починен КРАСНЫЙ pre-existing тест главного бэкенда: backend/tests/test_rbac.py::test_get_role_known_users ждал pilot у всех user1..user10, но user2 («Брусника») стал expired 2026-07-30. CI это пропустил — auth/roles.yaml не входит в paths-filter backend/**, из-за чего сьют не бежал. Тесты: 153 passed (tradein) + 24 passed (site-finder, было 23+1 failed). Новые — e2e через реальный rbac_guard по session-ветке (именно ею ходит praktika), пин deny_paths в выдаче /me, границы глоба и regression-guard'ы. Проверены снятием deny: 7 тестов краснеют, т.е. не тавтологии. --- auth/roles.yaml | 36 +++++ backend/tests/test_rbac.py | 15 +- .../backend/app/services/auth_session.py | 76 +++++++++- tradein-mvp/backend/tests/test_auth_api.py | 79 +++++++++- .../backend/tests/test_auth_session.py | 21 +++ tradein-mvp/backend/tests/test_rbac.py | 141 +++++++++++++++++- .../frontend/src/app/sale-share/page.tsx | 11 +- .../src/components/trade-in/Topbar.tsx | 25 +++- 8 files changed, 395 insertions(+), 9 deletions(-) diff --git a/auth/roles.yaml b/auth/roles.yaml index ef999bff..12131be7 100644 --- a/auth/roles.yaml +++ b/auth/roles.yaml @@ -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. '/**' → '^(?:/.*)?$': сам путь + слэш + подпути, + # но НЕ соседи по префиксу ('…/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,6 +81,9 @@ roles: # для любого role != "admin" → analyst авто-403 на admin-API без доп. кода. # deny ниже драйвит фронтовый RouteGuard (deny_paths из /me) для UI-gating # /admin/** страниц. + # NB: клиентский deny 2026-07-31 («Доля в продаже» / «Кэш», см. pilot выше) + # на analyst СОЗНАТЕЛЬНО не распространён — analyst внутренняя роль и оба + # раздела для неё рабочий инструмент. Это не забытая дыра. paths: - "/**" deny: diff --git a/backend/tests/test_rbac.py b/backend/tests/test_rbac.py index 3c2435dc..21ab8026 100644 --- a/backend/tests/test_rbac.py +++ b/backend/tests/test_rbac.py @@ -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: diff --git a/tradein-mvp/backend/app/services/auth_session.py b/tradein-mvp/backend/app/services/auth_session.py index 35b27c9c..385444dc 100644 --- a/tradein-mvp/backend/app/services/auth_session.py +++ b/tradein-mvp/backend/app/services/auth_session.py @@ -196,17 +196,85 @@ def revoke_user_sessions(db: Session, user_id: int) -> None: # НЕ являются ключами auth/roles.yaml (тот файл — legacy Caddy trusted-header путь, # который этот эпик намеренно не трогает). Маппинг ниже даёт DB-ролям тот же # paths/deny-смысл, что и legacy-ролям, БЕЗ правки roles.yaml: -# employee -> те же права, что legacy pilot (/trade-in/** только). -# manager -> employee + задел /api/v1/team/** (роутер появится в #2554). +# 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, его же с трейлинг-слэшем и подпути через '/', но НЕ соседей по +# префиксу (см. 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 понимает ТОЛЬКО три формы: +# '/**' | '/**' | точный путь (строгое равенство). +# app.core.auth._glob_to_regex (roles.yaml) понимает сверх этого ещё одиночную +# '*' ('/foo/*' = один сегмент). Паттерн с одиночной '*', скопированный сюда из +# roles.yaml, станет ЛИТЕРАЛЬНОЙ строкой и МОЛЧА перестанет что-либо запрещать — +# без ошибки на импорте и без падения тестов, если на него нет прямого теста. +# Т.е. в DB_ROLE_PATHS допустимы только '/**', '/**' и точный путь; +# одиночная '*' здесь = 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/**"], + [ + "/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/**"], + [ + "/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": (["/**"], []), } diff --git a/tradein-mvp/backend/tests/test_auth_api.py b/tradein-mvp/backend/tests/test_auth_api.py index 8b55fb13..23997f98 100644 --- a/tradein-mvp/backend/tests/test_auth_api.py +++ b/tradein-mvp/backend/tests/test_auth_api.py @@ -191,6 +191,17 @@ def _build_test_app(store: _Store) -> FastAPI: 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) @@ -376,6 +387,12 @@ def test_me_with_session_cookie_returns_db_role(client: TestClient, store: _Stor 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" @@ -387,7 +404,12 @@ def test_me_manager_role_gets_team_path(client: TestClient, store: _Store) -> No resp = client.get("/api/v1/me") assert resp.status_code == 200 - assert "/api/v1/team/**" in resp.json()["allowed_paths"] + 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: @@ -477,6 +499,61 @@ def test_session_user_can_reach_tradein_but_not_admin(client: TestClient, store: 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 diff --git a/tradein-mvp/backend/tests/test_auth_session.py b/tradein-mvp/backend/tests/test_auth_session.py index 650186fa..b45ef98a 100644 --- a/tradein-mvp/backend/tests/test_auth_session.py +++ b/tradein-mvp/backend/tests/test_auth_session.py @@ -294,6 +294,27 @@ def test_get_db_role_scope_manager_adds_team_path() -> None: 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 == ["/**"] diff --git a/tradein-mvp/backend/tests/test_rbac.py b/tradein-mvp/backend/tests/test_rbac.py index 6fec305c..8c8b64ab 100644 --- a/tradein-mvp/backend/tests/test_rbac.py +++ b/tradein-mvp/backend/tests/test_rbac.py @@ -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,6 +69,16 @@ 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"} @@ -355,3 +365,132 @@ 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/", +) + +# Основной продукт — не должен быть задет 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-управление); + решение не распространять на неё клиентский deny осознанное, а не забытое. + """ + for path in _INTERNAL_TOOL_PATHS: + assert auth_mod.is_path_allowed("admin", path), f"admin lost access to {path}" + assert auth_mod.is_path_allowed("analyst", path), ( + f"analyst lost access to {path} — если это намеренно, обнови этот тест " + f"и комментарий у роли analyst в auth/roles.yaml" + ) + + +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 со слэшем и подпути через '/', но НЕ соседей по + префиксу (дефис не матчится). Именно поэтому глоб-форма безопасна как + замена точного пути: '/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}" diff --git a/tradein-mvp/frontend/src/app/sale-share/page.tsx b/tradein-mvp/frontend/src/app/sale-share/page.tsx index df6eae51..e0c1fa35 100644 --- a/tradein-mvp/frontend/src/app/sale-share/page.tsx +++ b/tradein-mvp/frontend/src/app/sale-share/page.tsx @@ -5,7 +5,16 @@ * Порог % → дома вторички, где доля квартир, выставленных на продажу, ≥ порога. * Сигнал для девелопера: расселение / инвест-выход / проблемный дом. * - * Доступ: 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` + * (`auth/roles.yaml`). Роль `analyst` сознательно не в deny — внутренняя. + * + * 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"; diff --git a/tradein-mvp/frontend/src/components/trade-in/Topbar.tsx b/tradein-mvp/frontend/src/components/trade-in/Topbar.tsx index c7b88064..7769adc9 100644 --- a/tradein-mvp/frontend/src/components/trade-in/Topbar.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/Topbar.tsx @@ -107,6 +107,23 @@ 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; @@ -122,7 +139,11 @@ const NAV_ITEMS: Array<{ 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", @@ -130,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 их не видел. { From 61b329e724acdbb39813820294f0e3a3287e6bdc Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 31 Jul 2026 18:36:12 +0300 Subject: [PATCH 32/41] =?UTF-8?q?fix(tradein/geocoder):=20fail-closed=20?= =?UTF-8?q?=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=20=D0=95=D0=9A=D0=91-=D1=80?= =?UTF-8?q?=D0=B5=D0=B5=D1=81=D1=82=D1=80=D0=BE=D0=B2=20=D0=B2=D0=BC=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=BE=20=D1=81=D0=BF=D0=B8=D1=81=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=B3=D0=BE=D1=80=D0=BE=D0=B4=D0=BE=D0=B2=20(#2582)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EKB-only локальные тиры (geoportal/cad_buildings) держались на списке из 37 городов области: "нет в списке -> считаем Екатеринбургом". Молча ломало любой адрес вне списка, включая ДРУГИЕ РЕГИОНЫ РФ целиком -- подтверждено живыми случаями в geocode_cache (Ялта/Крым, Трёхгорный/Челябинская обл., пос. Лёвиха, Димитровград/Ульяновская обл. -- все получили координаты ЕКБ). Новый гейт `_ekb_local_tiers_allowed`: локальные тиры разрешены только если Екатеринбург подтверждён явно, ЛИБО в адресе вообще не назван город/регион (bare "Ленина 1" -- основной трафик формы оценки, дефолт ЕКБ оправдан и сохранён без изменений). Если назван ЛЮБОЙ другой топоним -- известный город списка (`_names_non_ekb_city`, без изменений) или незнакомый (`_names_unrecognized_locality` -- новый структурный маркер "обл./край/ республика/г./пос./с.", БЕЗ "д." -- коллизия с house-маркером "д. 5") -- гейт закрыт, идём во внешние oblast/nation-aware провайдеры. city_hint-логика #2580 не тронута (тот же файл, сосед по цели). --- tradein-mvp/backend/app/services/geocoder.py | 87 +++++++++-- .../backend/tests/test_geocoder_bbox.py | 64 +++++++++ .../tests/test_geocoder_cadastral_matcher.py | 135 ++++++++++++++++++ 3 files changed, 276 insertions(+), 10 deletions(-) diff --git a/tradein-mvp/backend/app/services/geocoder.py b/tradein-mvp/backend/app/services/geocoder.py index 1bf0817f..0a5d8ebf 100644 --- a/tradein-mvp/backend/app/services/geocoder.py +++ b/tradein-mvp/backend/app/services/geocoder.py @@ -231,6 +231,68 @@ def _names_non_ekb_city(address: str) -> bool: return False +# ── Fail-closed EKB-local-tiers gate (#2582) ───────────────────────────────── +# `_names_non_ekb_city` выше ловит только 37 ПЕРЕЧИСЛЕННЫХ городов области — +# любой другой топоним (другой регион РФ целиком, ИЛИ областной город вне +# списка: Реж/Арамиль/Рефтинский/Лёвиха) молча проходил как «не назван», и +# EKB-only локальные тиры (geoportal/cad_buildings) снапали улицу+дом в ЕКБ по +# коллизии (см. живые случаи в geocode_cache: «республика крым, ялта, ...» → +# координаты ЕКБ, «челябинская обл., трёхгорный, ...» → координаты ЕКБ). +# +# Вместо перечисления (сама причина бага — список никогда не покроет все +# города РФ) используем общий структурный признак «в адресе назван +# город/регион»: маркер-слово, которое НИКОГДА не относится к улице/дому +# («обл.», «край», «республика», «г.», «пос.», «с.»). "д." сюда намеренно НЕ +# входит — это маркер дома (`_HOUSE_MARKER`: «ул. Ленина, д. 5» — основной +# формат ввода номера дома), включение сломало бы гейт почти на каждом адресе. +_LOCALITY_MARKER_RE = re.compile( + r"\b(?:обл\.?|область|край|республика|респ\.?|г|пос\.?|посёлок|поселок|с\.)\b" +) + + +def _names_unrecognized_locality(address: str) -> bool: + """True если адрес называет топоним, но это не Екатеринбург и не город из + известного списка `SVERDLOVSK_OBLAST_CITIES` (#2582). + + Отличает «город не назван» (bare «Ленина 1» — так приходит основной + трафик формы оценки; geoportal резолвит его как ЕКБ и это оправданный + дефолт) от «назван НЕЗНАКОМЫЙ город» (Ялта/Трёхгорный/Реж/Лёвиха — + структурный маркер топонима есть, но это не ЕКБ и не известный город + региона 66) — только во втором случае EKB-only локальные тиры нельзя + пускать. + """ + normalized = " ".join(address.lower().split()) + if _EKATERINBURG_RE.search(normalized): + return False # явный ЕКБ — приоритет, см. _names_non_ekb_city + return bool(_LOCALITY_MARKER_RE.search(normalized)) + + +def _ekb_local_tiers_allowed(address: str) -> bool: + """Fail-closed гейт локальных ЕКБ-тиров geocoder (`geocode()`/`suggest()`, #2582). + + ЕКБ-only локальные реестры (geoportal/cad_buildings) используются ТОЛЬКО + когда Екатеринбург подтверждён явно, или в адресе вообще не назван + никакой город/регион. Если назван ЛЮБОЙ другой топоним — известный город + региона (`_names_non_ekb_city`) или незнакомый (`_names_unrecognized_locality`, + маркер структуры адреса) — гейт закрыт, идём во внешние oblast/nation-aware + провайдеры (Yandex/Nominatim, оба с region cross-check и bbox-фильтром по + всей области/стране — им незнакомый топоним не страшен). + + До #2582 решение принималось от противного: «нет в списке 37 городов → + считаем Екатеринбургом» — молча ломало любой адрес вне списка, включая + другие регионы РФ целиком (см. docstring `_names_unrecognized_locality`). + + Explicit-Екатеринбург проверяется отдельно (не только внутри вложенных + хелперов) — это единственный сигнал, который обязан перевешивать ЛЮБОЙ + маркер топонима (напр. «г. Екатеринбург» сам содержит маркер «г»). + """ + if _EKATERINBURG_RE.search(" ".join(address.lower().split())): + return True # явный ЕКБ — приоритет над любым другим топонимом/маркером + if _names_non_ekb_city(address) or _names_unrecognized_locality(address): + return False + return True # города/региона не названо вообще — основной трафик, дефолт ЕКБ + + # ── Address normalisation ─────────────────────────────────────────────────── def normalize_address(address: str) -> str: """Нормализация для cache lookup: lowercase + trim + collapse whitespace. @@ -1099,11 +1161,12 @@ async def suggest(query: str, db: Session | None = None, limit: int = 8) -> list return [] # Tier 1: cadastral FDW (если db доступна) — самый быстрый, без внешних запросов. - # EKB-only (см. `geocode()` #11) — пропускаем, если query явно называет - # другой город области, иначе не-ЕКБ автокомплит может всплыть ЕКБ-домом - # с совпадающими улица+дом. Внешние тиры (2/3/4 ниже) не гейтим — они уже - # oblast-aware. - if db is not None and not _names_non_ekb_city(query): + # EKB-only fail-closed гейт (#2582, было #11) — пропускаем, если query + # явно называет другой топоним (известный город области ИЛИ незнакомый — + # см. `_ekb_local_tiers_allowed`), иначе не-ЕКБ автокомплит может всплыть + # ЕКБ-домом с совпадающими улица+дом. Внешние тиры (2/3/4 ниже) не гейтим — + # они уже oblast-aware. + if db is not None and _ekb_local_tiers_allowed(query): # 1a. Anchored house-match: парсим street+house → точный матч по дом-маркеру. # Решает кейс «Серова 27» где raw-ILIKE по readable_address давал 0 hits. parsed = _parse_street_house(query.strip()) @@ -1170,11 +1233,15 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None: # 2. Локальные источники по street+house (без внешнего API). parsed = _parse_street_house(address.strip()) - # #11: EKB-only локальные тиры (geoportal/cadastral) не должны отвечать на - # адрес другого города области — иначе улица+дом, коллизящие с ЕКБ-домом - # (напр. "проспект Ленина 1" есть и в Нижнем Тагиле, и в ЕКБ), снапаются в - # ЕКБ. Пропускаем сразу к oblast-aware внешним провайдерам ниже (3/4). - use_local_ekb = not _names_non_ekb_city(address) + # #2582 (было #11): EKB-only локальные тиры (geoportal/cadastral) не должны + # отвечать на адрес другого города/региона — иначе улица+дом, коллизящие с + # ЕКБ-домом (напр. "проспект Ленина 1" есть и в Нижнем Тагиле, и в ЕКБ), + # снапаются в ЕКБ. Fail-closed: разрешаем локальные тиры ТОЛЬКО если + # Екатеринбург подтверждён явно или город/регион вообще не назван — см. + # `_ekb_local_tiers_allowed`. Раньше решение принималось от противного + # (список из 37 городов — «нет в списке → считаем ЕКБ»), из-за чего ЛЮБОЙ + # другой регион РФ (Ялта, Трёхгорный) молча резолвился в координаты ЕКБ. + use_local_ekb = _ekb_local_tiers_allowed(address) # 2a. Геопортал ЕКБ — ПЕРВЫЙ локальный tier (полнее cad_buildings ~на 70%). if use_local_ekb and parsed is not None: diff --git a/tradein-mvp/backend/tests/test_geocoder_bbox.py b/tradein-mvp/backend/tests/test_geocoder_bbox.py index b0afa8ed..bd2750ae 100644 --- a/tradein-mvp/backend/tests/test_geocoder_bbox.py +++ b/tradein-mvp/backend/tests/test_geocoder_bbox.py @@ -21,8 +21,10 @@ from app.services.geocoder import ( 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, @@ -202,6 +204,68 @@ 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), + ], +) +def test_names_unrecognized_locality(address: str, expected: bool) -> None: + assert _names_unrecognized_locality(address) 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), + ], +) +def test_ekb_local_tiers_allowed(address: str, expected: bool) -> None: + assert _ekb_local_tiers_allowed(address) is expected + + # ── Accept-site two-pass tie-break + region cross-check ───────────────────── diff --git a/tradein-mvp/backend/tests/test_geocoder_cadastral_matcher.py b/tradein-mvp/backend/tests/test_geocoder_cadastral_matcher.py index c5d407fc..7bd013af 100644 --- a/tradein-mvp/backend/tests/test_geocoder_cadastral_matcher.py +++ b/tradein-mvp/backend/tests/test_geocoder_cadastral_matcher.py @@ -309,6 +309,98 @@ 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)"), + ], +) +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.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(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", "ЕКБ с явным городом"), + ], +) +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() + + # ── suggest() wiring ───────────────────────────────────────────────────────── @@ -409,3 +501,46 @@ 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 + mock_settings.yandex_geocoder_api_key = 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() From cf35e632db93ee7541db0f376cf5423309da7084 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 31 Jul 2026 18:38:39 +0300 Subject: [PATCH 33/41] =?UTF-8?q?fix(tradein/tasks):=20=D0=B3=D0=BE=D1=80?= =?UTF-8?q?=D0=BE=D0=B4=D1=81=D0=BA=D0=BE=D0=B9=20=D0=B3=D0=B5=D0=B9=D1=82?= =?UTF-8?q?=20=D0=B2=20=D0=BD=D0=BE=D1=87=D0=BD=D0=BE=D0=BC=20=D0=B1=D1=8D?= =?UTF-8?q?=D0=BA=D1=84=D0=B8=D0=BB=D0=BB=D0=B5=20=D0=BA=D0=BE=D0=BE=D1=80?= =?UTF-8?q?=D0=B4=D0=B8=D0=BD=D0=B0=D1=82=20(#2583)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit backfill_coords_from_geoportal брал все listings с lat IS NULL, парсил street+house и матчил напрямую против EKB-only ekb_geoportal_buildings, минуя geocoder.geocode() и его городской гейт (_names_non_ekb_city). Улица+дом могут буквально совпасть между Екатеринбургом и другим городом области ("проспект Ленина 1" есть и в ЕКБ, и в Нижнем Тагиле) — такие адреса получали екатеринбургские координаты и портили радиусные выборки аналогов на этой улице в ЕКБ, а сами исчезали из выборки своего города. Фикс: _names_non_ekb_city(address) перед вызовом _geoportal_house_match — тот же гейт, что уже используется в geocode(). Прямой вызов geoportal-матчера (а не полноценный geocode()) сохранён намеренно — pure local-DB операция без внешнего HTTP, полноценный geocode() добавил бы Nominatim/Yandex вызов на каждый non-EKB адрес backlog'а (лишняя нагрузка на ограниченный Nominatim, Yandex сейчас 403 — #2585). geo_precision оставлен NULL для house-level матчей этого тира — по конвенции 089_listings_geo_precision.sql/geocode_missing.py NULL означает "не coarse", то же значение что geo_precision=None для precise-адресов в geocode_missing_listings; исключать из radius-аналогов нужно только 'city'-fallback. Порядок окон (05:00 geoportal → 06:00 geocode_missing_listings) не менялся: гонка была безвредна для корректно заматченных EKB-адресов, вредна только из-за отсутствия гейта — теперь non-EKB адреса здесь не матчатся вообще и просто ждут oblast-aware провайдеров в следующем окне. Поправлен ложный комментарий в migration 171 ("не-ЕКБ адреса не матчатся — корректно"). Ущерб на проде (SELECT-only, без изменений): 2040 листингов с координатами внутри EKB-bbox (56.65-56.95, 60.40-60.85) при адресе, называющем другой город области (1941 после исключения мкр/р-н/жк-омонимов вроде ЖК "Заречный" внутри ЕКБ). Только ~31 из них совпадают по координатам с ekb_geoportal_buildings/gendesign_cad_buildings — основной массив, вероятно, из других источников координат (не только этот таск). Чистка — отдельный шаг. --- .../backfill_listings_coords_geoportal.py | 47 ++++++++++-- ...hedules_seed_geoportal_coords_backfill.sql | 10 ++- ...test_backfill_listings_coords_geoportal.py | 72 ++++++++++++++++++- 3 files changed, 122 insertions(+), 7 deletions(-) diff --git a/tradein-mvp/backend/app/tasks/backfill_listings_coords_geoportal.py b/tradein-mvp/backend/app/tasks/backfill_listings_coords_geoportal.py index 035f7be1..f95ae077 100644 --- a/tradein-mvp/backend/app/tasks/backfill_listings_coords_geoportal.py +++ b/tradein-mvp/backend/app/tasks/backfill_listings_coords_geoportal.py @@ -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, ) diff --git a/tradein-mvp/backend/data/sql/171_scrape_schedules_seed_geoportal_coords_backfill.sql b/tradein-mvp/backend/data/sql/171_scrape_schedules_seed_geoportal_coords_backfill.sql index c4492400..4be5641c 100644 --- a/tradein-mvp/backend/data/sql/171_scrape_schedules_seed_geoportal_coords_backfill.sql +++ b/tradein-mvp/backend/data/sql/171_scrape_schedules_seed_geoportal_coords_backfill.sql @@ -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. diff --git a/tradein-mvp/backend/tests/tasks/test_backfill_listings_coords_geoportal.py b/tradein-mvp/backend/tests/tasks/test_backfill_listings_coords_geoportal.py index c2db0780..23be177c 100644 --- a/tradein-mvp/backend/tests/tasks/test_backfill_listings_coords_geoportal.py +++ b/tradein-mvp/backend/tests/tasks/test_backfill_listings_coords_geoportal.py @@ -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 ─────────────────────────────────────────────────────────────── From 49ee85aae2d4de3b61f57ce010fc5b8ef086185a Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 31 Jul 2026 18:42:40 +0300 Subject: [PATCH 34/41] =?UTF-8?q?fix(tradein/ui):=20=D0=BD=D0=B5=20=D0=BD?= =?UTF-8?q?=D0=B0=D0=B7=D1=8B=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BD=D0=B5=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D1=82=D0=B2=D0=B5=D1=80=D0=B6=D0=B4=D1=91=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D0=B3=D0=BE=D1=80=D0=BE=D0=B4=20=D0=B2=20?= =?UTF-8?q?=D0=BF=D0=BB=D0=B0=D1=88=D0=BA=D0=B5=20=D0=BD=D0=B5=D0=BE=D0=BF?= =?UTF-8?q?=D1=80=D0=B5=D0=B4=D0=B5=D0=BB=D1=91=D0=BD=D0=BD=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=B8=20(#2576)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deep-review R3: плашка city_ambiguous всегда подставляла {city} — внутреннее состояние с дефолтом "Екатеринбург" (initCityLabel), а не то, что реально определил бэкенд (в ответе только булев target_city_ambiguous, угаданного города там нет). Ровно в целевом сценарии фикса — нетронутая форма, «Ленина 1», cityConfirmed=false — текст утверждал «если это не Екатеринбург» независимо от реального результата (там мог быть Нижний Тагил) — та же нечестность, которую предыдущий коммит убирал из запроса, только в тексте. Текст плашки теперь ветвится по cityConfirmed: - cityConfirmed=true (город реально был подтверждён и отправлен) — прежний текст с конкретным {city} уместен, не меняю. - cityConfirmed=false (это и есть путь, где cityAmbiguous обычно и срабатывает после предыдущего коммита) — нейтральная формулировка без упоминания конкретного города: «Если это неверно, выберите город выше и повторите оценку.» tsc --noEmit / next lint / next build — чисто (те же 2 pre-existing warning в несвязанных файлах). --- .../components/trade-in/v2/ParamsPanel.tsx | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx index 0555150b..17740970 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx @@ -1653,12 +1653,29 @@ export default function ParamsPanel({ )} {/* #2576 — честная, не блокирующая подсказка: последняя оценка вернулась с target_city_ambiguous, город определил геокодер - сам. role="status" (не alert) — это информация, не ошибка. */} + сам. role="status" (не alert) — это информация, не ошибка. + Deep-review R3: текст различает cityConfirmed — если бэкенд + не получил city_hint (`cityConfirmed === false`), `city` в + этот момент внутренний best-guess ("Екатеринбург"), а НЕ то, + что реально нашёл геокодер (в ответе вообще нет угаданного + города, только сам булев признак) — называть конкретный город + в этом случае было бы той же нечестностью, которую фикс + убирал из запроса, только в тексте плашки. */} {cityAmbiguous && !fieldErrors.address && (
- Город определён автоматически — результат может относиться к - другому населённому пункту области. Если это не {city}, - выберите верный город выше и повторите оценку. + {cityConfirmed ? ( + <> + Город определён автоматически — результат может + относиться к другому населённому пункту области. Если это + не {city}, выберите верный город выше и повторите оценку. + + ) : ( + <> + Город определён автоматически — результат может + относиться к другому населённому пункту области. Если это + неверно, выберите город выше и повторите оценку. + + )}
)} From 62aca75994865ace3f0a8bdda6557e77e1a0125d Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 31 Jul 2026 19:08:42 +0300 Subject: [PATCH 35/41] =?UTF-8?q?fix(tradein/ui):=20=D0=BD=D0=B5=20=D0=BE?= =?UTF-8?q?=D0=B1=D1=80=D0=B5=D0=B7=D0=B0=D1=82=D1=8C=20=D0=B4=D0=BB=D0=B8?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D0=B5=20=D0=BD=D0=B0=D0=B7=D0=B2=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=B3=D0=BE=D1=80=D0=BE=D0=B4=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=B2=20=D1=81=D0=B5=D0=BB=D0=B5=D0=BA=D1=82=D0=BE=D1=80=D0=B5?= =?UTF-8?q?=20(#2576)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Живая проверка прода после #2584: триггер городского дропдауна (pp-dd-trigger-dashed, ParamsPanel.tsx) — фиксированные 176x22px, font-size 11px. Дефолтный лейбл "Определить автоматически" (~146px в Manrope 400, замерено opentype.js против реального шрифта прода) не влезал в однострочный бюджет ~143px, переносился на вторую строку и обрезался высотой триггера. Заменил UNCONFIRMED_CITY_LABEL на "Автоопределение" (~96px, большой запас) — сохраняет смысл, перекликается с "Авто" у РАДИУС АНАЛИЗА, но не двусмысленно рядом с названиями городов. Самое длинное название города в CITY_LABELS, "Каменск-Уральский" (~108px), укладывается в тот же бюджет без переноса — второго фикса не требует. --- tradein-mvp/frontend/src/lib/city-registry.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tradein-mvp/frontend/src/lib/city-registry.ts b/tradein-mvp/frontend/src/lib/city-registry.ts index b8cd75f2..16178d6c 100644 --- a/tradein-mvp/frontend/src/lib/city-registry.ts +++ b/tradein-mvp/frontend/src/lib/city-registry.ts @@ -64,8 +64,20 @@ 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 = "Определить автоматически"; +export const UNCONFIRMED_CITY_LABEL = "Автоопределение"; /** RU-лейблы для дропдауна — тот же формат (string[]), что dropdownOptions.* в v2/fixtures.ts. */ export const CITY_LABELS: string[] = OBLAST_CITIES.map((c) => c.label); From af609c18f31b34d85391a99ad6c191292605bbac Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 31 Jul 2026 19:17:47 +0300 Subject: [PATCH 36/41] =?UTF-8?q?fix(tradein/geocoder):=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=87=D0=B8=D0=BD=D0=B8=D1=82=D1=8C=20=D0=BC=D0=B0=D1=80=D0=BA?= =?UTF-8?q?=D0=B5=D1=80=20=D1=81=D0=B5=D0=BB=D0=B0=20+=20fail-closed=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D1=8F=D0=B2=D0=BD=D0=BE=D0=B3=D0=BE=20cit?= =?UTF-8?q?y=5Fhint=20(#2582)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit H1 (deep-review PR #2589): `с\.` в _LOCALITY_MARKER_RE был мертворождён — хвостовой \b не может сработать между "." и пробелом (оба не-словесные), формат «с. <село>» (104/77к прод-адресов) не ловился НИ РАЗУ, зато ложно срабатывал на инициалах «им с.п.королева». Живой инцидент воспроизведён: "с. Новоалексеевское, ул. 8 Марта,37" снапалось в ЕКБ (совпадение до 5-го знака, село в 35 км западнее). Заменил на lookahead-форму для однобуквенных маркеров (с/д/дер/пгт/ст): "\.\s*(?=[а-яё])" — различает "д. 5" (дом-номер, не матчит) от "д. Кашино" (деревня, матчит), что заодно позволило безопасно включить "д."/"дер." (раньше исключённые из-за коллизии с house-маркером). M2: гейт по city_hint оставался list-based (_names_non_ekb_city(city_hint)) — для СТРУКТУРНОГО хинта (не свободного текста) fail-closed форма должна быть обратной: "не Екатеринбург → закрыто", а не "есть в списке 37 → закрыто". city_hint принимает произвольную строку без валидации (API напрямую, минуя 6-городный UI-selector) — city_hint="Реж" держал гейт открытым, geoportal отдавал точный ЕКБ-дом, city_ambiguous=False делало это уверенно-неверным. M3: добавлены изолирующие тесты — предыдущая параметризация "незнакомый топоним" всегда несла ещё и "обл."/"республика"/"край" одновременно с проверяемым сигналом, поэтому мёртвый "с\." прошёл незамеченным. Плюс честно зафиксирована известная дыра (гейт по city_hint) — "реж, ленина 5" без маркера остаётся в ЕКБ-дефолте (архитектурное решение отдельно, не список городов). docstring `_names_unrecognized_locality`/`_ekb_local_tiers_allowed` приведён в соответствие с фактическим поведением regex. --- tradein-mvp/backend/app/services/geocoder.py | 79 ++++++++++++++----- .../backend/tests/test_geocoder_bbox.py | 67 +++++++++++++++- .../tests/test_geocoder_cadastral_matcher.py | 52 ++++++++++++ 3 files changed, 174 insertions(+), 24 deletions(-) diff --git a/tradein-mvp/backend/app/services/geocoder.py b/tradein-mvp/backend/app/services/geocoder.py index 0f450ccc..0a503cca 100644 --- a/tradein-mvp/backend/app/services/geocoder.py +++ b/tradein-mvp/backend/app/services/geocoder.py @@ -295,11 +295,29 @@ def _names_non_ekb_city(address: str) -> bool: # Вместо перечисления (сама причина бага — список никогда не покроет все # города РФ) используем общий структурный признак «в адресе назван # город/регион»: маркер-слово, которое НИКОГДА не относится к улице/дому -# («обл.», «край», «республика», «г.», «пос.», «с.»). "д." сюда намеренно НЕ -# входит — это маркер дома (`_HOUSE_MARKER`: «ул. Ленина, д. 5» — основной -# формат ввода номера дома), включение сломало бы гейт почти на каждом адресе. +# («обл.», «край», «республика», «г.», «пос.»). +# +# Два разных класса маркеров — разная форма regex: +# 1. Маркеры БЕЗ обязательной точки после (обл/область/край/республика/респ/ +# г/пос/посёлок/поселок) — сохраняют старую форму `\bслово\.?\b`. Точка +# опциональна и есть трейлинг `\b`, который для этих слов срабатывает и +# без точки (следующий символ — пробел/запятая, не-словесный). +# 2. Маркеры-ОДНОБУКВЕННЫЕ сокращения, где точка ОБЯЗАТЕЛЬНА для отличия от +# других смыслов той же буквы («с» — типографский мусор/что угодно, «д» — +# дом-маркер `_HOUSE_MARKER`) — «с.», «д.», «дер.», «пгт.», «ст.». Тут +# хвостовой `\b` В ПРИНЦИПЕ не может сработать после точки перед пробелом +# (оба не-словесные — на стыке `.` и ` ` нет границы \w/не-\w) — было +# смерто-рождённой формой, вылавливавшей 0 из 104 прод-адресов «с. <село>» +# (deep-review PR #2589, H1). Вместо `\b` в конце — lookahead на кириллицу +# сразу после точки (с опциональными пробелами): `\.\s*(?=[а-яё])`. +# Это и различает «д. 5» (дом-маркер, номер, НЕ топоним — после точки +# цифра, lookahead не матчит) от «д. Кашино» (деревня — после точки буква, +# матчит) БЕЗ явного исключения "д." из списка: раньше "д." исключали +# целиком, потому что не было способа отличить дом от деревни — lookahead +# даёт этот способ, поэтому "д."/"дер." теперь можно включить безопасно. _LOCALITY_MARKER_RE = re.compile( - r"\b(?:обл\.?|область|край|республика|респ\.?|г|пос\.?|посёлок|поселок|с\.)\b" + r"\b(?:обл\.?|область|край|республика|респ\.?|г|пос\.?|посёлок|поселок)\b" + r"|\b(?:с|д|дер|пгт|ст)\.\s*(?=[а-яё])" ) @@ -309,10 +327,18 @@ def _names_unrecognized_locality(address: str) -> bool: Отличает «город не назван» (bare «Ленина 1» — так приходит основной трафик формы оценки; geoportal резолвит его как ЕКБ и это оправданный - дефолт) от «назван НЕЗНАКОМЫЙ город» (Ялта/Трёхгорный/Реж/Лёвиха — - структурный маркер топонима есть, но это не ЕКБ и не известный город - региона 66) — только во втором случае EKB-only локальные тиры нельзя - пускать. + дефолт) от «назван НЕЗНАКОМЫЙ город» (Ялта/Трёхгорный/Лёвиха, «с. Кашино», + «д. Родина» — структурный маркер топонима есть, но это не ЕКБ и не + известный город региона 66) — только во втором случае EKB-only локальные + тиры нельзя пускать. + + Известная дыра (сознательно, не «баг» — закрывать architectural-решением + отдельно, не перечислением городов): голый «Реж, Ленина 5» / «Арамиль, + Мира 1» БЕЗ структурного маркера («обл.»/«г.»/«с.» и т.п.) и без города + из списка `SVERDLOVSK_OBLAST_CITIES` не ловится — падает в «город не + назван» и уходит в ЕКБ-дефолт. Все живые прод-подмены (geocode_cache) + были с маркером/региональным префиксом — см. тесты `test_ekb_local_tiers_allowed` + (изолирующий кейс на голое «реж, ленина 5» зафиксирован как известная дыра). """ normalized = " ".join(address.lower().split()) if _EKATERINBURG_RE.search(normalized): @@ -330,15 +356,25 @@ def _ekb_local_tiers_allowed(address: str, city_hint: str | None = None) -> bool - известный город региона 66 в ТЕКСТЕ адреса (`_names_non_ekb_city`, без изменений, #11); - - `city_hint` называет известный город региона 66, отличный от ЕКБ — - #2580 (C1): без этой проверки `city_hint` мёртв для локальных тиров — - caller передал, например, "Нижний Тагил" для текста без города, но - всё равно получал бы "точный" ЕКБ-хит с `city_ambiguous=False` (хинт - ведь был!) — ложно-уверенный неверный результат; + - `city_hint` ЗАДАН и НЕ является Екатеринбургом (`hint_blocks` ниже) — + #2580 (C1) + #2589 (deep-review M2): `city_hint` — явный СТРУКТУРНЫЙ + вход от caller'а (не свободный текст адреса), поэтому для него + fail-closed форма ОБРАТНАЯ по сравнению с текстом адреса: «не + Екатеринбург → закрыто», а не «есть в списке 37 → закрыто». Список + 37 годился бы, только если единственный риск — известные города; но + `city_hint` принимает произвольную строку без валидации (в т.ч. через + API напрямую, минуя 6-городный selector UI) — «Реж»/любой седьмой + город, добавленный в реестр, до сих пор не в списке 37 → список-based + проверка держала гейт открытым, geoportal отдавал точный ЕКБ-дом, а + `city_ambiguous=False` (хинт ведь БЫЛ) делало это уверенно-неверным — + худший случай (без hint был бы честный `city_ambiguous=True`); - незнакомый топоним в ТЕКСТЕ адреса — структурный маркер - (`_names_unrecognized_locality`, #2582: «обл./край/республика/г./пос./ - с.») — покрывает города вне списка 37 (Реж/Лёвиха) и ДРУГИЕ РЕГИОНЫ РФ - целиком (Ялта, Трёхгорный), не требуя их перечислять. + (`_names_unrecognized_locality`, #2582: «обл./край/республика/г./пос.» + и однобуквенные «с./д./дер./пгт./ст.» с lookahead на кириллицу после + точки) — покрывает города вне списка 37 С маркером (Лёвиха, «с. + Новоалексеевское») и ДРУГИЕ РЕГИОНЫ РФ целиком (Ялта, Трёхгорный), не + требуя их перечислять. Голый город без маркера и вне списка (Реж без + «обл.») — известная дыра, см. `_names_unrecognized_locality`. До #2582 решение по третьему пункту принималось от противного: «нет в списке 37 городов → считаем Екатеринбургом» — молча ломало любой адрес @@ -348,13 +384,14 @@ def _ekb_local_tiers_allowed(address: str, city_hint: str | None = None) -> bool `city_hint`, противоречащий явному "Екатеринбург" в тексте адреса (`_names_non_ekb_city(address)` для такого текста вернёт False и не закроет гейт сам по себе — Екатеринбург в тексте не мешает), тем не менее - ЗАКРОЕТ гейт через `hint_names_non_ekb` — тот же trade-off, что уже был - одобрен в #2580 (`use_local_ekb`), сюда не переписываем. Нормальный кейс — - caller не передаёт противоречащие address и city_hint одновременно. + ЗАКРОЕТ гейт через `hint_blocks` — тот же trade-off, что уже был одобрен + в #2580 (`use_local_ekb`), сюда не переписываем. Нормальный кейс — caller + не передаёт противоречащие address и city_hint одновременно. """ - hint_names_non_ekb = bool(city_hint) and _names_non_ekb_city(city_hint) + hint = " ".join((city_hint or "").lower().split()) + hint_blocks = bool(hint) and not _EKATERINBURG_RE.search(hint) return not ( - _names_non_ekb_city(address) or hint_names_non_ekb or _names_unrecognized_locality(address) + _names_non_ekb_city(address) or hint_blocks or _names_unrecognized_locality(address) ) diff --git a/tradein-mvp/backend/tests/test_geocoder_bbox.py b/tradein-mvp/backend/tests/test_geocoder_bbox.py index f5d49d3b..eacc7d3a 100644 --- a/tradein-mvp/backend/tests/test_geocoder_bbox.py +++ b/tradein-mvp/backend/tests/test_geocoder_bbox.py @@ -18,6 +18,7 @@ import httpx import pytest from app.services.geocoder import ( + _LOCALITY_MARKER_RE, EKB_BBOX_TIGHT, EKB_BBOX_WIDE, OBLAST66_BBOX, @@ -232,12 +233,57 @@ def test_names_non_ekb_city(address: str, expected: bool) -> None: # явный Екатеринбург побеждает даже при наличии маркера "г": ("г. екатеринбург, ул. малышева, 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", [ @@ -260,16 +306,24 @@ def test_names_unrecognized_locality(address: str, expected: bool) -> None: ("проспект Ленина, 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) ─────────────── +# ── _ekb_local_tiers_allowed × city_hint (#2580 merge, #2582, #2589 M2) ───── # Обе логики независимо закрывают гейт (OR трёх сигналов) — проверяем, что -# ни одна из четырёх веток не стала недостижимой после слияния #2580/#2582: +# ни одна из веток не стала недостижимой после слияния #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="Екатеринбург" (без конфликта в тексте) → открыто. @@ -282,6 +336,12 @@ def test_ekb_local_tiers_allowed(address: str, expected: bool) -> None: # если сам текст адреса города вообще не называет (#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: маркер в тексте"), @@ -289,7 +349,8 @@ def test_ekb_local_tiers_allowed(address: str, expected: bool) -> None: ("Малышева 30", None, True, "C: ничего не названо"), ("Серова 27", "", True, "C: пустой hint == отсутствию hint'а"), # D: явный city_hint="Екатеринбург" без конфликта в тексте → открыто — - # ключевая проверка, что #2580 C1 не сломал дефолтный ЕКБ-путь через hint. + # ключевая проверка, что #2580 C1 / #2589 M2 не сломали дефолтный + # ЕКБ-путь через hint. ("Ленина, 1", "Екатеринбург", True, "D: hint=Екатеринбург"), ("Серова 27", "екатеринбург", True, "D: hint=екатеринбург (регистр)"), ], diff --git a/tradein-mvp/backend/tests/test_geocoder_cadastral_matcher.py b/tradein-mvp/backend/tests/test_geocoder_cadastral_matcher.py index 70707996..3e179c8c 100644 --- a/tradein-mvp/backend/tests/test_geocoder_cadastral_matcher.py +++ b/tradein-mvp/backend/tests/test_geocoder_cadastral_matcher.py @@ -326,6 +326,10 @@ async def test_geocode_skips_ekb_local_tiers_for_non_ekb_city() -> None: "Лёвиха (город области ВНЕ списка 37)", ), ("свердловская обл., реж, ул. ленина, 5", "Реж (город области ВНЕ списка 37)"), + ( + "с. Новоалексеевское, ул. 8 Марта,37", + "Новоалексеевское (живой инцидент #2589 H1 — «с.»-маркер без доп. «обл.»)", + ), ], ) async def test_geocode_skips_ekb_local_tiers_for_unrecognized_locality( @@ -372,6 +376,7 @@ async def test_geocode_skips_ekb_local_tiers_for_unrecognized_locality( ("Серова 27", "ЕКБ без города (основной трафик формы оценки)"), ("Малышева 30", "ЕКБ без города (основной трафик формы оценки)"), ("г. Екатеринбург, ул. Серова, д. 27", "ЕКБ с явным городом"), + ("ул. Серова, д. 27", "house-маркер «д.» не закрывает гейт (#2589 H1 regression)"), ], ) async def test_geocode_still_uses_ekb_local_tiers_for_real_ekb_address( @@ -427,6 +432,53 @@ async def test_geocode_explicit_ekaterinburg_city_hint_still_uses_local_tiers() 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.settings") as mock_settings, + patch( + "app.services.geocoder._nominatim_lookup", + new_callable=AsyncMock, + return_value=rezh_result, + ), + ): + mock_settings.yandex_geocoder_api_key = None + 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 ───────────────────────────────────────────────────────── From 762d1f68c6cb99a14e84ecf0c336c2952929cb4f Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 31 Jul 2026 20:24:24 +0300 Subject: [PATCH 37/41] =?UTF-8?q?chore(ci+rbac):=20auth/**=20=D0=B2=20path?= =?UTF-8?q?s-=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=D1=8B=20+=20=C2=AB=D0=9F?= =?UTF-8?q?=D0=BE=D0=B8=D1=81=D0=BA=20=D0=B4=D0=BE=D0=BC=D0=BE=D0=B2=C2=BB?= =?UTF-8?q?=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D1=83=20=D0=B0=D0=B4?= =?UTF-8?q?=D0=BC=D0=B8=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Две связанные вещи, обе — по решению владельца продукта. 1. auth/** в paths-фильтры обоих CI (ci.yml, ci-tradein.yml). auth/roles.yaml — общий RBAC-конфиг двух стеков, но лежит в корне репы и не попадал НИ В ОДИН фильтр: правка ролей/пользователей не запускала ни backend-, ни tradein-сьют. Так 2026-07-30 в main уехал красный test_get_role_known_users (user2 переведён в expired, тест ждал pilot) — обнаружен только вручную и починен в PR #2587. Теперь правка roles.yaml гоняет оба гейта. 2. «Поиск домов» (/trade-in/sale-share) — ТЕСТОВЫЙ продукт, доступ только у админа. Раньше он был закрыт от клиентских ролей (employee/manager/pilot), но оставался открыт внутренней роли analyst. «Только у админа» включает и внутренние роли → analyst добавлен в deny по sale-share. Асимметрия с «Кэшем» намеренная и запиннена тестом: Кэш — не продукт, а диагностика кэшей/скраперов, т.е. ровно тот инструмент, ради которого роль analyst заведена; ему он оставлен. Замеры после правки (реальный is_path_allowed поверх roles.yaml): роль | Поиск домов | Кэш | ядро продукта admin | True | True | True analyst | False | True | True pilot | False | False| True Тест test_yaml_roles_deliberately_outside_client_deny переписан: пиннит ОБЕ стороны асимметрии, а не только «analyst видит всё». Набор внутренних путей разрезан на _SALE_SHARE_PATHS / _CACHE_TOOL_PATHS с assert'ом, что разрез покрывает исходный набор целиком — иначе новый путь добавят и забудут отнести к продукту, оставив analyst непроверенным. Заодно поправлены устаревшие комментарии «Доступ: pilot + admin» в Caddyfile (vanity-редирект gendsgn.ru/sale-share) и в докстринге самой страницы. Тесты: 77 passed (tradein rbac/auth_session/auth_api) + 24 passed (site-finder). tsc --noEmit + next build — зелёные. YAML обоих workflow провалидирован. --- .forgejo/workflows/ci-tradein.yml | 7 +++ .forgejo/workflows/ci.yml | 8 ++++ Caddyfile | 17 ++++--- auth/roles.yaml | 19 ++++++-- tradein-mvp/backend/tests/test_rbac.py | 44 +++++++++++++++++-- .../frontend/src/app/sale-share/page.tsx | 20 +++++---- 6 files changed, 94 insertions(+), 21 deletions(-) diff --git a/.forgejo/workflows/ci-tradein.yml b/.forgejo/workflows/ci-tradein.yml index 11396792..4a0bc79c 100644 --- a/.forgejo/workflows/ci-tradein.yml +++ b/.forgejo/workflows/ci-tradein.yml @@ -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/**' diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 48a47220..a261bc30 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -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/**' diff --git a/Caddyfile b/Caddyfile index 84f137de..ad74ba62 100644 --- a/Caddyfile +++ b/Caddyfile @@ -139,12 +139,19 @@ gendsgn.ru { # /trade-in/sale-share, для которого теперь нет Caddy basic_auth (как и # для остального /trade-in). Это НЕ делает страницу публичной: она всё # ещё за собственной авторизацией trade-in — `RouteGuard` во фронте - # (`app/layout.tsx`, «Доступ: pilot + admin») и сессия для - # `/api/v1/buildings/sale-share*` на бэке; без валидной сессии юзер - # получит редирект на /login, а не контент. Смысл переноса — не открыть - # страницу всем, а убрать несогласованность: короткий URL не должен быть - # строже (Caddy basic_auth) целевого адреса, к которому и так уже нет + # (`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 diff --git a/auth/roles.yaml b/auth/roles.yaml index 12131be7..1273eb38 100644 --- a/auth/roles.yaml +++ b/auth/roles.yaml @@ -81,15 +81,28 @@ roles: # для любого role != "admin" → analyst авто-403 на admin-API без доп. кода. # deny ниже драйвит фронтовый RouteGuard (deny_paths из /me) для UI-gating # /admin/** страниц. - # NB: клиентский deny 2026-07-31 («Доля в продаже» / «Кэш», см. pilot выше) - # на analyst СОЗНАТЕЛЬНО не распространён — analyst внутренняя роль и оба - # раздела для неё рабочий инструмент. Это не забытая дыра. + # Клиентский 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), чтобы дойти до фронта и увидеть diff --git a/tradein-mvp/backend/tests/test_rbac.py b/tradein-mvp/backend/tests/test_rbac.py index 8c8b64ab..ea0837dd 100644 --- a/tradein-mvp/backend/tests/test_rbac.py +++ b/tradein-mvp/backend/tests/test_rbac.py @@ -395,6 +395,27 @@ _INTERNAL_TOOL_PATHS = ( "/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") @@ -422,14 +443,29 @@ def test_yaml_roles_deliberately_outside_client_deny() -> None: ровно тот класс рассинхрона, что уже случился с user2 (см. backend/tests/test_rbac.py::test_get_role_known_users). - `analyst` — внутренняя роль (paths "/**", deny только admin-управление); - решение не распространять на неё клиентский deny осознанное, а не забытое. + `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 lost access to {path} — если это намеренно, обнови этот тест " - f"и комментарий у роли analyst в auth/roles.yaml" + f"analyst потерял «Кэш» ({path}) — это его рабочий инструмент; если " + f"закрыли намеренно, обнови тест И комментарий у роли analyst" ) diff --git a/tradein-mvp/frontend/src/app/sale-share/page.tsx b/tradein-mvp/frontend/src/app/sale-share/page.tsx index e0c1fa35..e4db0bf5 100644 --- a/tradein-mvp/frontend/src/app/sale-share/page.tsx +++ b/tradein-mvp/frontend/src/app/sale-share/page.tsx @@ -5,16 +5,18 @@ * Порог % → дома вторички, где доля квартир, выставленных на продажу, ≥ порога. * Сигнал для девелопера: расселение / инвест-выход / проблемный дом. * - * Доступ: ТОЛЬКО 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` - * (`auth/roles.yaml`). Роль `analyst` сознательно не в deny — внутренняя. + * Доступ: ТОЛЬКО 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 — сейчас скоуп только ролевой, выдать его отдельному клиенту нечем. + * 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"; From 146139fbf97baea90dc3ff6abe6c3af052d96e20 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 31 Jul 2026 21:01:47 +0300 Subject: [PATCH 38/41] =?UTF-8?q?chore(tradein):=20=D1=83=D0=B4=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D1=82=D1=8C=20=D0=BE=D1=81=D1=82=D0=B0=D1=82=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=B8=D1=81=D1=82=D0=BE=D1=87=D0=BD=D0=B8=D0=BA=D0=B0?= =?UTF-8?q?=20N1=20(#2593)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit N1 не собирается с 16 июня, в scrape_schedules его нет. Миграция 165 удалила источник на 90% (allowlist/scheduler/settings) — оставались точечные литералы: - SourcesMap.tsx: цвет для мёртвого source в легенде карты (fallback серый). - admin.py geocode-missing: N1-ветка address-плейсхолдер фильтра + стале докстринги, упоминавшие N1 как активный источник listings. - test_estimator_source_quota.py: докстринг регрессии с упоминанием N1 среди вытесняемых источников. Данные (382 listings source='n1', is_active=false) не трогаются — все поверхности уже провайдер-агностичны с safe fallback для неизвестных id (source-registry.ts, trade_in_pdf.py _SOURCE_LOGO_COLORS.get, SourcesMap.tsx colorForSource). Денормализованные счётчики (TOTAL_SOURCES/mappers.ts, _TOTAL_SOURCES/trade_in_pdf.py, LIVE_SOURCE_COUNT/source-registry.ts) уже производные от актуальных ростеров без n1 — индексация не затронута. --- tradein-mvp/backend/app/api/v1/admin.py | 13 ++-- .../tests/test_estimator_source_quota.py | 59 ++++++++++++------- .../src/components/trade-in/v2/SourcesMap.tsx | 1 - 3 files changed, 44 insertions(+), 29 deletions(-) diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py index e7df8544..c03e024e 100644 --- a/tradein-mvp/backend/app/api/v1/admin.py +++ b/tradein-mvp/backend/app/api/v1/admin.py @@ -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( diff --git a/tradein-mvp/backend/tests/test_estimator_source_quota.py b/tradein-mvp/backend/tests/test_estimator_source_quota.py index 0b1e562a..9b02b85c 100644 --- a/tradein-mvp/backend/tests/test_estimator_source_quota.py +++ b/tradein-mvp/backend/tests/test_estimator_source_quota.py @@ -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) diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/SourcesMap.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/SourcesMap.tsx index 426aa5a4..fc9df4e3 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/SourcesMap.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/SourcesMap.tsx @@ -89,7 +89,6 @@ const SOURCE_COLORS: Record = { cian: "#1baf7a", yandex: "#eda100", domklik: "#008300", - n1: "#4a3aa7", }; const FALLBACK_SOURCE_COLOR = "#6b7280"; // неизвестный/прочий листинговый источник const COLOR_TARGET = "#f59e0b"; // та же семантика, что и MapCard.COLOR_TARGET From 3d075632a9aa0e66c0a644540b73e2b0b8e056a6 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 31 Jul 2026 21:22:09 +0300 Subject: [PATCH 39/41] =?UTF-8?q?chore(tradein/geocoder):=20=D1=83=D0=B4?= =?UTF-8?q?=D0=B0=D0=BB=D0=B8=D1=82=D1=8C=20=D0=AF=D0=BD=D0=B4=D0=B5=D0=BA?= =?UTF-8?q?=D1=81-=D0=B3=D0=B5=D0=BE=D0=BA=D0=BE=D0=B4=D0=B5=D1=80=20(#259?= =?UTF-8?q?3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Убирает ядро Yandex Geocoder (forward/reverse/suggest lookups + region-check + bias-хелперы + EKB_BBOX dict) из app/services/geocoder.py — Yandex demo-key исчерпан, Nominatim/DaData/локальные ЕКБ-тиры (geoportal/cadastral) остаются единственными живыми провайдерами. Цепочка тиров после удаления: кэш → геопортал ЕКБ → кадастр (house-match) → кадастр (raw) → Nominatim; в подсказках дополнительно DaData. НЕ затронуто (намеренно): Yandex.Недвижимость как источник объявлений (source='yandex', yandex_city_sweep*, providers/yandex/serp.py:geocoderAddress), Avito geocoder (providers/avito/imv.py:_geocode), EKB_BBOX_TIGHT/WIDE, _nominatim_region_ok, scripts/*_yandex_reverse.py и их тесты, tests/fixtures/ yandex_geocode_sample.json (всё ещё используется test_audit_address_mismatch.py). _SNAP_PRECISIONS оставлен с "exact" (недостижимо без Yandex-tier, но дёшево хранить — parity с frontend MapPicker.tsx SNAP_PRECISIONS и не ломает test_snap_precision_useful_exact_and_number). --- tradein-mvp/backend/app/api/v1/geocode.py | 4 +- tradein-mvp/backend/app/core/config.py | 5 - tradein-mvp/backend/app/core/fdw.py | 23 +- tradein-mvp/backend/app/services/estimator.py | 5 +- tradein-mvp/backend/app/services/geocoder.py | 491 ++---------------- .../backend/app/tasks/geocode_missing.py | 2 +- .../tests/services/test_cadastral_reverse.py | 143 +---- .../tests/test_ekb_geoportal_ingest.py | 6 - .../backend/tests/test_geocode_reverse_api.py | 109 +--- .../backend/tests/test_geocoder_bbox.py | 76 +-- .../tests/test_geocoder_cadastral_matcher.py | 18 - .../backend/tests/test_geocoder_city_hint.py | 299 +++-------- 12 files changed, 168 insertions(+), 1013 deletions(-) diff --git a/tradein-mvp/backend/app/api/v1/geocode.py b/tradein-mvp/backend/app/api/v1/geocode.py index 7b981887..e9c25631 100644 --- a/tradein-mvp/backend/app/api/v1/geocode.py +++ b/tradein-mvp/backend/app/api/v1/geocode.py @@ -124,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) diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index a8da5bb2..b4a3f507 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -71,11 +71,6 @@ class Settings(BaseSettings): default=300, validation_alias="LOGIN_RATE_LIMIT_WINDOW_S" ) - # 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) # для User-Agent в Nominatim (Nominatim Usage Policy) contact_email: str = "erginrajpopxbe@outlook.com" diff --git a/tradein-mvp/backend/app/core/fdw.py b/tradein-mvp/backend/app/core/fdw.py index ec3cbf78..a5892289 100644 --- a/tradein-mvp/backend/app/core/fdw.py +++ b/tradein-mvp/backend/app/core/fdw.py @@ -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: diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index a37ee550..96b802bd 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -3201,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, diff --git a/tradein-mvp/backend/app/services/geocoder.py b/tradein-mvp/backend/app/services/geocoder.py index 0a503cca..ff832001 100644 --- a/tradein-mvp/backend/app/services/geocoder.py +++ b/tradein-mvp/backend/app/services/geocoder.py @@ -2,7 +2,7 @@ Стратегия: - Cache lookup в `geocode_cache` (Postgres) — TTL 90 дней -- Cache miss → Yandex Geocoder (если есть key) → fallback Nominatim +- Cache miss → локальные ЕКБ-тиры (геопортал/кадастр) → fallback Nominatim - Результат сохраняется в кэш для последующих вызовов Используется в: @@ -36,7 +36,7 @@ class GeocodeResult: lat: float lon: float full_address: str - provider: Literal["nominatim", "yandex", "cache"] + provider: Literal["nominatim", "cache"] confidence: Literal["exact", "approximate", "locality"] = "approximate" # #2576: True если город НЕ был указан пользователем (ни в тексте адреса, ни # через `city_hint`) — т.е. итоговый город результата определил провайдер @@ -88,9 +88,10 @@ def is_within_ekb_bbox_wide(lat: float, lon: float) -> bool: # bbox = (lat_min, lat_max, lon_min, lon_max). OBLAST66_BBOX = (55.8, 62.2, 56.7, 66.6) -# Nominatim/Yandex viewport для областного (не только ЕКБ) поиска — тот же -# формат что EKB_BBOX ниже, но покрывает всю область. viewbox — Nominatim -# (lon1,lat1,lon2,lat2, NW→SE); ll/spn — Yandex центр+охват (lon,lat). +# Nominatim viewport для областного (не только ЕКБ) поиска — покрывает всю +# область. viewbox — Nominatim-формат (lon1,lat1,lon2,lat2, NW→SE); ll/spn — +# legacy-поля того же словаря (были общим форматом с удалённым #2593 Yandex +# Geocoder-тиром), не используются Nominatim-кодом напрямую. OBLAST66_VIEWBOX = { "viewbox": "56.7,62.2,66.6,55.8", "ll": "61.65,59.0", @@ -104,10 +105,9 @@ def is_within_oblast66_bbox(lat: float, lon: float) -> bool: # Крупные города области (кроме ЕКБ) — НЕ исчерпывающий список (в области ~30 -# городов), покрывает основные региональные центры. Используется чтобы: -# (a) не навязывать "Екатеринбург, " в запрос, если пользователь уже указал -# другой город региона (геокодер forward/suggest prefix-логика); -# (b) как known-locality set в _yandex_reverse при перестановке "улица, город". +# городов), покрывает основные региональные центры. Используется чтобы не +# навязывать "Екатеринбург, " в запрос, если пользователь уже указал другой +# город региона (геокодер forward/suggest prefix-логика). SVERDLOVSK_OBLAST_CITIES = frozenset( { "екатеринбург", @@ -198,8 +198,8 @@ def _has_oblast_marker(text_lower: str) -> bool: def _resolve_city_for_geocode(address: str, city_hint: str | None) -> tuple[str | None, bool]: - """Определяет, какой город подставлять в запрос внешнему провайдеру (Yandex/ - Nominatim), когда сам текст адреса города не называет. + """Определяет, какой город подставлять в запрос внешнему провайдеру + (Nominatim), когда сам текст адреса города не называет. Приоритет: 1. Адрес уже содержит маркер города/области региона 66 (`_has_oblast_marker`) @@ -211,8 +211,8 @@ def _resolve_city_for_geocode(address: str, city_hint: str | None) -> tuple[str — для жителей других городов области это давало уверенно неверную цену («Ленина, 1» в Нижнем Тагиле снапалось на екатеринбургскую улицу Ленина, обе улицы называются одинаково). Теперь НЕ подставляем никакой город — - провайдер ищет по OBLAST66 viewbox/bbox (см. `_yandex_bias`, - `OBLAST66_VIEWBOX`), без привязки к конкретному городу. Возвращает + провайдер ищет по OBLAST66 viewbox/bbox (см. `OBLAST66_VIEWBOX`), без + привязки к конкретному городу. Возвращает (None, False) — второй элемент False сигнализирует, что город пользователь НЕ указывал (источник `GeocodeResult.city_ambiguous`). @@ -227,23 +227,6 @@ def _resolve_city_for_geocode(address: str, city_hint: str | None) -> tuple[str return None, False -def _yandex_bias(address: str, city_hint: str | None) -> dict[str, str]: - """ll/spn soft-bias для Yandex Geocoder. - - ЕКБ-центр (`EKB_BBOX`) — ТОЛЬКО если контекст однозначно про Екатеринбург - (явное слово в адресе либо `city_hint`). Иначе — центр всей области - (`OBLAST66_VIEWBOX`): раньше bias всегда указывал на ЕКБ независимо от - того, назвал ли пользователь город (#2576) — молчаливый перекос в пользу - ЕКБ даже без текстового префикса "Екатеринбург, ". - """ - normalized = " ".join(address.lower().split()) - if _EKATERINBURG_RE.search(normalized): - return EKB_BBOX - if city_hint and _EKATERINBURG_RE.search(" ".join(city_hint.lower().split())): - return EKB_BBOX - return OBLAST66_VIEWBOX - - # Города региона 66 КРОМЕ Екатеринбурга — используется чтобы отсечь EKB-only # локальные тиры (geoportal/cadastral, см. `geocode()`) от адреса другого # города области. re.escape на элементах SVERDLOVSK_OBLAST_CITIES-{ekb}. @@ -585,8 +568,8 @@ async def _nominatim_lookup(address: str, city_hint: str | None = None) -> Geoco (предпочитает tight-ЕКБ bbox), который для одноимённых улиц ВНУТРИ региона (напр. "Ленина" — и в Екатеринбурге, и в с. Свердловское) не различает город. Эмпирически подтверждено: "Ленина 1" без города → случайное село внутри - области; "Нижний Тагил, Ленина 1" → корректно резолвится. Раз Yandex-ключ - сейчас недействителен (#2585), это единственный живой внешний провайдер — + области; "Нижний Тагил, Ленина 1" → корректно резолвится. Nominatim — + единственный живой внешний провайдер (#2593: Yandex Geocoder удалён) — city_hint должен реально влиять на его результат, не только на кэш-ключ. """ headers = { @@ -625,140 +608,7 @@ async def _nominatim_lookup(address: str, city_hint: str | None = None) -> Geoco ) -def _yandex_region_ok(geo_object: dict) -> bool | None: - """Кросс-чек региона по Yandex `AdministrativeAreaName`. - - True/False если Yandex вернул поле и оно однозначно про/не про Свердловскую - область. None если поля нет / оно не строка — тогда accept-логика падает - обратно на bbox. Ловит Тюмень/Шадринск/Кунгур/Снежинск — они внутри - генерального OBLAST66_BBOX (специально щедрого), но их регион явно другой. - """ - try: - admin_area = ( - geo_object.get("metaDataProperty", {}) - .get("GeocoderMetaData", {}) - .get("AddressDetails", {}) - .get("Country", {}) - .get("AdministrativeArea", {}) - .get("AdministrativeAreaName") - ) - except AttributeError: - return None - if not isinstance(admin_area, str) or not admin_area: - return None - return "свердловск" in admin_area.lower() - - -# ── Provider: Yandex Geocoder (требует key, лучшее покрытие РФ) ───────────── -@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8)) -async def _yandex_lookup( - address: str, api_key: str, city_hint: str | None = None -) -> GeocodeResult | None: - """Yandex Geocoder — 25K req/day free для самопод, лучше РФ. - - Docs: https://yandex.ru/dev/maps/geocoder/doc/desc/concepts/input_params.html - - Запрашиваем с ll+spn (центр ЕКБ, если контекст ЕКБ, иначе центр всей - области — см. `_yandex_bias`) для приоритизации местных результатов, но - БЕЗ rspn — чтобы fuzzy matching работал при опечатках. - """ - # Город в запрос подставляем ТОЛЬКО если он известен (адрес уже называет - # город/область региона 66, либо явный `city_hint`) — раньше (#2576) сюда - # молча подставлялся "Екатеринбург" при отсутствии обоих, что давало - # уверенно неверную цену жителям других городов области. - city, _ = _resolve_city_for_geocode(address, city_hint) - geocode_query = f"{city}, {address}" if city else address - bias = _yandex_bias(address, city_hint) - async with httpx.AsyncClient(timeout=10.0) as client: - response = await client.get( - "https://geocode-maps.yandex.ru/1.x/", - params={ - "apikey": api_key, - "geocode": geocode_query, - "format": "json", - "results": 5, # берем top-5, отфильтруем по ЕКБ bbox ниже - "lang": "ru_RU", - "ll": bias["ll"], - "spn": bias["spn"], - }, - ) - response.raise_for_status() - data = response.json() - - members = data.get("response", {}).get("GeoObjectCollection", {}).get("featureMember", []) - if not members: - return None - - # Фильтруем top-5 по bbox области (region 66) — игнорируем Челябинск/Уфу/Москву - # при опечатке, но принимаем легитимные Нижний Тагил/Серов/etc. Two-pass - # tie-break: tight-ЕКБ приоритетнее (byte-identical для ЕКБ-запросов, даже - # если Yandex ранжировал его не первым), иначе первый oblast-bbox кандидат. - # Region cross-check (AdministrativeAreaName) отсекает кандидатов ЯВНО из - # другого региона (Тюмень и т.п. попадают в генеральный bbox координатно). - best = None - oblast_fallback = None - for m in members: - obj = m.get("GeoObject", {}) - try: - lon_str, lat_str = obj["Point"]["pos"].split() - lat_f, lon_f = float(lat_str), float(lon_str) - except Exception: - continue - if _yandex_region_ok(obj) is False: - continue # регион явно не Свердловская область — не рассматриваем - if is_within_ekb_bbox(lat_f, lon_f): - best = obj - break - if oblast_fallback is None and is_within_oblast66_bbox(lat_f, lon_f): - oblast_fallback = obj - - if best is None: - best = oblast_fallback - - if best is None: - # Никто из top-5 не попал ни в tight, ни в oblast bbox (или все явно - # отсеяны по региону) → берём первый «как есть», НО только если его - # регион не опровергнут явно — иначе весь смысл region cross-check - # теряется на garbage-query, где top-1 — гарантированно не тот регион - # (например, Тюмень). - first_obj = members[0]["GeoObject"] - if _yandex_region_ok(first_obj) is not False: - best = first_obj - - if best is None: - return None - - lon_str, lat_str = best["Point"]["pos"].split() - precision_raw = ( - best.get("metaDataProperty", {}).get("GeocoderMetaData", {}).get("precision", "other") - ) - confidence_map = { - "exact": "exact", - "number": "exact", - "near": "approximate", - "range": "approximate", - "street": "approximate", - } - return GeocodeResult( - lat=float(lat_str), - lon=float(lon_str), - full_address=best.get("metaDataProperty", {}) - .get("GeocoderMetaData", {}) - .get("text", address), - provider="yandex", - confidence=confidence_map.get(precision_raw, "approximate"), - ) - - # ── Suggest (автокомплит) ─────────────────────────────────────────────────── -# ЕКБ bounding box (приблизительно): юг 56.65, запад 60.40, север 56.95, восток 60.85 -# Это ЕКБ fast-path (Yandex rspn=1 bounded tier + soft-bias ll/spn). Для областного -# охвата (region 66) — см. OBLAST66_VIEWBOX выше. -EKB_BBOX = { - "viewbox": "60.40,56.95,60.85,56.65", # Nominatim format: lon1,lat1,lon2,lat2 (NW,SE) - "ll": "60.605,56.838", # Yandex center (lon,lat) - "spn": "0.45,0.30", # Yandex span (lon,lat) -} @dataclass(frozen=True, slots=True) @@ -769,46 +619,14 @@ class GeocodeSuggestion: lon: float kind: str # 'house' / 'street' / 'locality' # ГАР OBJECTGUID (ФИАС) дома — заполняется ТОЛЬКО для house-level кандидатов - # DaData-тира (fias_level 8/9). У Yandex/Nominatim/cadastral-тиров и у + # DaData-тира (fias_level 8/9). У Nominatim/cadastral-тиров и у # street/locality-кандидатов остаётся None (нет стабильного house-fias). fias_id: str | None = None -def _parse_yandex_members(members: list[dict]) -> list[GeocodeSuggestion]: - """Yandex geocode_members → list[GeocodeSuggestion]. Чистим описание от мусора.""" - out: list[GeocodeSuggestion] = [] - for m in members: - obj = m.get("GeoObject", {}) - try: - lon_str, lat_str = obj["Point"]["pos"].split() - meta = obj.get("metaDataProperty", {}).get("GeocoderMetaData", {}) - kind = meta.get("kind", "other") - full = meta.get("text", obj.get("name", "")) - name = obj.get("name", full) - desc = obj.get("description", "") - desc_parts = [ - p.strip() - for p in desc.split(",") - if p.strip() and p.strip() not in {"Россия", "Свердловская область"} - ] - label = name if not desc_parts else f"{name} · {', '.join(desc_parts)}" - out.append( - GeocodeSuggestion( - label=label, - full_address=full, - lat=float(lat_str), - lon=float(lon_str), - kind=kind, - ) - ) - except Exception: - continue - return out - - # ── DaData suggest wrapper ────────────────────────────────────────────────── # Маппинг DaData kind → GeocodeSuggestion.kind (внутри geocoder используется -# 'locality' вместо 'city' — consistent с Yandex/Nominatim ветками). +# 'locality' вместо 'city' — consistent с Nominatim-веткой). _DADATA_KIND_MAP = {"house": "house", "street": "street", "city": "locality"} @@ -847,101 +665,6 @@ async def _dadata_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion] return out -async def _yandex_geocode_request( - client: httpx.AsyncClient, - api_key: str, - query: str, - limit: int, - bounded: bool, - bias: dict[str, str] | None = None, -) -> list[dict]: - """Single Yandex Geocoder request — bounded=True → строго внутри `bias` bbox через rspn=1. - - `bias` — ll/spn (`EKB_BBOX` или `OBLAST66_VIEWBOX`). По умолчанию `EKB_BBOX` - (backward-compat для вызовов без явного bias). - """ - b = bias or EKB_BBOX - params: dict[str, str] = { - "apikey": api_key, - "geocode": query, - "format": "json", - "results": str(limit), - "lang": "ru_RU", - "ll": b["ll"], - "spn": b["spn"], - } - if bounded: - params["rspn"] = "1" - response = await client.get("https://geocode-maps.yandex.ru/1.x/", params=params) - response.raise_for_status() - data = response.json() - return data.get("response", {}).get("GeoObjectCollection", {}).get("featureMember", []) - - -@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4)) -async def _yandex_suggest( - query: str, api_key: str, limit: int = 8, city_hint: str | None = None -) -> list[GeocodeSuggestion]: - """Yandex Geocoder с авто-fallback на typo-tolerant режим. - - Tier 1: bounded (rspn=1) — быстрый путь. Bounded на ЕКБ, если контекст - однозначно про ЕКБ (текст адреса/`city_hint`), иначе bounded на ВСЮ область - (`OBLAST66_VIEWBOX`) — раньше (#2576) Tier 1/2 всегда форсили bounded-ЕКБ - с "Екатеринбург, "-префиксом даже когда пользователь не называл город, из-за - чего автокомплит для жителей других городов области либо не находил ничего, - либо подсовывал ЕКБ-варианты вместо нужного города. - Tier 2: bounded на typo-variants (удвоение согласных), тот же bias. - Tier 3: без rspn — fuzzy по всей стране, фильтр результатов по bbox области - (region 66) — ловит легитимные Нижний Тагил/Серов/etc, которые Tier 1/2 - (bounded) структурно вернуть не могут при неверном bias. - """ - city, _ = _resolve_city_for_geocode(query, city_hint) - prefixed_query = f"{city}, {query}" if city else query - bias = _yandex_bias(query, city_hint) - async with httpx.AsyncClient(timeout=8.0) as client: - # Tier 1: strict bounded на оригинал - members = await _yandex_geocode_request( - client, - api_key, - prefixed_query, - limit, - bounded=True, - bias=bias, - ) - results = _parse_yandex_members(members) - if results: - return results - - # Tier 2: bounded на typo-варианты (тот же bias) - for variant in _typo_variants(query, limit=4): - variant_city, _ = _resolve_city_for_geocode(variant, city_hint) - variant_query = f"{variant_city}, {variant}" if variant_city else variant - members = await _yandex_geocode_request( - client, - api_key, - variant_query, - limit, - bounded=True, - bias=bias, - ) - results = _parse_yandex_members(members) - if results: - return results - - # Tier 3: без rspn — даём fuzzy сделать своё дело, фильтр по bbox области - members = await _yandex_geocode_request( - client, - api_key, - prefixed_query, - limit, - bounded=False, - bias=bias, - ) - results = _parse_yandex_members(members) - in_oblast = [r for r in results if is_within_oblast66_bbox(r.lat, r.lon)] - return in_oblast - - async def _nominatim_query_multi(client: httpx.AsyncClient, query: str, limit: int) -> list[dict]: """Один Nominatim search с фильтром по bbox области (region 66). Возвращает up to N items.""" response = await client.get( @@ -1024,7 +747,8 @@ async def _nominatim_query_city_aware( async def _nominatim_suggest( query: str, limit: int = 8, city_hint: str | None = None ) -> list[GeocodeSuggestion]: - """Nominatim в режиме suggest. С typo-fallback (для случаев когда Yandex недоступен). + """Nominatim в режиме suggest. С typo-fallback (для случаев когда оригинальный + запрос ничего не находит). Суффикс города навязывается, только если он известен: адрес уже называет город/область региона 66, либо передан явный `city_hint`. Если город @@ -1384,16 +1108,17 @@ async def suggest( query: str, db: Session | None = None, limit: int = 8, city_hint: str | None = None ) -> list[GeocodeSuggestion]: """Автокомплит адресов в Свердловской области (region 66; ЕКБ — основной трафик, - остаётся быстрым fast-path). Cadastral FDW → DaData → Yandex → Nominatim → []. + остаётся быстрым fast-path). Cadastral FDW → DaData → Nominatim → []. db: если передан — cadastral lookup через gendesign_cad_buildings (первый tier). city_hint: город, если известен вызывающему коду (#2576) — прокидывается в - Yandex/Nominatim тиры, чтобы НЕ подставлять "Екатеринбург" молча, когда + Nominatim-тир, чтобы НЕ подставлять "Екатеринбург" молча, когда пользователь его не называл. Опционально, backward-compatible (None — прежнее поведение минус молчаливый EKB-дефолт, см. `_resolve_city_for_geocode`). - DaData /suggest (PR Q2) — token-only, 10k/день, заменяет Yandex который - заблокирован (1k/день demo limit исчерпан). DaData region-constraint уже - охватывает всю область (не только ЕКБ) — city_hint ей не нужен. + DaData /suggest (PR Q2) — token-only, 10k/день, лучший fit для РФ-адресов + (#2593: Yandex Geocoder, который был primary external provider до DaData, + удалён). DaData region-constraint уже охватывает всю область (не только + ЕКБ) — city_hint ей не нужен. Без кэша (дешёво, провайдеры толерируют автокомплит-запросы). """ if not query or len(query.strip()) < 2: @@ -1405,7 +1130,7 @@ async def suggest( # незнакомый — структурный маркер (`_names_unrecognized_locality`), ИЛИ # `city_hint` называет город региона 66, отличный от ЕКБ (#2580 C1 — # иначе хинт мёртвый параметр для этого тира, см. `_ekb_local_tiers_allowed` - # и `geocode()` ниже — тот же гейт). Внешние тиры (2/3/4 ниже) не гейтим — + # и `geocode()` ниже — тот же гейт). Внешние тиры (2/3 ниже) не гейтим — # они уже oblast-aware. if db is not None and _ekb_local_tiers_allowed(query, city_hint): # 1a. Anchored house-match: парсим street+house → точный матч по дом-маркеру. @@ -1422,28 +1147,16 @@ async def suggest( return cad_results # Tier 2: DaData /suggest — token-only (X-Secret не нужен), 10k/день free, - # лучший fit для РФ адресов. Заменил Yandex как primary external provider - # после того как demo-key Yandex (1k/день) был исчерпан. + # лучший fit для РФ адресов. if settings.dadata_api_token: try: dadata_results = await _dadata_suggest(query, limit) if dadata_results: return dadata_results except Exception: - logger.exception("dadata suggest failed, falling back to yandex") + logger.exception("dadata suggest failed, falling back to nominatim") - # Tier 3: Yandex (legacy — оставляем как fallback, если key есть) - if settings.yandex_geocoder_api_key: - try: - results = await _yandex_suggest( - query, settings.yandex_geocoder_api_key, limit, city_hint=city_hint - ) - if results: - return results - except Exception: - logger.exception("yandex suggest failed, falling back to nominatim") - - # Tier 4: Nominatim (последний fallback — OSM, без ключа) + # Tier 3: Nominatim (последний fallback — OSM, без ключа) try: return await _nominatim_suggest(query, limit, city_hint=city_hint) except Exception: @@ -1453,15 +1166,15 @@ async def suggest( # ── Public API ─────────────────────────────────────────────────────────────── async def geocode(address: str, db: Session, city_hint: str | None = None) -> GeocodeResult | None: - """Геокодинг с кэшем. Cadastral FDW → Yandex → Nominatim → None. + """Геокодинг с кэшем. Cadastral FDW → Nominatim → None. Args: address: пользовательский ввод (может быть грязным — нормализуем). db: сессия Postgres для cache lookup/write и cadastral FDW lookup. city_hint: город, если известен вызывающему коду (#2576) — например выбран пользователем на предыдущем шаге UI. Опциональный, не - ломает существующий контракт. Прокидывается в Yandex/Nominatim - внешние тиры вместо молчаливой подстановки "Екатеринбург" и + ломает существующий контракт. Прокидывается в Nominatim-тир + вместо молчаливой подстановки "Екатеринбург" и участвует в cache-ключе (см. `_cache_key`), чтобы ответы для разных городов по одному и тому же тексту адреса не перезатирали друг друга. @@ -1575,42 +1288,7 @@ async def geocode(address: str, db: Session, city_hint: str | None = None) -> Ge ) return result - # 3. Yandex (если есть key) с typo-fallback - if settings.yandex_geocoder_api_key: - try: - result = await _yandex_lookup(address, settings.yandex_geocoder_api_key, city_hint) - # Если результат вне области (region 66) — пробуем typo-варианты - in_oblast = result is not None and is_within_oblast66_bbox(result.lat, result.lon) - if result is not None and in_oblast: - result = replace(result, city_ambiguous=city_ambiguous) - await asyncio.to_thread(_cache_put, db, addr_norm, result) - logger.info("geocode yandex: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon) - return result - # Tier 2: typo-variants - for variant in _typo_variants(address, limit=4): - try: - result = await _yandex_lookup( - variant, settings.yandex_geocoder_api_key, city_hint - ) - except Exception: - continue - if result is None: - continue - if is_within_oblast66_bbox(result.lat, result.lon): - result = replace(result, city_ambiguous=city_ambiguous) - await asyncio.to_thread(_cache_put, db, addr_norm, result) - logger.info( - "geocode yandex typo-fixed: %s → %s → (%.5f, %.5f)", - addr_norm, - variant, - result.lat, - result.lon, - ) - return result - except Exception: - logger.exception("yandex geocoder failed, falling back to nominatim") - - # 4. Nominatim fallback + # 3. Nominatim fallback try: result = await _nominatim_lookup(address, city_hint) if result is not None: @@ -1628,9 +1306,14 @@ async def geocode(address: str, db: Session, city_hint: str | None = None) -> Ge # ── Reverse: координаты → адрес (для map-picker'а) ────────────────────────── # Precision levels which we treat as "снап к зданию имеет смысл": -# - exact — точный матч на здание (Yandex) +# - exact — точный матч на здание. #2593: единственный живой источник +# (`_yandex_reverse`) удалён — значение сейчас недостижимо ни одним из +# оставшихся тиров (cadastral/Nominatim), но оставлено в множестве: +# дёшево держать (frozen set membership-check, не enum), сохраняет parity +# с фронтовым `SNAP_PRECISIONS` (`MapPicker.tsx`, тот же литерал), и не +# ломает `test_snap_precision_useful_exact_and_number`. # - number — найден дом с номером (то что нам надо для квартирного оценщика) -# - cadastral — Cadastral FDW row (то же по точности что Yandex "number") +# - cadastral — Cadastral FDW row (та же точность, что и "number") # Остальные (street/range/near/locality/other) → не снапаем, marker остаётся на клике. _SNAP_PRECISIONS = {"exact", "number", "cadastral"} @@ -1643,14 +1326,14 @@ class ReverseGeocodeResult: - `snapped_lat` — координата центра здания если provider дал её, иначе echo `lat` входной точки (для precision=street/locality). - `snapped_lon` — то же. - - `precision` — yandex-style: `exact`/`number`/`street`/`range`/`near`/ - `locality`/`other`/`cadastral`. Используется фронтом чтобы + - `precision` — `exact`/`number`/`street`/`range`/`near`/`locality`/ + `other`/`cadastral`. Используется фронтом чтобы решить — двигать marker (exact/number) или нет. - - `provider` — кто дал результат (`yandex`/`nominatim`/`cadastral`). + - `provider` — кто дал результат (`nominatim`/`cadastral`). Фронт MapPicker'а после клика смотрит на precision: если `exact`/`number` и snapped >5m от click point — пересаживает marker на snapped point - (чтобы пользователь видел центр дома по Яндексу, а не свой клик во дворе). + (чтобы пользователь видел центр дома, а не свой клик во дворе). Для остальных precision marker остаётся где кликнули — не врём что нашли точное здание. """ @@ -1659,7 +1342,7 @@ class ReverseGeocodeResult: snapped_lat: float snapped_lon: float precision: str - provider: Literal["yandex", "nominatim", "cadastral"] + provider: Literal["nominatim", "cadastral"] def _format_reverse_address(addr: dict) -> str | None: @@ -1686,77 +1369,6 @@ def _format_reverse_address(addr: dict) -> str | None: return ", ".join(parts) -@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8)) -async def _yandex_reverse(lat: float, lon: float, api_key: str) -> ReverseGeocodeResult | None: - """Yandex Geocoder /reverse — возвращает snapped Point.pos здания + precision. - - Docs: https://yandex.ru/dev/maps/geocoder/doc/desc/concepts/input_params.html - Параметр `geocode` принимает `lon,lat` (важно — обратный порядок!). - """ - async with httpx.AsyncClient(timeout=10.0) as client: - response = await client.get( - "https://geocode-maps.yandex.ru/1.x/", - params={ - "apikey": api_key, - "geocode": f"{lon},{lat}", - "format": "json", - "results": "1", - "kind": "house", # просим именно здание (house), не улицу - "lang": "ru_RU", - }, - ) - response.raise_for_status() - data = response.json() - - members = data.get("response", {}).get("GeoObjectCollection", {}).get("featureMember", []) - if not members: - return None - obj = members[0].get("GeoObject", {}) - try: - lon_str, lat_str = obj["Point"]["pos"].split() - snapped_lat = float(lat_str) - snapped_lon = float(lon_str) - except (KeyError, ValueError): - return None - meta = obj.get("metaDataProperty", {}).get("GeocoderMetaData", {}) - precision = str(meta.get("precision", "other")) - address_text = str(meta.get("text") or obj.get("name") or "") - # Yandex address text начинается с «Россия, Свердловская область, …» — режем prefix, - # оставляем «улица, дом, город» для consistency с Nominatim/cadastral. - if address_text: - # «Россия, Свердловская область, Екатеринбург, улица Малышева, 51» - # → «улица Малышева, 51, Екатеринбург» (drop country/oblast, swap city/street) - parts = [p.strip() for p in address_text.split(",") if p.strip()] - filtered = [ - p - for p in parts - if p not in {"Россия", "Свердловская область"} and not p.startswith("городской округ") - ] - # Найдём locality (Екатеринбург / Нижний Тагил / Берёзовский / …) и переставим - # в конец — любой город региона 66, не только ЕКБ (case-insensitive match). - locality = None - rest: list[str] = [] - for p in filtered: - if locality is None and p.lower() in SVERDLOVSK_OBLAST_CITIES: - locality = p - else: - rest.append(p) - if locality and rest: - address_text = ", ".join([*rest, locality]) - else: - address_text = ", ".join(filtered) - - if not address_text: - return None - return ReverseGeocodeResult( - address=address_text, - snapped_lat=snapped_lat, - snapped_lon=snapped_lon, - precision=precision, - provider="yandex", - ) - - @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8)) async def _nominatim_reverse(lat: float, lon: float) -> ReverseGeocodeResult | None: """Nominatim /reverse → ReverseGeocodeResult с snapped coords из item.lat/lon. @@ -1856,7 +1468,7 @@ def _cadastral_reverse_sync_full( async def reverse_geocode( lat: float, lon: float, db: Session | None = None ) -> ReverseGeocodeResult | None: - """Cadastral FDW → Yandex (если key) → Nominatim. Возвращает snapped coords. + """Cadastral FDW → Nominatim. Возвращает snapped coords. Возвращает None если ни один источник не дал адрес. Endpoint api/v1/geocode/reverse сам выкинет 404. НЕ даёт выйти HTTPStatusError @@ -1881,16 +1493,7 @@ async def reverse_geocode( provider="cadastral", ) - # 2. Yandex — основной источник snap'а (его Point.pos = центр здания) - if settings.yandex_geocoder_api_key: - try: - result = await _yandex_reverse(lat, lon, settings.yandex_geocoder_api_key) - if result is not None: - return result - except Exception: - logger.exception("yandex reverse failed for (%.5f, %.5f)", lat, lon) - - # 3. Nominatim fallback (wrap to prevent 500 on ban/rate-limit) + # 2. Nominatim fallback (wrap to prevent 500 on ban/rate-limit) try: return await _nominatim_reverse(lat, lon) except Exception: diff --git a/tradein-mvp/backend/app/tasks/geocode_missing.py b/tradein-mvp/backend/app/tasks/geocode_missing.py index 2dcfd9f2..38776907 100644 --- a/tradein-mvp/backend/app/tasks/geocode_missing.py +++ b/tradein-mvp/backend/app/tasks/geocode_missing.py @@ -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). diff --git a/tradein-mvp/backend/tests/services/test_cadastral_reverse.py b/tradein-mvp/backend/tests/services/test_cadastral_reverse.py index 2204774a..fdd2d88b 100644 --- a/tradein-mvp/backend/tests/services/test_cadastral_reverse.py +++ b/tradein-mvp/backend/tests/services/test_cadastral_reverse.py @@ -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 ───────────────── diff --git a/tradein-mvp/backend/tests/test_ekb_geoportal_ingest.py b/tradein-mvp/backend/tests/test_ekb_geoportal_ingest.py index 638e3bfa..6ccd81f0 100644 --- a/tradein-mvp/backend/tests/test_ekb_geoportal_ingest.py +++ b/tradein-mvp/backend/tests/test_ekb_geoportal_ingest.py @@ -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 diff --git a/tradein-mvp/backend/tests/test_geocode_reverse_api.py b/tradein-mvp/backend/tests/test_geocode_reverse_api.py index a50bb838..7b5adebe 100644 --- a/tradein-mvp/backend/tests/test_geocode_reverse_api.py +++ b/tradein-mvp/backend/tests/test_geocode_reverse_api.py @@ -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 diff --git a/tradein-mvp/backend/tests/test_geocoder_bbox.py b/tradein-mvp/backend/tests/test_geocoder_bbox.py index eacc7d3a..93457634 100644 --- a/tradein-mvp/backend/tests/test_geocoder_bbox.py +++ b/tradein-mvp/backend/tests/test_geocoder_bbox.py @@ -8,12 +8,10 @@ инвариант 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 @@ -27,7 +25,6 @@ from app.services.geocoder import ( _names_non_ekb_city, _names_unrecognized_locality, _nominatim_query, - _yandex_lookup, is_within_ekb_bbox, is_within_ekb_bbox_wide, is_within_oblast66_bbox, @@ -145,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 @@ -419,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 diff --git a/tradein-mvp/backend/tests/test_geocoder_cadastral_matcher.py b/tradein-mvp/backend/tests/test_geocoder_cadastral_matcher.py index 3e179c8c..a2e78b57 100644 --- a/tradein-mvp/backend/tests/test_geocoder_cadastral_matcher.py +++ b/tradein-mvp/backend/tests/test_geocoder_cadastral_matcher.py @@ -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 @@ -353,14 +345,12 @@ async def test_geocode_skips_ekb_local_tiers_for_unrecognized_locality( 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(address, db) assert result is not None, label @@ -462,14 +452,12 @@ async def test_geocode_city_hint_unknown_city_skips_local_ekb_tiers() -> None: ) 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.settings") as mock_settings, patch( "app.services.geocoder._nominatim_lookup", new_callable=AsyncMock, return_value=rezh_result, ), ): - mock_settings.yandex_geocoder_api_key = None result = await geocode("Ленина, 1", db, city_hint="Реж") mock_geoportal.assert_not_called() @@ -501,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) @@ -509,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: @@ -534,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) @@ -542,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: @@ -571,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 @@ -615,7 +598,6 @@ async def test_suggest_skips_ekb_local_tier_for_unrecognized_locality( ) as mock_nominatim, ): mock_settings.dadata_api_token = None - mock_settings.yandex_geocoder_api_key = None results = await suggest(query, db=db, limit=8) assert len(results) == 1, label diff --git a/tradein-mvp/backend/tests/test_geocoder_city_hint.py b/tradein-mvp/backend/tests/test_geocoder_city_hint.py index 5a5666c2..0565aa0d 100644 --- a/tradein-mvp/backend/tests/test_geocoder_city_hint.py +++ b/tradein-mvp/backend/tests/test_geocoder_city_hint.py @@ -1,18 +1,20 @@ """Тесты #2576 — geocoder больше НЕ подставляет "Екатеринбург" молча. -Проблема (issue #2576 / эпик расширения на область): `_yandex_lookup`, -`_yandex_suggest`, `_nominatim_suggest` при отсутствии маркера города/области в -самом адресе всегда молча подставляли "Екатеринбург" — житель Нижнего Тагила, -вводя «Ленина, 1», получал уверенно неверную цену по екатеринбургской улице -Ленина (обе улицы называются одинаково). +Проблема (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. -- `_yandex_lookup` — без города НЕ получает "Екатеринбург, "-префикс и bias - смещён на всю область (не форсит ЕКБ-центр); с `city_hint` — префикс из hint'а; - с явным "Екатеринбург" в адресе — поведение НЕ изменилось (как раньше). -- `_yandex_suggest` (Tier 1 bounded) — то же самое, плюс bias/rspn. -- `_nominatim_suggest` — то же самое (суффикс города, не префикс). +- `_nominatim_suggest` — без города НЕ форсит city-суффикс молчаливо (dual-query); + с `city_hint` — суффикс из hint'а; с явным "Екатеринбург" в адресе — поведение + НЕ изменилось (как раньше). - `geocode()` — `city_ambiguous=True` когда город не указан ни в адресе, ни в `city_hint`; `False` когда указан явно (текстом или через `city_hint`). - Cache-ключ (`_cache_key`) — разные `city_hint` для одного текста адреса НЕ @@ -27,19 +29,14 @@ from unittest.mock import AsyncMock, MagicMock, patch os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost/test_db") -import httpx import pytest from app.services.geocoder import ( - EKB_BBOX, - OBLAST66_VIEWBOX, GeocodeResult, GeocodeSuggestion, _cache_key, _nominatim_suggest, _resolve_city_for_geocode, - _yandex_lookup, - _yandex_suggest, geocode, suggest, ) @@ -93,139 +90,6 @@ def test_cache_key_hint_normalized() -> None: assert _cache_key("ленина, 1", "Нижний Тагил") == _cache_key("ленина, 1", "нижний тагил ") -# ── _yandex_lookup — query string + bias ───────────────────────────────────── - -_REAL_ASYNC_CLIENT = httpx.AsyncClient - - -def _yandex_client_factory(transport: httpx.MockTransport): - def factory(*_: object, **__: object) -> httpx.AsyncClient: - return _REAL_ASYNC_CLIENT(transport=transport) - - return factory - - -def _empty_yandex_payload() -> dict: - return {"response": {"GeoObjectCollection": {"featureMember": []}}} - - -async def test_yandex_lookup_no_city_no_prefix_and_oblast_bias() -> None: - """#2576: без города в адресе/hint — Yandex-запрос БЕЗ "Екатеринбург, "-префикса, - bias смещён на всю область (не форсит ЕКБ-центр по умолчанию).""" - captured: dict[str, str | None] = {} - - def handler(request: httpx.Request) -> httpx.Response: - captured["geocode"] = request.url.params.get("geocode") - captured["ll"] = request.url.params.get("ll") - captured["spn"] = request.url.params.get("spn") - return httpx.Response(200, json=_empty_yandex_payload()) - - transport = httpx.MockTransport(handler) - with patch("app.services.geocoder.httpx.AsyncClient", _yandex_client_factory(transport)): - result = await _yandex_lookup("Ленина, 1", "fake-key") - - assert result is None # пустой featureMember - assert captured["geocode"] == "Ленина, 1" - assert "Екатеринбург" not in (captured["geocode"] or "") - assert captured["ll"] == OBLAST66_VIEWBOX["ll"] - assert captured["spn"] == OBLAST66_VIEWBOX["spn"] - - -async def test_yandex_lookup_city_hint_prefix() -> None: - """city_hint="Нижний Тагил" → запрос получает префикс из hint'а, не "Екатеринбург".""" - captured: dict[str, str | None] = {} - - def handler(request: httpx.Request) -> httpx.Response: - captured["geocode"] = request.url.params.get("geocode") - captured["ll"] = request.url.params.get("ll") - return httpx.Response(200, json=_empty_yandex_payload()) - - transport = httpx.MockTransport(handler) - with patch("app.services.geocoder.httpx.AsyncClient", _yandex_client_factory(transport)): - await _yandex_lookup("Ленина, 1", "fake-key", city_hint="Нижний Тагил") - - assert captured["geocode"] == "Нижний Тагил, Ленина, 1" - # Тагил — не ЕКБ-контекст → bias не форсит ЕКБ-центр. - assert captured["ll"] == OBLAST66_VIEWBOX["ll"] - - -async def test_yandex_lookup_explicit_ekaterinburg_unchanged() -> None: - """Явное "Екатеринбург" в адресе → поведение НЕ изменилось (как до фикса).""" - captured: dict[str, str | None] = {} - - def handler(request: httpx.Request) -> httpx.Response: - captured["geocode"] = request.url.params.get("geocode") - captured["ll"] = request.url.params.get("ll") - return httpx.Response(200, json=_empty_yandex_payload()) - - transport = httpx.MockTransport(handler) - with patch("app.services.geocoder.httpx.AsyncClient", _yandex_client_factory(transport)): - await _yandex_lookup("Екатеринбург, Малышева 30", "fake-key") - - assert captured["geocode"] == "Екатеринбург, Малышева 30" - assert captured["ll"] == EKB_BBOX["ll"] - - -# ── _yandex_suggest (Tier 1 bounded) ───────────────────────────────────────── - - -async def test_yandex_suggest_no_city_uses_oblast_bounded() -> None: - """#2576: автокомплит без города — bounded по ВСЕЙ области, без city-префикса - (раньше Tier 1 всегда форсил bounded-ЕКБ с "Екатеринбург, ").""" - calls: list[tuple[str, bool, dict[str, str] | None]] = [] - - async def fake_request(client, api_key, query, limit, bounded, bias=None): - calls.append((query, bounded, bias)) - return [] - - with patch( - "app.services.geocoder._yandex_geocode_request", new=AsyncMock(side_effect=fake_request) - ): - result = await _yandex_suggest("Ленина, 1", "fake-key") - - assert result == [] - assert calls, "expected at least one Yandex request" - first_query, first_bounded, first_bias = calls[0] - assert first_query == "Ленина, 1" - assert "Екатеринбург" not in first_query - assert first_bounded is True - assert first_bias == OBLAST66_VIEWBOX - - -async def test_yandex_suggest_city_hint_prefix_bounded() -> None: - calls: list[tuple[str, bool, dict[str, str] | None]] = [] - - async def fake_request(client, api_key, query, limit, bounded, bias=None): - calls.append((query, bounded, bias)) - return [] - - with patch( - "app.services.geocoder._yandex_geocode_request", new=AsyncMock(side_effect=fake_request) - ): - await _yandex_suggest("Ленина, 1", "fake-key", city_hint="Нижний Тагил") - - first_query, _, first_bias = calls[0] - assert first_query == "Нижний Тагил, Ленина, 1" - assert first_bias == OBLAST66_VIEWBOX - - -async def test_yandex_suggest_explicit_ekb_unchanged() -> None: - calls: list[tuple[str, bool, dict[str, str] | None]] = [] - - async def fake_request(client, api_key, query, limit, bounded, bias=None): - calls.append((query, bounded, bias)) - return [] - - with patch( - "app.services.geocoder._yandex_geocode_request", new=AsyncMock(side_effect=fake_request) - ): - await _yandex_suggest("Екатеринбург, Малышева 30", "fake-key") - - first_query, _, first_bias = calls[0] - assert first_query == "Екатеринбург, Малышева 30" - assert first_bias == EKB_BBOX - - # ── _nominatim_suggest ─────────────────────────────────────────────────────── @@ -363,14 +227,16 @@ async def test_nominatim_suggest_dedupe_across_dual_query() -> None: # ── geocode() — city_ambiguous flag ────────────────────────────────────────── -def _geocode_patches(yandex_result: GeocodeResult | None): +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._yandex_lookup", new=AsyncMock(return_value=yandex_result)), + patch( + "app.services.geocoder._nominatim_lookup", new=AsyncMock(return_value=nominatim_result) + ), ) @@ -393,29 +259,25 @@ async def test_geocode_city_hint_non_ekb_skips_local_ekb_tiers() -> None: kind="house", ) tagil_result = GeocodeResult( - lat=57.905, lon=59.950, full_address="Ленина, 1, Нижний Тагил", provider="yandex" + lat=57.905, lon=59.950, full_address="Ленина, 1, Нижний Тагил", provider="nominatim" ) - with patch("app.services.geocoder.settings") as mock_settings: - mock_settings.yandex_geocoder_api_key = "fake" - 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) + 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), ) - 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._yandex_lookup", - new=AsyncMock(return_value=tagil_result), - ) - ) - result = await geocode("Ленина, 1", db, city_hint="Нижний Тагил") + ) + result = await geocode("Ленина, 1", db, city_hint="Нижний Тагил") geoportal_mock.assert_not_called() cadastral_mock.assert_not_called() @@ -459,7 +321,6 @@ async def test_suggest_city_hint_non_ekb_skips_cadastral_tier1() -> None: 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 - mock_settings.yandex_geocoder_api_key = None stack.enter_context( patch("app.services.geocoder._nominatim_suggest", new=AsyncMock(return_value=[])) ) @@ -472,13 +333,13 @@ async def test_suggest_city_hint_non_ekb_skips_cadastral_tier1() -> None: async def test_geocode_city_ambiguous_true_when_no_city_known() -> None: """Ни адрес, ни city_hint не называют город → city_ambiguous=True.""" db = MagicMock() - yandex_result = GeocodeResult(lat=56.838, lon=60.605, full_address="что-то", provider="yandex") - with patch("app.services.geocoder.settings") as mock_settings: - mock_settings.yandex_geocoder_api_key = "fake" - with contextlib.ExitStack() as stack: - for cm in _geocode_patches(yandex_result): - stack.enter_context(cm) - result = await geocode("Малышева, 30", db) + 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 @@ -487,15 +348,13 @@ async def test_geocode_city_ambiguous_true_when_no_city_known() -> None: async def test_geocode_city_ambiguous_false_when_marker_present() -> None: """Явный "Екатеринбург" в адресе → город указан пользователем → city_ambiguous=False.""" db = MagicMock() - yandex_result = GeocodeResult( - lat=56.838, lon=60.605, full_address="Екатеринбург, Малышева, 30", provider="yandex" + nominatim_result = GeocodeResult( + lat=56.838, lon=60.605, full_address="Екатеринбург, Малышева, 30", provider="nominatim" ) - with patch("app.services.geocoder.settings") as mock_settings: - mock_settings.yandex_geocoder_api_key = "fake" - with contextlib.ExitStack() as stack: - for cm in _geocode_patches(yandex_result): - stack.enter_context(cm) - result = await geocode("Екатеринбург, Малышева, 30", db) + 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 @@ -504,15 +363,13 @@ async def test_geocode_city_ambiguous_false_when_marker_present() -> None: async def test_geocode_city_ambiguous_false_when_city_hint_given() -> None: """city_hint передан вызывающим кодом → город указан → city_ambiguous=False.""" db = MagicMock() - yandex_result = GeocodeResult( - lat=57.905, lon=59.950, full_address="Нижний Тагил, Ленина, 1", provider="yandex" + nominatim_result = GeocodeResult( + lat=57.905, lon=59.950, full_address="Нижний Тагил, Ленина, 1", provider="nominatim" ) - with patch("app.services.geocoder.settings") as mock_settings: - mock_settings.yandex_geocoder_api_key = "fake" - with contextlib.ExitStack() as stack: - for cm in _geocode_patches(yandex_result): - stack.enter_context(cm) - result = await geocode("Ленина, 1", db, city_hint="Нижний Тагил") + 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 @@ -535,45 +392,37 @@ async def test_geocode_cache_does_not_mix_cities() -> None: def fake_cache_put(db, addr_norm, result): store[addr_norm] = result - async def fake_yandex_lookup(address, api_key, city_hint=None): + async def fake_nominatim_lookup(address, city_hint=None): if city_hint == "Нижний Тагил": return GeocodeResult( - lat=57.905, lon=59.950, full_address="Нижний Тагил, Ленина, 1", provider="yandex" + lat=57.905, lon=59.950, full_address="Нижний Тагил, Ленина, 1", provider="nominatim" ) return GeocodeResult( - lat=56.838, lon=60.605, full_address="Екатеринбург, Ленина, 1", provider="yandex" + lat=56.838, lon=60.605, full_address="Екатеринбург, Ленина, 1", provider="nominatim" ) db = MagicMock() - with patch("app.services.geocoder.settings") as mock_settings: - mock_settings.yandex_geocoder_api_key = "fake" - 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._yandex_lookup", - new=AsyncMock(side_effect=fake_yandex_lookup), - ) + 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="Екатеринбург") + 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) From bea61f6cd9b3b304dc972a95c8b456af1522241f Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 31 Jul 2026 21:47:33 +0300 Subject: [PATCH 40/41] =?UTF-8?q?feat(auth):=20=D0=BE=D1=82=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D0=B0=D1=8F=20=D0=91=D0=94=20auth=20?= =?UTF-8?q?=E2=80=94=20=D1=84=D1=83=D0=BD=D0=B4=D0=B0=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=20=D0=B5=D0=B4=D0=B8=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=B2?= =?UTF-8?q?=D1=85=D0=BE=D0=B4=D0=B0=20=C2=AB=D0=9C=D0=B5=D1=80=D1=8B=C2=BB?= =?UTF-8?q?=20=D0=B8=20=C2=AB=D0=9F=D1=82=D0=B8=D1=86=D1=8B=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR-1 эпика: вся авторизация переезжает на одну нейтральную форму входа, браузерный popup (Caddy basic_auth) убирается. Этот PR — ТОЛЬКО фундамент, прод работает как сейчас: в БД gendesign ничего не меняется, новая БД создаётся и наполняется логинами без паролей, читать её пока некому. Почему отдельная БД, а не таблица в существующей: хранилище доступов не должно принадлежать продукту, из которого аккаунты выносятся. Сервер — существующий gendesign-postgres (новый контейнер не заводим); проверено, что оба бэкенда сидят в сети gendesign_shared и TCP-достают до него. Состав: - data/sql/auth/001-003 — схема (users, sessions), роль приложения, сид 13 логинов. password_hash = NULL у ВСЕХ: plaintext и bcrypt-хеши в git запрещены, пароли проставляются отдельно на проде (конвенция репы, прецедент tradein м.193). - ops/db-bootstrap/create_auth_db.sql — CREATE DATABASE через \gexec. Не миграцией: CREATE DATABASE запрещён в транзакции, а миграции обязаны быть транзакционными. - ops/db-bootstrap/set_auth_app_password.sql — пароль роли из env, зеркало set_tradein_fdw_password.sql (GUC + \o /dev/null + %L, строго через stdin — :'pw' не интерполируется внутри $$...$$, на этом падал деплой 2026-05-24). Схема лежит в ПОДКАТАЛОГЕ data/sql/auth/ намеренно: основной цикл деплоя использует `ls -1 data/sql/*.sql`, который в подкаталоги не рекурсирует → эти файлы физически не могут примениться в БД gendesign. Защита не на дисциплине, а на глобе. Триггер `data/sql/**` подкаталог при этом покрывает. Права: владелец БД — суперюзер, а не auth_app (иначе гранты были бы декорацией). users — только SELECT+UPDATE (INSERT не выдан: создания аккаунтов в этом PR нет, а снять грант, на который уже опирается прод-код, сложнее чем выдать). REVOKE ALL ON DATABASE FROM PUBLIC продублирован в bootstrap и в миграции намеренно: bootstrap гоняется каждый деплой (переприменяемость), миграция — однократно (самодостаточность). Проверено ИСПОЛНЕНИЕМ на postgis/postgis:16-3.4 (тот же образ, что на проде): двойной прогон всех файлов идемпотентен; COALESCE-защита сида не затирает вручную проставленные пароль/имя (проверено живьём); ASCII-CHECK отклоняет кириллицу; auth_app коннектится, посторонняя роль → permission denied; битая миграция даёт exit 1 и НЕ пишется в _schema_migrations, т.е. деплой прервётся до подъёма кода; пароль с кавычками и бэкслешем не ломает %L и не печатается в stdout. Тест backend/tests/sql/test_auth_sql_migrations.py: имена, транзакционность, наличие wiring в deploy.yml и детектор паролей/хешей, покрывающий И data/sql/auth, И ops/db-bootstrap — единственное место в репе с ALTER ROLE ... PASSWORD. Детектор проверен на живучесть: подложенный bcrypt-хеш роняет тест. Открытые развилки зафиксированы комментариями в коде, решаются в PR-2/3: судьба tradein_users/tradein_sessions (два одинаковых по схеме хранилища) и expired != disabled (trial-экран не выражается булевым is_active). NB: переменную AUTH_DB_PASSWORD нужно завести вручную в runtime-env бэкенда на VPS. Пока пусто — шаг ALTER ROLE пропускается с warning'ом, деплой не падает. --- .forgejo/workflows/deploy.yml | 68 ++++++++ backend/tests/sql/test_auth_sql_migrations.py | 160 ++++++++++++++++++ data/sql/auth/001_identity_schema.sql | 123 ++++++++++++++ data/sql/auth/002_auth_app_role.sql | 82 +++++++++ data/sql/auth/003_users_seed.sql | 111 ++++++++++++ docs/Secrets_Rotation_Policy.md | 3 +- ops/db-bootstrap/create_auth_db.sql | 67 ++++++++ ops/db-bootstrap/set_auth_app_password.sql | 50 ++++++ 8 files changed, 663 insertions(+), 1 deletion(-) create mode 100644 backend/tests/sql/test_auth_sql_migrations.py create mode 100644 data/sql/auth/001_identity_schema.sql create mode 100644 data/sql/auth/002_auth_app_role.sql create mode 100644 data/sql/auth/003_users_seed.sql create mode 100644 ops/db-bootstrap/create_auth_db.sql create mode 100644 ops/db-bootstrap/set_auth_app_password.sql diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 9ed960aa..7303d4cf 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -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 если файлы не менялись. diff --git a/backend/tests/sql/test_auth_sql_migrations.py b/backend/tests/sql/test_auth_sql_migrations.py new file mode 100644 index 00000000..492fcd84 --- /dev/null +++ b/backend/tests/sql/test_auth_sql_migrations.py @@ -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." diff --git a/data/sql/auth/001_identity_schema.sql b/data/sql/auth/001_identity_schema.sql new file mode 100644 index 00000000..f6803113 --- /dev/null +++ b/data/sql/auth/001_identity_schema.sql @@ -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; diff --git a/data/sql/auth/002_auth_app_role.sql b/data/sql/auth/002_auth_app_role.sql new file mode 100644 index 00000000..48dfe277 --- /dev/null +++ b/data/sql/auth/002_auth_app_role.sql @@ -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; diff --git a/data/sql/auth/003_users_seed.sql b/data/sql/auth/003_users_seed.sql new file mode 100644 index 00000000..6559d88d --- /dev/null +++ b/data/sql/auth/003_users_seed.sql @@ -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; diff --git a/docs/Secrets_Rotation_Policy.md b/docs/Secrets_Rotation_Policy.md index cdcc39d5..8a913a42 100644 --- a/docs/Secrets_Rotation_Policy.md +++ b/docs/Secrets_Rotation_Policy.md @@ -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** | diff --git a/ops/db-bootstrap/create_auth_db.sql b/ops/db-bootstrap/create_auth_db.sql new file mode 100644 index 00000000..b94dd4ef --- /dev/null +++ b/ops/db-bootstrap/create_auth_db.sql @@ -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 живёт внутри этой же БД).'; diff --git a/ops/db-bootstrap/set_auth_app_password.sql b/ops/db-bootstrap/set_auth_app_password.sql new file mode 100644 index 00000000..bd6df39b --- /dev/null +++ b/ops/db-bootstrap/set_auth_app_password.sql @@ -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 From 0a4e126b30c1243e7ee825a7af335e46fcf4ca5c Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 31 Jul 2026 22:14:47 +0300 Subject: [PATCH 41/41] =?UTF-8?q?fix(tradein/scraper):=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=B0=D0=B2=D0=BB=D1=8F=D1=82=D1=8C=20=D0=B3?= =?UTF-8?q?=D0=BE=D1=80=D0=BE=D0=B4=20=D0=BE=D0=B1=D1=8A=D1=8F=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B8=D0=B7=20=D0=BA=D0=BE=D0=BD?= =?UTF-8?q?=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=B0=20=D1=80=D0=B0=D0=B7=D0=B2?= =?UTF-8?q?=D1=91=D1=80=D1=82=D0=BA=D0=B8=20(#2594)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Скрапер знает город в момент сбора (city_slug из CITY_LOCATIONS/CITY_ANCHORS, scraper_kit.orchestration.pipeline), но раньше нигде его не записывал. Провайдеры (avito/cian) часто отдают адрес БЕЗ города в тексте ("ул. Победы, 30" вместо "Нижний Тагил, ул. Победы, 30" — cian даже явно вырезает location-часть перед записью, providers/cian/serp.py _format_address skip_types={"location",...}). Без города такой адрес при геокодинге считался "город не назван" и коллизировал с одноимённой екатеринбургской улицей (Ленина/Победы/Тенистая — сотни совпадений в ЕКБ-реестрах) → объявление получало координаты Екатеринбурга. Fix: отдельная колонка listings.city (196_listings_city.sql), проставляется из sweep-контекста через save_listings(..., city=...) — НЕ парсингом/дописыванием в address. Раздельная колонка не портит исходный текст адреса: downstream text-парсеры (geocoder._parse_street_house/_names_non_ekb_city, estimator house-matching) продолжают работать на исходном сыром тексте неизменёнными — дописывание города в address ломало бы bare-form адреса без street-маркера ("Дружинина, 33" без "ул.") в этих же парсерах. Симметрия: EKB-варианты city-sweep функций (city_slug=None) тоже получают city="Екатеринбург" — resolve_city_name(None) даёт тот же ЕКБ-дефолт, что и get_city_location/get_city_anchors. Проставлено во всех продовых write-путях: run_avito_city_sweep/run_yandex_city_sweep/run_cian_city_sweep (city_slug-aware), run_avito_newbuilding_sweep/run_cian_full_load/run_yandex_full_load/ run_avito_full_load (подтверждённо EKB-only по докстрингам), run_domclick_city_sweep (EKB city_id, oblast B2 ещё не wired — честный None для неизвестного city_id). Scope: только write-path для НОВЫХ листингов. Бэкфилл накопленных строк и консультация city в geocode_missing_listings/backfill_coords_from_geoportal (gate там пока text-only, _names_non_ekb_city) — geocoder.py намеренно не тронут (#2582/#2580) — отдельные follow-up задачи. --- .../backend/data/sql/196_listings_city.sql | 44 ++++ tradein-mvp/backend/tests/test_city_sweep.py | 35 +++ .../tests/test_listings_city_from_sweep.py | 232 ++++++++++++++++++ .../tests/test_scraper_kit_pipeline_parity.py | 52 +++- .../test_scraper_kit_pipeline_parity2.py | 107 +++++++- .../scraper-kit/src/scraper_kit/base.py | 18 +- .../src/scraper_kit/orchestration/pipeline.py | 89 ++++++- 7 files changed, 564 insertions(+), 13 deletions(-) create mode 100644 tradein-mvp/backend/data/sql/196_listings_city.sql create mode 100644 tradein-mvp/backend/tests/test_listings_city_from_sweep.py diff --git a/tradein-mvp/backend/data/sql/196_listings_city.sql b/tradein-mvp/backend/data/sql/196_listings_city.sql new file mode 100644 index 00000000..2da941a6 --- /dev/null +++ b/tradein-mvp/backend/data/sql/196_listings_city.sql @@ -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; diff --git a/tradein-mvp/backend/tests/test_city_sweep.py b/tradein-mvp/backend/tests/test_city_sweep.py index 55cdfb4d..87c6aa0e 100644 --- a/tradein-mvp/backend/tests/test_city_sweep.py +++ b/tradein-mvp/backend/tests/test_city_sweep.py @@ -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 ─────────────────────────────────────────────────────── diff --git a/tradein-mvp/backend/tests/test_listings_city_from_sweep.py b/tradein-mvp/backend/tests/test_listings_city_from_sweep.py new file mode 100644 index 00000000..e00160f1 --- /dev/null +++ b/tradein-mvp/backend/tests/test_listings_city_from_sweep.py @@ -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() diff --git a/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity.py b/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity.py index 0c5b2554..a2973834 100644 --- a/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity.py +++ b/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity.py @@ -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"] == "Екатеринбург" diff --git a/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py b/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py index 0c8bbd9f..a5d54834 100644 --- a/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py +++ b/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py @@ -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"] == "Екатеринбург" diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/base.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/base.py index d3919be1..9af5f933 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/base.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/base.py @@ -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), diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py index 756f315e..89967ce4 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py @@ -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