From 59fa6d52da8dfdbf6907a444de1e3e4a0db5eefe Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 30 Jul 2026 09:05:43 +0300 Subject: [PATCH 001/141] =?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 002/141] =?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 003/141] 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 004/141] 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 005/141] =?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 006/141] =?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 007/141] =?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 008/141] =?UTF-8?q?feat(tradein/team):=20team-management?= =?UTF-8?q?=20API=20=E2=80=94=20employees=20CRUD,=20quotas,=20stats=20(#25?= =?UTF-8?q?54)?= 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 009/141] =?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 010/141] =?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 011/141] =?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 012/141] =?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 013/141] =?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 014/141] =?UTF-8?q?fix(tradein/db):=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D0=B7=D0=B0=D1=82=D0=B8=D1=80=D0=B0=D1=82=D1=8C=20manager=5Fid?= =?UTF-8?q?=20=D0=BF=D1=80=D0=B8=20=D0=BF=D0=BE=D0=B2=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D0=BD=D0=BE=D0=BC=20=D0=BF=D1=80=D0=BE=D0=B3=D0=BE=D0=BD=D0=B5?= =?UTF-8?q?=20=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 015/141] =?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 016/141] =?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 017/141] =?UTF-8?q?feat(tradein/caddy):=20=D1=81=D0=BD?= =?UTF-8?q?=D1=8F=D1=82=D1=8C=20basic=5Fauth=20=D1=81=20/trade-in=20?= =?UTF-8?q?=E2=80=94=20=D1=81=D0=B2=D0=BE=D1=8F=20=D0=B0=D0=B2=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=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 018/141] =?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 019/141] =?UTF-8?q?docs(tradein/caddy):=20=D0=B8=D1=81?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D1=82=D1=8C=20=D0=BE=D0=B1=D0=BE?= =?UTF-8?q?=D1=81=D0=BD=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20scrub=20+=20?= =?UTF-8?q?scrub=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 020/141] =?UTF-8?q?fix(tradein/team):=20admin=20=D1=83?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D1=8F=D0=B5=D1=82=20=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D0=B5=D0=B4=D0=B6=D0=B5=D1=80=D0=B0=D0=BC=D0=B8=20?= =?UTF-8?q?=E2=80=94=20=D1=81=D0=B1=D1=80=D0=BE=D1=81=20=D0=BF=D0=B0=D1=80?= =?UTF-8?q?=D0=BE=D0=BB=D1=8F=20=D0=B8=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=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 021/141] =?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 022/141] =?UTF-8?q?fix(tradein/avito):=20=D0=BE=D0=B1?= =?UTF-8?q?=D0=BE=D0=B3=D0=B0=D1=89=D0=B0=D1=82=D1=8C=20=D0=B4=D0=B5=D1=82?= =?UTF-8?q?=D0=B0=D0=BB=D1=8F=D0=BC=D0=B8=20=D0=BB=D0=B8=D1=81=D1=82=D0=B8?= =?UTF-8?q?=D0=BD=D0=B3=D0=B8=20=D0=B2=D1=81=D0=B5=D0=B9=20=D0=BE=D0=B1?= =?UTF-8?q?=D0=BB=D0=B0=D1=81=D1=82=D0=B8,=20=D0=BD=D0=B5=20=D1=82=D0=BE?= =?UTF-8?q?=D0=BB=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 023/141] =?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 024/141] =?UTF-8?q?fix(tradein/geocoder):=20=D0=BD=D0=B5?= =?UTF-8?q?=20=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 025/141] =?UTF-8?q?test(tradein):=20=D0=B4=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=B2=D0=B8=D1=82=D1=8C=20deal=5Fcity=5Fprice=5Fbands=5F?= =?UTF-8?q?refresh=20=D0=B2=20=D0=BF=D0=B0=D1=80=D0=B8=D1=82=D0=B5=D1=82-?= =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82=20=D0=BF=D0=BB=D0=B0=D0=BD=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=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 026/141] =?UTF-8?q?fix(tradein/avito):=20=D1=8D=D0=BA?= =?UTF-8?q?=D1=80=D0=B0=D0=BD=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20?= =?UTF-8?q?=5F=20=D0=B2=20LIKE-=D0=BF=D0=B0=D1=82=D1=82=D0=B5=D1=80=D0=BD?= =?UTF-8?q?=D0=B0=D1=85=20+=20=D1=87=D0=B5=D1=81=D1=82=D0=BD=D0=B0=D1=8F?= =?UTF-8?q?=20=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 027/141] =?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 028/141] =?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 029/141] =?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 030/141] =?UTF-8?q?fix(tradein/ui):=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D1=81=D0=BB=D0=B0=D1=82=D1=8C=20city=5Fhint=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D1=83=D0=BC=D0=BE=D0=BB=D1=87=D0=B0=D0=BD=D0=B8=D1=8E=20?= =?UTF-8?q?=E2=80=94=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D1=8F=D0=B2?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=20=D0=B8?= =?UTF-8?q?=D0=BB=D0=B8=20=D0=B3=D0=BE=D1=80=D0=BE=D0=B4=20=D0=B8=D0=B7=20?= =?UTF-8?q?=D0=B0=D0=B4=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 031/141] =?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 032/141] =?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 033/141] =?UTF-8?q?fix(tradein/tasks):=20=D0=B3=D0=BE?= =?UTF-8?q?=D1=80=D0=BE=D0=B4=D1=81=D0=BA=D0=BE=D0=B9=20=D0=B3=D0=B5=D0=B9?= =?UTF-8?q?=D1=82=20=D0=B2=20=D0=BD=D0=BE=D1=87=D0=BD=D0=BE=D0=BC=20=D0=B1?= =?UTF-8?q?=D1=8D=D0=BA=D1=84=D0=B8=D0=BB=D0=BB=D0=B5=20=D0=BA=D0=BE=D0=BE?= =?UTF-8?q?=D1=80=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 034/141] =?UTF-8?q?fix(tradein/ui):=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D0=BD=D0=B0=D0=B7=D1=8B=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BD=D0=B5?= =?UTF-8?q?=D0=BF=D0=BE=D0=B4=D1=82=D0=B2=D0=B5=D1=80=D0=B6=D0=B4=D1=91?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D0=B9=20=D0=B3=D0=BE=D1=80=D0=BE=D0=B4=20?= =?UTF-8?q?=D0=B2=20=D0=BF=D0=BB=D0=B0=D1=88=D0=BA=D0=B5=20=D0=BD=D0=B5?= =?UTF-8?q?=D0=BE=D0=BF=D1=80=D0=B5=D0=B4=D0=B5=D0=BB=D1=91=D0=BD=D0=BD?= =?UTF-8?q?=D0=BE=D1=81=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 035/141] =?UTF-8?q?fix(tradein/ui):=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D0=BE=D0=B1=D1=80=D0=B5=D0=B7=D0=B0=D1=82=D1=8C=20=D0=B4=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BD=D1=8B=D0=B5=20=D0=BD=D0=B0=D0=B7=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=B3=D0=BE=D1=80=D0=BE=D0=B4=D0=BE=D0=B2?= =?UTF-8?q?=20=D0=B2=20=D1=81=D0=B5=D0=BB=D0=B5=D0=BA=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D0=B5=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 036/141] =?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 037/141] =?UTF-8?q?chore(ci+rbac):=20auth/**=20=D0=B2=20pa?= =?UTF-8?q?ths-=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=D1=8B=20+=20=C2=AB?= =?UTF-8?q?=D0=9F=D0=BE=D0=B8=D1=81=D0=BA=20=D0=B4=D0=BE=D0=BC=D0=BE=D0=B2?= =?UTF-8?q?=C2=BB=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D1=83=20=D0=B0?= =?UTF-8?q?=D0=B4=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 038/141] =?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 039/141] =?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 040/141] =?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 041/141] =?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 From feff55bea9eec7fc1ff536e381123f0e11f94d17 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 31 Jul 2026 23:16:19 +0300 Subject: [PATCH 042/141] =?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=BE=D1=81=D1=82=D0=B0=D1=82?= =?UTF-8?q?=D0=BA=D0=B8=20=D1=81=D0=BA=D1=80=D0=B8=D0=BF=D1=82=D0=BE=D0=B2?= =?UTF-8?q?=20=D0=AF=D0=BD=D0=B4=D0=B5=D0=BA=D1=81-=D0=B3=D0=B5=D0=BE?= =?UTF-8?q?=D0=BA=D0=BE=D0=B4=D0=B5=D1=80=D0=B0,=20=D1=87=D0=B0=D1=81?= =?UTF-8?q?=D1=82=D1=8C=203=20(#2593)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Удалены мёртвые ops-скрипты Yandex Geocoder (уже недостижимы после #2593 частей 1-2): - scripts/_yandex_reverse.py, scripts/audit_address_mismatch.py, scripts/backfill_house_coords.py - их тесты + осиротевшая фикстура tests/fixtures/yandex_geocode_sample.json - осиротевшие SQL-хелперы scripts/audit_address_sample.sql, scripts/address_audit_report.sql (использовались только audit_address_mismatch.py) Обновлена документация (осиротевшие упоминания YANDEX_GEOCODER_API_KEY / удалённых скриптов): scripts/README.md, tradein-mvp/DEPLOY.md, docs/Secrets_Rotation_Policy.md. Добавлен tests/test_geocoder_nominatim_lookup.py — покрывает _nominatim_lookup (единственный живой внешний геокодер) на предмет реальной передачи city_hint в исходящий HTTP-запрос к Nominatim; закрывает дыру в coverage, оставленную удалёнными yandex-тестами. Refs #2593 --- docs/Secrets_Rotation_Policy.md | 7 +- tradein-mvp/DEPLOY.md | 8 +- tradein-mvp/backend/scripts/README.md | 150 +---- .../backend/scripts/_yandex_reverse.py | 380 ----------- .../backend/scripts/address_audit_report.sql | 91 --- .../backend/scripts/audit_address_mismatch.py | 595 ----------------- .../backend/scripts/audit_address_sample.sql | 47 -- .../backend/scripts/backfill_house_coords.py | 619 ------------------ .../tests/fixtures/yandex_geocode_sample.json | 74 --- .../tests/test_audit_address_mismatch.py | 373 ----------- .../tests/test_backfill_house_coords.py | 510 --------------- .../tests/test_geocoder_nominatim_lookup.py | 91 +++ 12 files changed, 109 insertions(+), 2836 deletions(-) delete mode 100644 tradein-mvp/backend/scripts/_yandex_reverse.py delete mode 100644 tradein-mvp/backend/scripts/address_audit_report.sql delete mode 100644 tradein-mvp/backend/scripts/audit_address_mismatch.py delete mode 100644 tradein-mvp/backend/scripts/audit_address_sample.sql delete mode 100644 tradein-mvp/backend/scripts/backfill_house_coords.py delete mode 100644 tradein-mvp/backend/tests/fixtures/yandex_geocode_sample.json delete mode 100644 tradein-mvp/backend/tests/test_audit_address_mismatch.py delete mode 100644 tradein-mvp/backend/tests/test_backfill_house_coords.py create mode 100644 tradein-mvp/backend/tests/test_geocoder_nominatim_lookup.py diff --git a/docs/Secrets_Rotation_Policy.md b/docs/Secrets_Rotation_Policy.md index 8a913a42..61180727 100644 --- a/docs/Secrets_Rotation_Policy.md +++ b/docs/Secrets_Rotation_Policy.md @@ -77,7 +77,6 @@ |---|---|---| | `TRADEIN_POSTGRES_PASSWORD` / `TRADEIN_POSTGRES_USER` | Пароль/юзер БД `tradein` | **E** | | `TRADEIN_READER_PASSWORD` | Пароль роли `gendesign_reader` (ETL #976, `ops/db-bootstrap/set_gendesign_reader_password.sql`) | **E** | -| `YANDEX_GEOCODER_API_KEY` | Yandex Geocoder (25k req/day) | **D** | | `DADATA_API_TOKEN` / `DADATA_API_SECRET` | DaData `/clean/address` enrichment | **D** | | `SCRAPER_PROXY_URL` (+ legacy `AVITO_PROXY_URL`, `CIAN_PROXY_URL`, `YANDEX_PROXY_URL` и их `*_ROTATE_URL`) | Мобильный прокси для скраперов (содержит user:pass в URL) | **G** (proxy creds) | | `CIAN_LOGIN_EMAIL` / `CIAN_LOGIN_PASSWORD` | Cian browser auto-login (#639, Variant B) | **D** | @@ -147,14 +146,14 @@ bcrypt-хеши — односторонние, не plaintext-секреты, 3. Frontend: обновить `GLITCHTIP_FRONTEND_DSN` (build-arg `NEXT_PUBLIC_GLITCHTIP_DSN`) → требует **rebuild frontend образа** (запекается на build-time) → `workflow_dispatch` или push в `frontend/**`. 4. Vault entry. -### Класс D — 3rd-party API keys (`OBJECTIVE_API_KEY`, `OPENAI_API_KEY`, `YANDEX_GEOCODER_API_KEY`, `DADATA_*`, `CIAN_LOGIN_*`) +### Класс D — 3rd-party API keys (`OBJECTIVE_API_KEY`, `OPENAI_API_KEY`, `DADATA_*`, `CIAN_LOGIN_*`) **Downtime:** нет (фичи gracefully degrade при пустом ключе — см. config-комментарии). -1. Перевыпустить/ротировать ключ в кабинете провайдера (Объектив / OpenAI / Yandex Cloud / DaData / Cian-аккаунт). +1. Перевыпустить/ротировать ключ в кабинете провайдера (Объектив / OpenAI / DaData / Cian-аккаунт). 2. Где живёт: - `OBJECTIVE_API_KEY`, `OPENAI_API_KEY` — Forgejo secret → deploy пишет в main `.env.runtime`. - - `YANDEX_GEOCODER_API_KEY`, `DADATA_*`, `CIAN_LOGIN_*` — tradein `.env.runtime` (правится **на VPS вручную**, не из CI). + - `DADATA_*`, `CIAN_LOGIN_*` — tradein `.env.runtime` (правится **на VPS вручную**, не из CI). 3. Обновить значение `sed`-ом (НЕ перезапись файла) и `up -d --force-recreate --no-deps backend worker beat` (main) / `... backend scraper` (tradein). 4. Vault entry. diff --git a/tradein-mvp/DEPLOY.md b/tradein-mvp/DEPLOY.md index 6b9fdc2c..d8012ea5 100644 --- a/tradein-mvp/DEPLOY.md +++ b/tradein-mvp/DEPLOY.md @@ -57,8 +57,7 @@ import /opt/gendesign/tradein-mvp/deploy/Caddyfile.tradein-fragment shell-скриптом deploy через `source .env.runtime` перед `compose up`. 2. `/opt/gendesign/tradein-mvp/backend/.env.runtime` — переменные внутри контейнера `tradein-backend` (читаются через `env_file:` в compose). Сюда - попадают `YANDEX_GEOCODER_API_KEY`, `COOKIE_ENCRYPTION_KEY` — - всё, что нужно scripts/backfill_house_coords.py и application code внутри + попадают `COOKIE_ENCRYPTION_KEY` и остальные application-секреты внутри контейнера. ```bash @@ -66,7 +65,6 @@ import /opt/gendesign/tradein-mvp/deploy/Caddyfile.tradein-fragment TRADEIN_POSTGRES_USER=tradein TRADEIN_POSTGRES_PASSWORD=<сгенерировать openssl rand -hex 32> TRADEIN_CONTACT_EMAIL=tradein@gendsgn.ru -YANDEX_GEOCODER_API_KEY= # пусто пока, Nominatim fallback работает # Encryption key for Cian session cookies (pgp_sym_encrypt / Stage 9 Calculator). # Empty = Valuation Calculator scraper disabled + /api/v1/cookies/upload returns 503. @@ -77,10 +75,9 @@ COOKIE_ENCRYPTION_KEY=<64-char hex> ```bash # /opt/gendesign/tradein-mvp/backend/.env.runtime — те же ключи которые -# читаются ВНУТРИ container'а (scripts/backfill_house_coords.py, app/*). +# читаются ВНУТРИ container'а (app/*, scripts/*.py). # Может быть симлинком на ../.env.runtime если переменные совпадают: # ln -s ../.env.runtime /opt/gendesign/tradein-mvp/backend/.env.runtime -YANDEX_GEOCODER_API_KEY= COOKIE_ENCRYPTION_KEY=<64-char hex> GENDESIGN_FDW_PASSWORD= GLITCHTIP_DSN= @@ -200,7 +197,6 @@ cat > tradein-mvp/.env.runtime < Локальные примеры ниже — для dev-машины с `uv run` и переменными в shell. -> На prod используй canonical `docker exec` команды из секции выше — там -> `YANDEX_GEOCODER_API_KEY` уже подгружен из `backend/.env.runtime`. - -### `audit_address_mismatch.py` — Phase 1 baseline (PR #583) - -Stratified-sample audit (200 EKB houses) comparing `houses.address` vs -Yandex Geocoder reverse lookup. Writes one row per house into -`address_mismatch_audit` with the snapped point + canonical address + distance. - -```bash -DATABASE_URL=postgresql+psycopg://... \ -YANDEX_GEOCODER_API_KEY=... \ -uv run python -m scripts.audit_address_mismatch \ - --batch 2026-05-25_run1 \ - --limit-per-district 25 -``` - -Mode `auto` picks API if the key is set, otherwise Playwright (CAPTCHA-aware, -4-7s sleep between calls). API tier free is 25k req/day → 200-row sample -takes ~10s with no quota concern. - -Report: - -```bash -psql "$DATABASE_URL" -v batch='2026-05-25_run1' \ - -f scripts/address_audit_report.sql -``` - -### `backfill_house_coords.py` — Phase 2-3 (PR for #582) - -Two modes (`--audit-only` flag switches between them): - -**Backfill (default)** — forward-geocode `houses.address` for the ~4141 rows -WHERE `lat IS NULL OR lon IS NULL`. Only writes back if Yandex returns -`precision='exact'` or `'number'` (skips street-only / locality matches). -Each processed row gets an `address_mismatch_audit` entry with status -`backfill` / `imprecise` / `no_match` / `error`. - -```bash -DATABASE_URL=postgresql+psycopg://... \ -YANDEX_GEOCODER_API_KEY=... \ -uv run python -m scripts.backfill_house_coords \ - --batch 2026-05-27_backfill -``` - -Expected duration (~4141 rows, 50ms between calls, ~250ms RTT per request): -20-25 min. Expected output split (rough baseline from Phase 1 numbers): - -| Status | Approx rows | What it means | -|-------------|-------------|-----------------------------------------------------| -| `backfill` | ~3.3k–3.7k | UPDATE landed, lat/lon now populated | -| `imprecise` | ~300–500 | Match returned but precision too low — needs review | -| `no_match` | ~100–300 | Yandex couldn't resolve; address probably mangled | -| `error` | <50 | HTTP errors / timeouts — re-run picks them up | - -**Audit-only** — reverse-geocode the ~4452 houses WITH coords, write -audit rows with status `ok` (≤50m) / `mismatch` (>50m) / `no_match` / `error`. -Does NOT modify the `houses` table. - -```bash -uv run python -m scripts.backfill_house_coords \ - --batch 2026-05-27_audit --audit-only -``` - -Combined budget for both phases (~8.6k requests) is well under the 25k/day -Geocoder free tier. - -### Common ops - -Canary first — run with `--limit 100` and inspect the audit table before -letting the full job loose: - -```bash -uv run python -m scripts.backfill_house_coords \ - --batch canary_$(date +%F) --limit 100 -psql "$DATABASE_URL" -c " - SELECT audit_status, COUNT(*) - FROM address_mismatch_audit - WHERE audit_batch = 'canary_$(date +%F)' - GROUP BY audit_status; -" -``` - -Resume after crash / quota hit — same `--batch` label, the UNIQUE -`(house_id, audit_batch)` index skips finished rows: - -```bash -uv run python -m scripts.backfill_house_coords --batch 2026-05-27_backfill -# ... interruption ... -uv run python -m scripts.backfill_house_coords --batch 2026-05-27_backfill -# logs: "resuming batch 2026-05-27_backfill: N rows already processed" -``` - -### Helpers (not entry points) - -- `_yandex_reverse.py` — `forward_via_api()`, `reverse_via_api()`, - `reverse_via_playwright()`, `YandexReverseResult` dataclass. Both API - paths share `_parse_api_payload` because Yandex's forward/reverse - envelopes have the same shape. -- `audit_address_sample.sql` — random sample for the Phase 1 audit (used - by `audit_address_mismatch.py`). -- `address_audit_report.sql` — psql-driven post-run summary (p50/p75/p95 - distance, top-20 outliers, per-district breakdown). +`audit_address_mismatch.py`, `backfill_house_coords.py`, `_yandex_reverse.py` +и их SQL-хелперы (`audit_address_sample.sql`, `address_audit_report.sql`) +удалены — весь pipeline опирался на Yandex Geocoder API, который выпилен +из проекта (#2593, части 1-3). `houses.address`→lat/lon geocoding теперь +идёт через `app/services/geocoder.py` (кадастр/геопортал ЕКБ-тиры + Nominatim +fallback, единственный живой внешний провайдер) на обычном write-path +(`/api/v1/trade-in/estimate`, listing ingest). Разовый forward-backfill +недостающих `houses` координат — `scripts/geocode_deals_nominatim.py` +(живой, работает с `rosreestr_deals`, не с `houses` — читай его docstring +перед использованием на других таблицах). Таблица `address_mismatch_audit` +осталась в схеме (используется `house_dedup_merge.py` при слиянии дублей +домов, независимо от Yandex-аудита). --- diff --git a/tradein-mvp/backend/scripts/_yandex_reverse.py b/tradein-mvp/backend/scripts/_yandex_reverse.py deleted file mode 100644 index 2e01146c..00000000 --- a/tradein-mvp/backend/scripts/_yandex_reverse.py +++ /dev/null @@ -1,380 +0,0 @@ -"""Yandex Geocoder helpers for the address-mismatch audit + backfill (issue #582). - -Three geocoding paths exposed: - -- `reverse_via_api()` — Yandex Geocoder HTTP API, lon/lat → address. Fast, - structured response, needs a valid API key (env `YANDEX_GEOCODER_API_KEY`). - Free tier is 25k req/day, fine for ~8.5k houses + audit (~17k total). - -- `reverse_via_playwright()` — fallback when no API key is available. Drives - a real browser session at https://yandex.ru/maps/?…&mode=whatshere. Slower - and CAPTCHA-prone, so the driver inserts 4-7s sleeps between calls and we - raise a dedicated exception on CAPTCHA so the batch can pause-and-resume. - -- `forward_via_api()` — address → lon/lat + canonical address (Phase 2 of - issue #582). Used by `backfill_house_coords.py` to fill `houses.lat/lon` - for the 4141 houses scraped from sources that didn't include coords (esp. - yandex_valuation, which only returns an address string). - -All three return a `YandexReverseResult` dataclass — same shape regardless -of direction so the driver code stays implementation-agnostic. The `raw` -field always carries the full source payload for post-hoc diagnostics, and -`precision` / `kind` are filled in by the API paths so the caller can skip -imprecise matches (e.g. only-street-level results during backfill). - -Why three paths: -The user (issue #582 discussion) wants the audit to run on dev machines -that may not have an API key, but on prod we already provision the key for -estimator.py. Forward geocode is API-only — Playwright forward geocoding -through Yandex Maps search is too fragile (relevance ranking, suggest -dropdown). For dev without a key, backfill simply doesn't run. -""" - -from __future__ import annotations - -import asyncio -import logging -import random -from dataclasses import dataclass, field -from typing import Any - -import httpx - -logger = logging.getLogger(__name__) - -# Yandex Maps "what's here" URL — wraps a reverse-geocode in browser-driven UI. -# `whatshere[point]` accepts "," (note: lon first, Yandex convention). -_YANDEX_MAPS_WHATSHERE = ( - "https://yandex.ru/maps/?ll={lon:.6f}%2C{lat:.6f}&z=18&mode=whatshere" - "&whatshere%5Bpoint%5D={lon:.6f}%2C{lat:.6f}&whatshere%5Bzoom%5D=18" -) - -# Geocoder HTTP API. `kind=house` narrows the result to a building if possible, -# which is what we want for cadastr-style addresses (улица + дом). -_YANDEX_GEOCODE_API = "https://geocode-maps.yandex.ru/1.x/" - -# Reasonable timeouts: API call should be sub-second; we give it generous -# headroom for slow networks but not so much that a hang stalls the batch. -_API_TIMEOUT = httpx.Timeout(connect=5.0, read=10.0, write=5.0, pool=5.0) - - -# --------------------------------------------------------------------------- -# Dataclasses + exceptions -# --------------------------------------------------------------------------- - - -@dataclass -class YandexReverseResult: - """Normalized result of a geocode call (forward, reverse-API, or browser). - - Attributes: - address: Human-readable canonical address Yandex returned. For - reverse, this is the snapped address at the queried point. For - forward, this is the canonical form of the input address. None - if Yandex returned no match. - snapped_lat: Latitude of the matched object's geometric centre. - snapped_lon: Longitude of the matched object's geometric centre. - precision: For forward calls — Yandex match precision tag (`exact`, - `number`, `near`, `range`, `street`, `other`). For reverse — - same field is filled when present (usually `house` / `street`). - None for the playwright path. Used by the backfill driver to - skip imprecise matches. - kind: Object kind from Yandex (`house`, `street`, `locality`, ...). - Same source as `precision` — see metaDataProperty.GeocoderMetaData. - raw: Raw response payload retained for forensics (JSON dict from API, - or snapshot dict from playwright). Used to populate - `address_mismatch_audit.raw_payload` and - `houses.raw_payload.yandex_geocode`. - """ - - address: str | None - snapped_lat: float | None - snapped_lon: float | None - raw: dict[str, Any] = field(default_factory=dict) - precision: str | None = None - kind: str | None = None - - -class YandexBlockedError(RuntimeError): - """Raised when Yandex returns a CAPTCHA / anti-bot challenge. - - The driver catches this, marks the row `audit_status='blocked'`, logs the - current batch position, then exits cleanly so a human can intervene. - """ - - -# --------------------------------------------------------------------------- -# Path A — HTTP Geocoder API -# --------------------------------------------------------------------------- - - -async def reverse_via_api( - lat: float, - lon: float, - api_key: str, - *, - client: httpx.AsyncClient | None = None, -) -> YandexReverseResult: - """Reverse-geocode (lat, lon) via the Yandex Geocoder HTTP API. - - Why a separate `client` parameter: lets the driver reuse one - `AsyncClient` across all 200 calls (TCP keep-alive + connection pool), - and lets the tests inject a `MockTransport` to assert request shape. - - Args: - lat: latitude in WGS84. - lon: longitude in WGS84. - api_key: Yandex Geocoder API key. - client: optional pre-built async client. If None, a one-shot client - is created. - - Returns: - `YandexReverseResult` with the first `featureMember[0].GeoObject` - result, or all-None if Yandex returned no match (still includes - `raw` payload so we can later inspect why). - """ - params = { - "apikey": api_key, - # Yandex expects "lon,lat" (longitude first) per docs — same - # convention as the "whatshere" map URL above. - "geocode": f"{lon},{lat}", - "format": "json", - "kind": "house", - "results": "1", - } - - own_client = client is None - if client is None: - client = httpx.AsyncClient(timeout=_API_TIMEOUT) - - try: - resp = await client.get(_YANDEX_GEOCODE_API, params=params) - resp.raise_for_status() - data = resp.json() - finally: - if own_client: - await client.aclose() - - return _parse_api_payload(data) - - -def _parse_api_payload(data: dict[str, Any]) -> YandexReverseResult: - """Extract address + snapped point from a Yandex Geocoder API JSON response. - - Split out so unit tests can feed a fixture file directly without spinning - up an HTTP mock. Same payload shape for forward and reverse calls — - Yandex's response envelope is symmetric. - """ - try: - members = data.get("response", {}).get("GeoObjectCollection", {}).get("featureMember", []) - if not members: - return YandexReverseResult(address=None, snapped_lat=None, snapped_lon=None, raw=data) - - geo_obj = members[0].get("GeoObject", {}) - - # Address: prefer the long `metaDataProperty.GeocoderMetaData.text` - # (full canonical) and fall back to `name` (street + house number). - meta = geo_obj.get("metaDataProperty", {}).get("GeocoderMetaData", {}) - address = meta.get("text") or geo_obj.get("name") - precision = meta.get("precision") - kind = meta.get("kind") - - # Point format: " " — space-separated string. - point_str = geo_obj.get("Point", {}).get("pos", "") - snapped_lon: float | None - snapped_lat: float | None - if point_str: - try: - lon_s, lat_s = point_str.split() - snapped_lon = float(lon_s) - snapped_lat = float(lat_s) - except (ValueError, TypeError): - snapped_lon = None - snapped_lat = None - else: - snapped_lon = None - snapped_lat = None - - return YandexReverseResult( - address=address, - snapped_lat=snapped_lat, - snapped_lon=snapped_lon, - raw=data, - precision=precision, - kind=kind, - ) - except Exception as e: # pragma: no cover — defensive; tests cover happy paths - logger.warning("yandex API payload parse failed: %s", e) - return YandexReverseResult(address=None, snapped_lat=None, snapped_lon=None, raw=data) - - -# --------------------------------------------------------------------------- -# Path A.2 — Forward geocode (address → lon/lat) via HTTP API -# --------------------------------------------------------------------------- - - -async def forward_via_api( - address: str, - api_key: str, - *, - client: httpx.AsyncClient | None = None, -) -> YandexReverseResult: - """Forward-geocode an address string via the Yandex Geocoder HTTP API. - - Phase 2 of issue #582 — used by `backfill_house_coords.py` to populate - `houses.lat/lon` for houses that were scraped without coords (esp. - yandex_valuation rows, which only carry an address). - - Args: - address: free-form address ("ул Малышева 51", "Екатеринбург, Ленина 5", - etc.). Yandex's NLU is forgiving — no need to pre-normalize. - api_key: Yandex Geocoder API key. - client: optional pre-built async client. If None, a one-shot client - is created (matches `reverse_via_api` ergonomics). - - Returns: - `YandexReverseResult` with the canonical address + snapped point of - the first matching feature. `precision` and `kind` are populated so - the backfill driver can skip imprecise hits (e.g. precision='street' - means we landed on the road, not the building — too vague for - comparable-listings spatial queries). - - Same envelope as `reverse_via_api` — `_parse_api_payload` handles both. - """ - params = { - "apikey": api_key, - "geocode": address, - "format": "json", - # `kind=house` filters out street-only / locality-only matches at - # the API level when possible. Yandex still returns lower-precision - # results when no building matches, so the caller must double-check - # `precision` before writing to houses. - "kind": "house", - "results": "1", - # Locality bias for EKB — improves recall when the input address - # omits the city. The audit population is 99% EKB houses, so this - # is safe; non-EKB inputs (rare) still resolve, just with the bias. - "ll": "60.6122,56.8389", - "spn": "0.6,0.4", - } - - own_client = client is None - if client is None: - client = httpx.AsyncClient(timeout=_API_TIMEOUT) - - try: - resp = await client.get(_YANDEX_GEOCODE_API, params=params) - resp.raise_for_status() - data = resp.json() - finally: - if own_client: - await client.aclose() - - return _parse_api_payload(data) - - -# --------------------------------------------------------------------------- -# Path B — Playwright fallback -# --------------------------------------------------------------------------- - - -async def reverse_via_playwright( - lat: float, - lon: float, - page: Any, -) -> YandexReverseResult: - """Reverse-geocode (lat, lon) by driving yandex.ru/maps with Playwright. - - Why this exists: - The Yandex Geocoder API requires a key with paid quota for >25k/day. The - audit only needs 200 rows but a dev without a key still needs a way to - run the script, so we ship a browser-driven fallback. - - Implementation: - 1. Navigate to the `whatshere` URL — Yandex Maps responds by opening a - toponym card at the requested coordinates and rendering the resolved - address in the side panel. - 2. Wait for client hydration (`networkidle`). - 3. First try to read `window.__INITIAL_STATE__` — Yandex stores the - toponym address inside the hydrated Redux tree, which is more - stable across UI redesigns than DOM selectors. - 4. Fall back to DOM selectors (`.toponym-card-title-view__title` + - `__subtitle`) if the state walk doesn't find an address. - 5. Detect CAPTCHA (`.CheckboxCaptcha`) early and raise `YandexBlockedError` - so the batch can pause-and-resume without spamming Yandex. - - `page` is typed as `Any` to keep playwright a dev-only dep — runtime - importers don't need playwright installed if they only use the API path. - """ - url = _YANDEX_MAPS_WHATSHERE.format(lat=lat, lon=lon) - await page.goto(url, wait_until="domcontentloaded") - - # Light wait for client-side hydration. Yandex Maps fires lots of - # background XHRs so `networkidle` is too aggressive; this small wait is - # enough for the toponym card to render. - try: - await page.wait_for_load_state("networkidle", timeout=8000) - except Exception as e: - # Slow networks: continue — selectors will retry with their own waits. - logger.debug("networkidle wait timed out, continuing: %s", e) - await asyncio.sleep(random.uniform(0.5, 1.2)) - - # CAPTCHA gate — Yandex shows a `.CheckboxCaptcha` form when it suspects - # automation. Once we see it, every subsequent reverse call will also be - # blocked, so we raise immediately and let the driver stop the batch. - captcha = await page.query_selector(".CheckboxCaptcha") - if captcha is not None: - raise YandexBlockedError("Yandex CAPTCHA detected on maps page") - - # Attempt 1 — initial state walk. - state_addr: str | None = None - state_pos: tuple[float, float] | None = None - try: - state_addr, state_pos = await page.evaluate( - "() => {\n" - " const s = window.__INITIAL_STATE__ || {};\n" - " const card = (s.cards && s.cards.toponym) || (s.card && s.card.toponym) || null;\n" - " if (!card) return [null, null];\n" - " const addr = card.title || card.address || null;\n" - " const pos = card.coords || card.point || null;\n" - " if (pos && pos.length === 2) return [addr, [pos[0], pos[1]]];\n" - " return [addr, null];\n" - "}" - ) - except Exception as e: - logger.debug("playwright state walk failed (will fall back to DOM): %s", e) - - address = state_addr - - # Attempt 2 — DOM fallback. - if not address: - title_el = await page.query_selector(".toponym-card-title-view__title") - subtitle_el = await page.query_selector(".toponym-card-title-view__subtitle") - title = (await title_el.inner_text()).strip() if title_el else "" - subtitle = (await subtitle_el.inner_text()).strip() if subtitle_el else "" - # subtitle often holds "Екатеринбург, район", title the street + house - address = ", ".join([p for p in (subtitle, title) if p]) or None - - snapped_lat: float | None - snapped_lon: float | None - if state_pos: - # State stored as [lon, lat] in Yandex's coordinate convention. - snapped_lon = float(state_pos[0]) - snapped_lat = float(state_pos[1]) - else: - snapped_lon = None - snapped_lat = None - - raw = { - "url": url, - "state_addr": state_addr, - "state_pos": list(state_pos) if state_pos else None, - "dom_address": address if not state_addr else None, - } - - return YandexReverseResult( - address=address, - snapped_lat=snapped_lat, - snapped_lon=snapped_lon, - raw=raw, - ) diff --git a/tradein-mvp/backend/scripts/address_audit_report.sql b/tradein-mvp/backend/scripts/address_audit_report.sql deleted file mode 100644 index 7b391b9e..00000000 --- a/tradein-mvp/backend/scripts/address_audit_report.sql +++ /dev/null @@ -1,91 +0,0 @@ --- address_audit_report.sql --- Post-run report for the address-mismatch audit (issue #582 Phase 1). --- --- Sections: --- 1. Summary — count, p50/p75/p95/mean distance, % street_differs, --- % over 50m / 200m thresholds. --- 2. Top-20 outliers by distance (manual triage list). --- 3. Per-district breakdown — same metrics grouped by district column. --- --- Run via psql: --- psql "$DATABASE_URL" -v batch='2026-05-25_run1' -f scripts/address_audit_report.sql --- --- :batch is a psql client variable substituted via -v. - -\set ON_ERROR_STOP on - -\echo '==============================================' -\echo ' Address mismatch audit — batch:' :batch -\echo '==============================================' - --- --------------------------------------------------------------------------- --- 1) Top-level summary --- --------------------------------------------------------------------------- -\echo '' -\echo '--- Summary (status=ok rows only) ---' -SELECT - COUNT(*) AS n_total, - COUNT(*) FILTER (WHERE audit_status = 'ok') AS n_ok, - COUNT(*) FILTER (WHERE audit_status = 'no_match') AS n_no_match, - COUNT(*) FILTER (WHERE audit_status = 'error') AS n_error, - COUNT(*) FILTER (WHERE audit_status = 'blocked') AS n_blocked, - ROUND(percentile_cont(0.50) - WITHIN GROUP (ORDER BY distance_m)::numeric, 1) AS p50_distance_m, - ROUND(percentile_cont(0.75) - WITHIN GROUP (ORDER BY distance_m)::numeric, 1) AS p75_distance_m, - ROUND(percentile_cont(0.95) - WITHIN GROUP (ORDER BY distance_m)::numeric, 1) AS p95_distance_m, - ROUND(AVG(distance_m)::numeric, 1) AS mean_distance_m, - ROUND(100.0 * AVG(CASE WHEN street_differs THEN 1.0 ELSE 0.0 END), 1) - AS pct_street_differs, - ROUND(100.0 * AVG(CASE WHEN distance_m > 50 THEN 1.0 ELSE 0.0 END), 1) - AS pct_over_50m, - ROUND(100.0 * AVG(CASE WHEN distance_m > 200 THEN 1.0 ELSE 0.0 END), 1) - AS pct_over_200m -FROM address_mismatch_audit -WHERE audit_batch = :'batch' - AND audit_status = 'ok'; - --- --------------------------------------------------------------------------- --- 2) Top-20 outliers --- --------------------------------------------------------------------------- -\echo '' -\echo '--- Top-20 outliers by distance ---' -SELECT - house_id, - district, - ROUND(distance_m::numeric, 1) AS distance_m, - street_differs, - LEFT(original_address, 60) AS original_address, - LEFT(snapped_address, 60) AS snapped_address -FROM address_mismatch_audit -WHERE audit_batch = :'batch' - AND audit_status = 'ok' - AND distance_m IS NOT NULL -ORDER BY distance_m DESC NULLS LAST -LIMIT 20; - --- --------------------------------------------------------------------------- --- 3) Per-district breakdown --- --------------------------------------------------------------------------- -\echo '' -\echo '--- Per-district breakdown (status=ok only) ---' -SELECT - COALESCE(district, '(no district)') AS district, - COUNT(*) AS n, - ROUND(percentile_cont(0.50) - WITHIN GROUP (ORDER BY distance_m)::numeric, 1) AS p50_distance_m, - ROUND(percentile_cont(0.95) - WITHIN GROUP (ORDER BY distance_m)::numeric, 1) AS p95_distance_m, - ROUND(AVG(distance_m)::numeric, 1) AS mean_distance_m, - ROUND(100.0 * AVG(CASE WHEN street_differs THEN 1.0 ELSE 0.0 END), 1) - AS pct_street_differs, - ROUND(100.0 * AVG(CASE WHEN distance_m > 50 THEN 1.0 ELSE 0.0 END), 1) - AS pct_over_50m, - ROUND(100.0 * AVG(CASE WHEN distance_m > 200 THEN 1.0 ELSE 0.0 END), 1) - AS pct_over_200m -FROM address_mismatch_audit -WHERE audit_batch = :'batch' - AND audit_status = 'ok' -GROUP BY COALESCE(district, '(no district)') -ORDER BY n DESC, district; diff --git a/tradein-mvp/backend/scripts/audit_address_mismatch.py b/tradein-mvp/backend/scripts/audit_address_mismatch.py deleted file mode 100644 index 87dc7d88..00000000 --- a/tradein-mvp/backend/scripts/audit_address_mismatch.py +++ /dev/null @@ -1,595 +0,0 @@ -"""Audit driver — compares houses.address vs Yandex reverse geocode. - -Phase 1 of Forgejo issue #582. Pulls a stratified sample of EKB houses (25 -per admin district = 200 total), reverse-geocodes each via Yandex, computes -the distance between the stored coordinates and the snapped Yandex point, -and writes the result into `address_mismatch_audit`. - -Design choices: -- **Resumable**: the audit table has UNIQUE (house_id, audit_batch). Re-run - with the same `--batch` skips rows already inserted, so a partial run can - be picked up after CAPTCHA / network blip. -- **Mode auto**: prefer API when `YANDEX_GEOCODER_API_KEY` is set, fall back - to Playwright otherwise. Explicit override via `--mode {api,playwright}`. -- **No prod side effects**: the script only writes to one new audit table; - it never touches `houses`, `house_sources`, or any matching/listing row. -- **Per-row SAVEPOINT**: a single Yandex error must not nuke the entire - batch — wrap each INSERT in `db.begin_nested()` per backend.md. - -How to run: - DATABASE_URL=postgresql+psycopg://... \ - YANDEX_GEOCODER_API_KEY=... \ - python -m scripts.audit_address_mismatch --batch 2026-05-25_run1 - -Outputs (post-run): -- New rows in `address_mismatch_audit` with batch label. -- `scripts/address_audit_report.sql :batch=` for summary. -""" - -from __future__ import annotations - -import argparse -import asyncio -import json -import logging -import os -import random -from dataclasses import dataclass -from datetime import date -from pathlib import Path -from typing import Any - -import httpx -from sqlalchemy import text -from sqlalchemy.orm import Session - -# Allow running both as `python -m scripts.audit_address_mismatch` (preferred) -# and as a stand-alone file (`python scripts/audit_address_mismatch.py`) -# without requiring package install. -try: - from app.core.db import SessionLocal # type: ignore[import-not-found] - from app.services.matching.normalize import normalize_address # type: ignore[import-not-found] -except ImportError: # pragma: no cover — fallback for adhoc invocation - import sys - - sys.path.insert(0, str(Path(__file__).resolve().parents[1])) - from app.core.db import SessionLocal - from app.services.matching.normalize import normalize_address - -# `from .` works when run via -m; the absolute import works under pytest. -try: - from scripts._yandex_reverse import ( # type: ignore[import-not-found] - YandexBlockedError, - YandexReverseResult, - reverse_via_api, - reverse_via_playwright, - ) -except ImportError: - from _yandex_reverse import ( # type: ignore[no-redef] - YandexBlockedError, - YandexReverseResult, - reverse_via_api, - reverse_via_playwright, - ) - -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s %(levelname)s %(name)s %(message)s", -) -logger = logging.getLogger("audit_address_mismatch") - -# Playwright persistent context location — keeps cookies/local storage between -# runs so we look like a returning user, reducing CAPTCHA frequency. -_PLAYWRIGHT_USER_DATA = Path.home() / ".cache" / "tradein-audit-playwright" - -_PLAYWRIGHT_UA = ( - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " - "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" -) - -_SAMPLE_SQL_PATH = Path(__file__).parent / "audit_address_sample.sql" - - -# --------------------------------------------------------------------------- -# Domain helpers -# --------------------------------------------------------------------------- - - -@dataclass -class SampleRow: - """One house from the stratified sampling query.""" - - id: int - address: str - lat: float - lon: float - district: str | None - - -# Words that introduce a street rather than identify it. We skip these so the -# comparison lands on the actual street name ('малышева' / 'ленина'). Mirrors -# the canonical forms produced by `normalize_address` (which expands all known -# abbreviations to these full words). -_STREET_TYPE_WORDS = frozenset( - { - "улица", - "проспект", - "переулок", - "бульвар", - "проезд", - "шоссе", - "площадь", - "набережная", - "тупик", - "строение", - "корпус", - "дом", - } -) - -# Geographic prefix words that addresses sometimes carry before the street -# (e.g. 'россия екатеринбург улица малышева 51'). We skip them too so we -# converge on the same identifying token regardless of how verbose the -# source representation is. -_GEO_PREFIX_WORDS = frozenset( - { - "россия", - "свердловская", - "область", - "екатеринбург", - "город", - "г", - } -) - - -def _first_street_token(address: str | None) -> str | None: - """Extract the first street-name token of a normalized address. - - Phase-1 heuristic for "do the streets agree": skip numeric tokens (house - numbers), street-type words ('улица', 'проспект', …), and geographic - prefixes ('россия', 'екатеринбург', …) — the next token is the street - name itself, which is the identifying part we want to compare. - - Returns None for an empty address or when no candidate token remains. - """ - norm = normalize_address(address or "") - if not norm: - return None - for tok in norm.split(): - if not tok: - continue - # Skip purely numeric tokens (e.g. '5', '17а' if it starts with digit). - if tok[0].isdigit(): - continue - # Skip street type words and geographic prefixes. - if tok in _STREET_TYPE_WORDS or tok in _GEO_PREFIX_WORDS: - continue - return tok - return None - - -def _street_differs(original: str | None, snapped: str | None) -> bool | None: - """True iff first non-numeric token differs between the two addresses. - - Returns None when either side is empty — we cannot compute a meaningful - diff (caller writes NULL into the audit row). - """ - a = _first_street_token(original) - b = _first_street_token(snapped) - if a is None or b is None: - return None - return a != b - - -def _distance_meters( - db: Session, - olat: float, - olon: float, - slat: float, - slon: float, -) -> float | None: - """Compute great-circle distance via PostGIS geography type. - - We could do this in Python with a haversine formula, but the audit table - uses ST_Distance results elsewhere so we use the same authority to avoid - drift. ST_MakePoint(lon, lat) — PostGIS convention is lon first. - """ - row = db.execute( - text( - "SELECT ST_Distance(" - " ST_SetSRID(ST_MakePoint(CAST(:olon AS double precision), " - " CAST(:olat AS double precision)), 4326)::geography, " - " ST_SetSRID(ST_MakePoint(CAST(:slon AS double precision), " - " CAST(:slat AS double precision)), 4326)::geography" - ") AS m" - ), - {"olat": olat, "olon": olon, "slat": slat, "slon": slon}, - ).first() - if row is None or row[0] is None: - return None - return float(row[0]) - - -# --------------------------------------------------------------------------- -# Sampling + resumption queries -# --------------------------------------------------------------------------- - - -def _load_sample(db: Session, limit_per_district: int) -> list[SampleRow]: - """Run the stratified sampling SQL → list of SampleRow.""" - sql = _SAMPLE_SQL_PATH.read_text(encoding="utf-8") - rows = db.execute(text(sql), {"limit_per_district": limit_per_district}).mappings().all() - return [ - SampleRow( - id=r["id"], - address=r["address"], - lat=float(r["lat"]), - lon=float(r["lon"]), - district=r["district"], - ) - for r in rows - ] - - -def _already_processed_ids(db: Session, batch: str) -> set[int]: - """Return the set of house_id already in the audit table for this batch. - - Drives resumability: drop these from the sample before geocoding. - """ - rows = db.execute( - text("SELECT house_id FROM address_mismatch_audit WHERE audit_batch = CAST(:b AS text)"), - {"b": batch}, - ).all() - return {r[0] for r in rows} - - -# --------------------------------------------------------------------------- -# Insert helper -# --------------------------------------------------------------------------- - - -def _insert_audit_row( - db: Session, - *, - house_id: int, - batch: str, - district: str | None, - original_address: str | None, - original_lat: float | None, - original_lon: float | None, - snapped_address: str | None, - snapped_lat: float | None, - snapped_lon: float | None, - distance_m: float | None, - street_differs: bool | None, - audit_status: str, - error_message: str | None, - raw_payload: dict[str, Any] | None, -) -> None: - """INSERT … ON CONFLICT DO NOTHING into address_mismatch_audit. - - Wrapped in begin_nested by the caller per backend.md SAVEPOINT pattern. - """ - db.execute( - text( - "INSERT INTO address_mismatch_audit (" - " house_id, audit_batch, district," - " original_address, original_lat, original_lon," - " snapped_address, snapped_lat, snapped_lon," - " distance_m, street_differs," - " audit_status, error_message, raw_payload" - ") VALUES (" - " CAST(:house_id AS bigint), CAST(:batch AS text), :district," - " :original_address, :original_lat, :original_lon," - " :snapped_address, :snapped_lat, :snapped_lon," - " :distance_m, :street_differs," - " CAST(:audit_status AS text), :error_message," - " CAST(:raw_payload AS jsonb)" - ") ON CONFLICT (house_id, audit_batch) DO NOTHING" - ), - { - "house_id": house_id, - "batch": batch, - "district": district, - "original_address": original_address, - "original_lat": original_lat, - "original_lon": original_lon, - "snapped_address": snapped_address, - "snapped_lat": snapped_lat, - "snapped_lon": snapped_lon, - "distance_m": distance_m, - "street_differs": street_differs, - "audit_status": audit_status, - "error_message": error_message, - "raw_payload": json.dumps(raw_payload) if raw_payload is not None else None, - }, - ) - - -# --------------------------------------------------------------------------- -# Mode dispatcher -# --------------------------------------------------------------------------- - - -def _resolve_mode(mode: str, api_key: str | None) -> str: - """Translate `--mode auto` → concrete 'api' / 'playwright' choice. - - Explicit modes are passed through unchanged; auto chooses api iff a key - is configured (fail-fast: we don't want a "should have used the API but - silently fell back to slow scraping" surprise). - """ - if mode == "auto": - return "api" if api_key else "playwright" - return mode - - -# --------------------------------------------------------------------------- -# Main loop -# --------------------------------------------------------------------------- - - -async def _run_api_mode( - db: Session, - sample: list[SampleRow], - batch: str, - api_key: str, -) -> int: - """Geocode the sample using the HTTP Geocoder API.""" - processed = 0 - last_distance: float | None = None - async with httpx.AsyncClient(timeout=httpx.Timeout(10.0)) as client: - for i, row in enumerate(sample, start=1): - status = "ok" - err: str | None = None - res: YandexReverseResult | None = None - try: - res = await reverse_via_api(row.lat, row.lon, api_key, client=client) - except httpx.HTTPError as e: - status = "error" - err = f"http_error: {e!s}" - except Exception as e: # pragma: no cover — defensive - status = "error" - err = f"unhandled: {e!s}" - - distance = None - street_diff: bool | None = None - if res is not None and status == "ok": - if res.address is None: - status = "no_match" - else: - if res.snapped_lat is not None and res.snapped_lon is not None: - distance = _distance_meters( - db, row.lat, row.lon, res.snapped_lat, res.snapped_lon - ) - last_distance = distance - street_diff = _street_differs(row.address, res.address) - - try: - with db.begin_nested(): - _insert_audit_row( - db, - house_id=row.id, - batch=batch, - district=row.district, - original_address=row.address, - original_lat=row.lat, - original_lon=row.lon, - snapped_address=res.address if res else None, - snapped_lat=res.snapped_lat if res else None, - snapped_lon=res.snapped_lon if res else None, - distance_m=distance, - street_differs=street_diff, - audit_status=status, - error_message=err, - raw_payload=res.raw if res else None, - ) - # Per-row commit: each row is durable on disk before the next - # Yandex call; --batch resume picks up exactly where we crashed. - db.commit() - processed += 1 - except Exception as e: - db.rollback() - logger.warning("insert failed for house_id=%s: %s", row.id, e) - - if i % 10 == 0: - logger.info( - "progress %d/%d, mode=api, last_distance=%s", - i, - len(sample), - f"{last_distance:.1f}m" if last_distance is not None else "n/a", - ) - - return processed - - -async def _run_playwright_mode( - db: Session, - sample: list[SampleRow], - batch: str, -) -> int: - """Geocode via a persistent Playwright context (CAPTCHA-aware).""" - try: - from playwright.async_api import async_playwright # type: ignore[import-not-found] - except ImportError as e: - raise RuntimeError( - "Playwright is required for --mode playwright. " - "Install with `uv sync --group dev` and `playwright install chromium`." - ) from e - - _PLAYWRIGHT_USER_DATA.mkdir(parents=True, exist_ok=True) - processed = 0 - last_distance: float | None = None - - async with async_playwright() as p: - context = await p.chromium.launch_persistent_context( - user_data_dir=str(_PLAYWRIGHT_USER_DATA), - headless=False, - user_agent=_PLAYWRIGHT_UA, - locale="ru-RU", - timezone_id="Asia/Yekaterinburg", - ) - page = await context.new_page() - - try: - for i, row in enumerate(sample, start=1): - status = "ok" - err: str | None = None - res: YandexReverseResult | None = None - stop_batch = False - try: - res = await reverse_via_playwright(row.lat, row.lon, page) - except YandexBlockedError as e: - status = "blocked" - err = str(e) - stop_batch = True - except Exception as e: - status = "error" - err = f"playwright: {e!s}" - - distance = None - street_diff: bool | None = None - if res is not None and status == "ok": - if res.address is None: - status = "no_match" - else: - if res.snapped_lat is not None and res.snapped_lon is not None: - distance = _distance_meters( - db, row.lat, row.lon, res.snapped_lat, res.snapped_lon - ) - last_distance = distance - street_diff = _street_differs(row.address, res.address) - - try: - with db.begin_nested(): - _insert_audit_row( - db, - house_id=row.id, - batch=batch, - district=row.district, - original_address=row.address, - original_lat=row.lat, - original_lon=row.lon, - snapped_address=res.address if res else None, - snapped_lat=res.snapped_lat if res else None, - snapped_lon=res.snapped_lon if res else None, - distance_m=distance, - street_differs=street_diff, - audit_status=status, - error_message=err, - raw_payload=res.raw if res else None, - ) - db.commit() - processed += 1 - except Exception as e: - db.rollback() - logger.warning("insert failed for house_id=%s: %s", row.id, e) - - if stop_batch: - logger.error( - "Yandex CAPTCHA detected at position %d/%d (house_id=%s). " - "Stopping batch — re-run with same --batch to resume.", - i, - len(sample), - row.id, - ) - break - - if i % 10 == 0: - logger.info( - "progress %d/%d, mode=playwright, last_distance=%s", - i, - len(sample), - f"{last_distance:.1f}m" if last_distance is not None else "n/a", - ) - - # Random delay 4-7s between requests — keeps us under Yandex's - # heuristic rate limit while still finishing 200 rows in <30min. - # Skip the wait on the last iteration (no next request to space). - if i < len(sample): - await asyncio.sleep(random.uniform(4.0, 7.0)) - finally: - await context.close() - - return processed - - -# --------------------------------------------------------------------------- -# Entry point -# --------------------------------------------------------------------------- - - -def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: - """argparse setup, factored out for testability.""" - p = argparse.ArgumentParser( - description="Phase 1 audit — houses.address vs Yandex reverse geocode.", - ) - p.add_argument( - "--batch", - default=f"{date.today().isoformat()}_run1", - help="Audit batch label. Same batch re-run skips already-processed houses.", - ) - p.add_argument( - "--limit-per-district", - type=int, - default=25, - help="Houses to sample per district (default 25 → ~200 total for EKB).", - ) - p.add_argument( - "--mode", - choices=("auto", "api", "playwright"), - default="auto", - help="auto = API if YANDEX_GEOCODER_API_KEY set, else playwright.", - ) - return p.parse_args(argv) - - -async def main(argv: list[str] | None = None) -> int: - """CLI entry point. Returns the number of rows processed this run.""" - args = _parse_args(argv) - api_key = os.environ.get("YANDEX_GEOCODER_API_KEY") - mode = _resolve_mode(args.mode, api_key) - - if mode == "api" and not api_key: - raise SystemExit("mode=api requested but YANDEX_GEOCODER_API_KEY is not set") - - logger.info( - "starting audit batch=%s mode=%s limit_per_district=%d", - args.batch, - mode, - args.limit_per_district, - ) - - db = SessionLocal() - try: - sample = _load_sample(db, args.limit_per_district) - logger.info("loaded sample: %d houses", len(sample)) - - # Resume support — drop already-processed house_ids. - done = _already_processed_ids(db, args.batch) - if done: - logger.info( - "resuming batch %s: %d rows already processed, %d remaining", - args.batch, - len(done), - len(sample) - sum(1 for s in sample if s.id in done), - ) - remaining = [s for s in sample if s.id not in done] - - if not remaining: - logger.info("nothing to do — batch %s is complete", args.batch) - return 0 - - if mode == "api": - n = await _run_api_mode(db, remaining, args.batch, api_key or "") - else: - n = await _run_playwright_mode(db, remaining, args.batch) - - logger.info("done: processed=%d batch=%s mode=%s", n, args.batch, mode) - return n - finally: - db.close() - - -if __name__ == "__main__": # pragma: no cover - asyncio.run(main()) diff --git a/tradein-mvp/backend/scripts/audit_address_sample.sql b/tradein-mvp/backend/scripts/audit_address_sample.sql deleted file mode 100644 index 11e83b62..00000000 --- a/tradein-mvp/backend/scripts/audit_address_sample.sql +++ /dev/null @@ -1,47 +0,0 @@ --- audit_address_sample.sql --- Random sample of EKB houses for the address-mismatch audit (issue #582). --- --- Strategy: --- 1. Filter to houses with non-null lat/lon and non-empty address. --- 2. Random shuffle via `ORDER BY random()` — repeatable enough for spot --- sampling without needing a stable PRNG seed (the audit table dedupes --- via UNIQUE (house_id, audit_batch), so re-running gives idempotent --- results regardless of which rows land in the sample first). --- 3. Cap the result at :limit_per_district * 8 rows — keeps the bind-param --- contract compatible with the old stratified sampler (`:limit_per_district` --- is still honored, just multiplied by the assumed 8-district count). --- --- Why no spatial stratification anymore: --- The previous version JOINed to `gendesign_ekb_districts_geom` (FDW --- polygon table) to bucket houses by admin district. That join is fine on --- prod where FDW is wired, but it adds a dependency we don't need for --- Phase 2-3 (backfill + canonical reverse). Aggregation by district at --- report time still works — we re-derive district during the audit via --- spatial containment in the report SQL when needed. --- --- Bind param: --- :limit_per_district — kept for back-compat with the audit driver. --- Effective sample size = :limit_per_district * 8 (e.g. 25 → 200). --- --- Columns returned: --- id, address, lat, lon, district --- `district` is always NULL here — the audit driver will reverse-derive it --- from Yandex Geocoder response (Yandex returns admin component) or leave --- it NULL if not present in the response. --- --- NB: uses CAST(:x AS int) per project sql.md rule (psycopg v3 ignores ::type --- after bind params). - -SELECT - h.id, - h.address, - h.lat, - h.lon, - NULL::text AS district -FROM houses h -WHERE h.lat IS NOT NULL - AND h.lon IS NOT NULL - AND h.address IS NOT NULL - AND length(trim(h.address)) > 0 -ORDER BY random() -LIMIT CAST(:limit_per_district AS int) * 8; diff --git a/tradein-mvp/backend/scripts/backfill_house_coords.py b/tradein-mvp/backend/scripts/backfill_house_coords.py deleted file mode 100644 index 7a607730..00000000 --- a/tradein-mvp/backend/scripts/backfill_house_coords.py +++ /dev/null @@ -1,619 +0,0 @@ -"""Forward-geocode houses through Yandex Geocoder API to backfill lat/lon -and canonical address, plus optional reverse audit of already-geocoded houses. - -Phase 2-3 of Forgejo issue #582. Two modes (mutually exclusive): - - 1. Backfill (default) — for the ~4141 rows WHERE lat IS NULL OR lon IS NULL: - forward-geocode `houses.address` → snap to a Yandex `house`-precision - point, UPDATE houses with the new lat/lon + canonical address payload, - and write an `address_mismatch_audit` row with `audit_status='backfill'`. - - 2. Audit-only (--audit-only) — for the ~4452 rows that already have coords: - reverse-geocode (lat, lon) → snapped point + canonical address, compute - ST_Distance vs stored coords, write an `address_mismatch_audit` row with - status 'ok' (≤50m) or 'mismatch' (>50m). Does NOT touch houses. - -Design choices: -- **Per-row SAVEPOINT** (`db.begin_nested()`): a single Yandex/PostGIS error - must not nuke the entire batch. Per backend.md, never use bare rollback - inside a loop. -- **Resumable** via UNIQUE (house_id, audit_batch). Re-running the same - --batch label skips already-processed houses, so a partial run can be - picked up after CAPTCHA / network blip / 25k/day quota hit. -- **Precision filter**: backfill skips matches with precision in - ('street', 'other', 'range', 'near', None) — those are too imprecise for - comparable-listing spatial queries and would silently degrade matching - recall. The audit row still records what Yandex returned for forensics. -- **Rate limit**: 50ms between calls (~20 req/sec, well under Yandex's - 25 req/sec service limit). Backfill mode runs single-threaded. -- **Daily quota**: 4141 backfill + 4452 audit ≈ 8.6k requests. Free Geocoder - tier is 25k/day → comfortable buffer for retries. - -Usage: - YANDEX_GEOCODER_API_KEY=xxx \\ - DATABASE_URL=postgresql+psycopg://... \\ - python -m scripts.backfill_house_coords --batch 2026-05-27_backfill - - # Audit-only on the 4452 already-geocoded houses - python -m scripts.backfill_house_coords --batch 2026-05-27_audit \\ - --audit-only --limit 500 - -Outputs: -- Backfill mode: UPDATE rows in `houses`, INSERT rows in - `address_mismatch_audit` with status 'backfill' / 'no_match' / 'imprecise'. -- Audit mode: INSERT rows in `address_mismatch_audit` with status 'ok' / - 'mismatch' / 'no_match' / 'error'. -- Per-batch progress is logged every 25 rows. -""" - -from __future__ import annotations - -import argparse -import asyncio -import json -import logging -import os -from dataclasses import dataclass -from datetime import date -from pathlib import Path -from typing import Any - -import httpx -from sqlalchemy import text -from sqlalchemy.orm import Session - -# Allow running both as `python -m scripts.backfill_house_coords` (preferred) -# and as a stand-alone file. Mirrors the audit_address_mismatch import dance. -try: - from app.core.db import SessionLocal # type: ignore[import-not-found] -except ImportError: # pragma: no cover — fallback for adhoc invocation - import sys - - sys.path.insert(0, str(Path(__file__).resolve().parents[1])) - from app.core.db import SessionLocal - -try: - from scripts._yandex_reverse import ( # type: ignore[import-not-found] - YandexReverseResult, - forward_via_api, - reverse_via_api, - ) -except ImportError: - from _yandex_reverse import ( # type: ignore[no-redef] - YandexReverseResult, - forward_via_api, - reverse_via_api, - ) - -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s %(levelname)s %(name)s %(message)s", -) -logger = logging.getLogger("backfill_house_coords") - -# Yandex Geocoder service limits per docs (as of 2026-05): -# - 25k requests/day free tier -# - 25 requests/sec sustained -# 50ms between calls = ~20 req/sec, leaving headroom for connection ramp-up. -_REQUEST_DELAY_S = 0.05 - -# Precision values we ACCEPT for backfill — anything else means Yandex didn't -# resolve to a specific building, and writing the result back into houses -# would degrade matching recall. -# `exact` → match found at the exact address (best case) -# `number` → house number matched, but unit/entrance unspecified (acceptable) -# `near` / `range` / `street` / `other` / None → skipped (logged for analysis). -_BACKFILL_OK_PRECISION = frozenset({"exact", "number"}) - -# Audit threshold per issue #582 — distances above this flag a "mismatch" -# (the row still goes in the audit table, just with status='mismatch' for -# the report SQL to bucket separately). -_MISMATCH_DISTANCE_M = 50.0 - - -# --------------------------------------------------------------------------- -# Domain types -# --------------------------------------------------------------------------- - - -@dataclass -class HouseRow: - """One house from the source query — minimal fields needed for geocode.""" - - id: int - address: str - lat: float | None - lon: float | None - - -# --------------------------------------------------------------------------- -# Source-row queries -# --------------------------------------------------------------------------- - - -def _select_houses_without_coords(db: Session, limit: int | None) -> list[HouseRow]: - """Pull houses needing forward geocode (lat IS NULL OR lon IS NULL). - - Skips rows with empty address — there's nothing to geocode there, they - need a separate cleanup pass. - """ - sql = ( - "SELECT id, address, lat, lon " - "FROM houses " - "WHERE (lat IS NULL OR lon IS NULL) " - " AND address IS NOT NULL " - " AND length(trim(address)) > 0 " - "ORDER BY id" - ) - if limit is not None: - sql += " LIMIT CAST(:limit AS int)" - rows = db.execute(text(sql), {"limit": limit}).mappings().all() - else: - rows = db.execute(text(sql)).mappings().all() - return [ - HouseRow(id=r["id"], address=r["address"], lat=r["lat"], lon=r["lon"]) for r in rows - ] - - -def _select_houses_with_coords(db: Session, limit: int | None) -> list[HouseRow]: - """Pull houses needing reverse audit (both lat AND lon present).""" - sql = ( - "SELECT id, address, lat, lon " - "FROM houses " - "WHERE lat IS NOT NULL " - " AND lon IS NOT NULL " - " AND address IS NOT NULL " - " AND length(trim(address)) > 0 " - "ORDER BY id" - ) - if limit is not None: - sql += " LIMIT CAST(:limit AS int)" - rows = db.execute(text(sql), {"limit": limit}).mappings().all() - else: - rows = db.execute(text(sql)).mappings().all() - return [ - HouseRow(id=r["id"], address=r["address"], lat=r["lat"], lon=r["lon"]) for r in rows - ] - - -def _already_processed_ids(db: Session, batch: str) -> set[int]: - """house_ids already in address_mismatch_audit for this batch → skip set.""" - rows = db.execute( - text("SELECT house_id FROM address_mismatch_audit WHERE audit_batch = CAST(:b AS text)"), - {"b": batch}, - ).all() - return {r[0] for r in rows} - - -# --------------------------------------------------------------------------- -# Distance helper — PostGIS, lon/lat order -# --------------------------------------------------------------------------- - - -def _distance_meters( - db: Session, olat: float, olon: float, slat: float, slon: float -) -> float | None: - """Great-circle distance (meters) via PostGIS geography type. - - Lifted from `audit_address_mismatch.py` to keep the two scripts using - the same authority for distance computation. ST_MakePoint takes lon - first per PostGIS convention. - """ - row = db.execute( - text( - "SELECT ST_Distance(" - " ST_SetSRID(ST_MakePoint(CAST(:olon AS double precision), " - " CAST(:olat AS double precision)), 4326)::geography, " - " ST_SetSRID(ST_MakePoint(CAST(:slon AS double precision), " - " CAST(:slat AS double precision)), 4326)::geography" - ") AS m" - ), - {"olat": olat, "olon": olon, "slat": slat, "slon": slon}, - ).first() - if row is None or row[0] is None: - return None - return float(row[0]) - - -# --------------------------------------------------------------------------- -# DB writers -# --------------------------------------------------------------------------- - - -def _update_house_coords( - db: Session, - *, - house_id: int, - lat: float, - lon: float, - payload: dict[str, Any], -) -> None: - """UPDATE houses SET lat/lon + merge yandex_geocode into raw_payload. - - The `houses_set_geom_trg` BEFORE UPDATE trigger (009_houses.sql) maintains - `geom` automatically when lat/lon change, so we don't need to set geom - explicitly here. `raw_payload || jsonb_build_object(...)` is the idiomatic - psycopg-safe way to merge — single ALTER, no read-modify-write race. - """ - db.execute( - text( - "UPDATE houses " - " SET lat = CAST(:lat AS double precision), " - " lon = CAST(:lon AS double precision), " - " raw_payload = COALESCE(raw_payload, '{}'::jsonb) " - " || jsonb_build_object('yandex_geocode', " - " CAST(:payload AS jsonb)) " - " WHERE id = CAST(:id AS bigint)" - ), - {"id": house_id, "lat": lat, "lon": lon, "payload": json.dumps(payload)}, - ) - - -def _insert_audit_row( - db: Session, - *, - house_id: int, - batch: str, - original_address: str | None, - original_lat: float | None, - original_lon: float | None, - snapped_address: str | None, - snapped_lat: float | None, - snapped_lon: float | None, - distance_m: float | None, - audit_status: str, - error_message: str | None, - raw_payload: dict[str, Any] | None, -) -> None: - """INSERT … ON CONFLICT DO NOTHING into address_mismatch_audit. - - Same column shape as `audit_address_mismatch._insert_audit_row` but the - `district` and `street_differs` fields are left NULL — backfill/audit - here doesn't have a stratification basis and we let the report SQL - derive district at query time if needed (via Yandex address parse). - - Caller wraps in `begin_nested()` per backend.md SAVEPOINT pattern. - """ - db.execute( - text( - "INSERT INTO address_mismatch_audit (" - " house_id, audit_batch, district," - " original_address, original_lat, original_lon," - " snapped_address, snapped_lat, snapped_lon," - " distance_m, street_differs," - " audit_status, error_message, raw_payload" - ") VALUES (" - " CAST(:house_id AS bigint), CAST(:batch AS text), NULL," - " :original_address, :original_lat, :original_lon," - " :snapped_address, :snapped_lat, :snapped_lon," - " :distance_m, NULL," - " CAST(:audit_status AS text), :error_message," - " CAST(:raw_payload AS jsonb)" - ") ON CONFLICT (house_id, audit_batch) DO NOTHING" - ), - { - "house_id": house_id, - "batch": batch, - "original_address": original_address, - "original_lat": original_lat, - "original_lon": original_lon, - "snapped_address": snapped_address, - "snapped_lat": snapped_lat, - "snapped_lon": snapped_lon, - "distance_m": distance_m, - "audit_status": audit_status, - "error_message": error_message, - "raw_payload": json.dumps(raw_payload) if raw_payload is not None else None, - }, - ) - - -# --------------------------------------------------------------------------- -# Backfill loop (forward geocode, lat IS NULL houses) -# --------------------------------------------------------------------------- - - -def _classify_backfill_status(res: YandexReverseResult | None) -> str: - """Translate a forward-geocode result into an audit_status value. - - 'backfill' — Yandex returned a precise hit, lat/lon will be written. - 'imprecise' — match returned but precision is too low (street/other/...). - 'no_match' — Yandex returned an empty featureMember. - 'error' — handled by the caller's exception branch. - """ - if res is None or res.address is None: - return "no_match" - if res.precision not in _BACKFILL_OK_PRECISION: - return "imprecise" - if res.snapped_lat is None or res.snapped_lon is None: - return "no_match" - return "backfill" - - -async def _run_backfill_mode( - db: Session, sample: list[HouseRow], batch: str, api_key: str -) -> int: - """Forward-geocode each house, UPDATE coords on precise hits, audit-log all.""" - processed = 0 - updated = 0 - n_imprecise = 0 - n_no_match = 0 - n_error = 0 - - async with httpx.AsyncClient(timeout=httpx.Timeout(10.0)) as client: - for i, row in enumerate(sample, start=1): - status = "backfill" - err: str | None = None - res: YandexReverseResult | None = None - try: - res = await forward_via_api(row.address, api_key, client=client) - except httpx.HTTPError as e: - status = "error" - err = f"http_error: {e!s}" - n_error += 1 - except Exception as e: # pragma: no cover — defensive - status = "error" - err = f"unhandled: {e!s}" - n_error += 1 - - if status != "error": - status = _classify_backfill_status(res) - if status == "imprecise": - n_imprecise += 1 - elif status == "no_match": - n_no_match += 1 - - try: - with db.begin_nested(): - if status == "backfill" and res is not None and res.snapped_lat is not None: - # safe: status='backfill' guarantees snapped_lat/lon non-None. - assert res.snapped_lon is not None - _update_house_coords( - db, - house_id=row.id, - lat=res.snapped_lat, - lon=res.snapped_lon, - payload={ - "address": res.address, - "precision": res.precision, - "kind": res.kind, - "batch": batch, - "source": "yandex_geocoder_api", - }, - ) - updated += 1 - _insert_audit_row( - db, - house_id=row.id, - batch=batch, - original_address=row.address, - original_lat=row.lat, - original_lon=row.lon, - snapped_address=res.address if res else None, - snapped_lat=res.snapped_lat if res else None, - snapped_lon=res.snapped_lon if res else None, - distance_m=None, - audit_status=status, - error_message=err, - raw_payload=res.raw if res else None, - ) - # Per-row commit so resume picks up exactly where we crashed. - db.commit() - processed += 1 - except Exception as e: - db.rollback() - logger.warning("backfill insert failed for house_id=%s: %s", row.id, e) - - if i % 25 == 0: - logger.info( - "backfill progress %d/%d updated=%d imprecise=%d no_match=%d error=%d", - i, - len(sample), - updated, - n_imprecise, - n_no_match, - n_error, - ) - - # Yandex 25 req/sec → 50ms between calls is plenty of headroom. - if i < len(sample): - await asyncio.sleep(_REQUEST_DELAY_S) - - logger.info( - "backfill done: processed=%d updated=%d imprecise=%d no_match=%d error=%d", - processed, - updated, - n_imprecise, - n_no_match, - n_error, - ) - return processed - - -# --------------------------------------------------------------------------- -# Audit-only loop (reverse geocode, lat IS NOT NULL houses) -# --------------------------------------------------------------------------- - - -async def _run_audit_mode( - db: Session, sample: list[HouseRow], batch: str, api_key: str -) -> int: - """Reverse-geocode each house, compute distance, audit-log status/mismatch.""" - processed = 0 - n_ok = 0 - n_mismatch = 0 - n_no_match = 0 - n_error = 0 - - async with httpx.AsyncClient(timeout=httpx.Timeout(10.0)) as client: - for i, row in enumerate(sample, start=1): - # Type-narrow: audit mode only feeds rows with non-null coords. - assert row.lat is not None and row.lon is not None - status = "ok" - err: str | None = None - res: YandexReverseResult | None = None - try: - res = await reverse_via_api(row.lat, row.lon, api_key, client=client) - except httpx.HTTPError as e: - status = "error" - err = f"http_error: {e!s}" - n_error += 1 - except Exception as e: # pragma: no cover — defensive - status = "error" - err = f"unhandled: {e!s}" - n_error += 1 - - distance = None - if res is not None and status == "ok": - if res.address is None: - status = "no_match" - n_no_match += 1 - else: - if res.snapped_lat is not None and res.snapped_lon is not None: - distance = _distance_meters( - db, row.lat, row.lon, res.snapped_lat, res.snapped_lon - ) - if distance is not None and distance > _MISMATCH_DISTANCE_M: - status = "mismatch" - n_mismatch += 1 - else: - n_ok += 1 - else: - n_ok += 1 - - try: - with db.begin_nested(): - _insert_audit_row( - db, - house_id=row.id, - batch=batch, - original_address=row.address, - original_lat=row.lat, - original_lon=row.lon, - snapped_address=res.address if res else None, - snapped_lat=res.snapped_lat if res else None, - snapped_lon=res.snapped_lon if res else None, - distance_m=distance, - audit_status=status, - error_message=err, - raw_payload=res.raw if res else None, - ) - db.commit() - processed += 1 - except Exception as e: - db.rollback() - logger.warning("audit insert failed for house_id=%s: %s", row.id, e) - - if i % 25 == 0: - logger.info( - "audit progress %d/%d ok=%d mismatch=%d no_match=%d error=%d", - i, - len(sample), - n_ok, - n_mismatch, - n_no_match, - n_error, - ) - - if i < len(sample): - await asyncio.sleep(_REQUEST_DELAY_S) - - logger.info( - "audit done: processed=%d ok=%d mismatch=%d no_match=%d error=%d", - processed, - n_ok, - n_mismatch, - n_no_match, - n_error, - ) - return processed - - -# --------------------------------------------------------------------------- -# CLI -# --------------------------------------------------------------------------- - - -def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: - """argparse setup, factored out for testability.""" - p = argparse.ArgumentParser( - description=( - "Phase 2-3 of issue #582 — backfill houses.lat/lon via Yandex forward " - "geocode, or audit already-geocoded houses via reverse geocode." - ), - ) - p.add_argument( - "--batch", - default=f"{date.today().isoformat()}_backfill", - help="Audit batch label. Same batch re-run skips already-processed houses.", - ) - p.add_argument( - "--audit-only", - action="store_true", - help=( - "Run reverse-geocode audit on houses WITH coords instead of forward " - "backfill on houses WITHOUT coords. Does not modify the houses table." - ), - ) - p.add_argument( - "--limit", - type=int, - default=None, - help=( - "Optional cap on source-row count. Useful for canary runs " - "(e.g. --limit 100 before letting the full 4k loose)." - ), - ) - return p.parse_args(argv) - - -async def main(argv: list[str] | None = None) -> int: - """CLI entry point. Returns the number of rows processed this run.""" - args = _parse_args(argv) - api_key = os.environ.get("YANDEX_GEOCODER_API_KEY") - if not api_key: - raise SystemExit( - "YANDEX_GEOCODER_API_KEY is required — forward geocode is API-only." - ) - - mode = "audit" if args.audit_only else "backfill" - logger.info( - "starting batch=%s mode=%s limit=%s", - args.batch, - mode, - args.limit if args.limit is not None else "all", - ) - - db = SessionLocal() - try: - if args.audit_only: - sample = _select_houses_with_coords(db, args.limit) - else: - sample = _select_houses_without_coords(db, args.limit) - logger.info("loaded source rows: %d", len(sample)) - - done = _already_processed_ids(db, args.batch) - if done: - logger.info( - "resuming batch %s: %d rows already processed", - args.batch, - len(done), - ) - remaining = [s for s in sample if s.id not in done] - if not remaining: - logger.info("nothing to do — batch %s is complete for the loaded sample", args.batch) - return 0 - - if args.audit_only: - n = await _run_audit_mode(db, remaining, args.batch, api_key) - else: - n = await _run_backfill_mode(db, remaining, args.batch, api_key) - - logger.info("done: processed=%d batch=%s mode=%s", n, args.batch, mode) - return n - finally: - db.close() - - -if __name__ == "__main__": # pragma: no cover - asyncio.run(main()) diff --git a/tradein-mvp/backend/tests/fixtures/yandex_geocode_sample.json b/tradein-mvp/backend/tests/fixtures/yandex_geocode_sample.json deleted file mode 100644 index 9a3660d0..00000000 --- a/tradein-mvp/backend/tests/fixtures/yandex_geocode_sample.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "response": { - "GeoObjectCollection": { - "metaDataProperty": { - "GeocoderResponseMetaData": { - "request": "60.586,56.838", - "results": "1", - "found": "1" - } - }, - "featureMember": [ - { - "GeoObject": { - "metaDataProperty": { - "GeocoderMetaData": { - "precision": "exact", - "text": "Россия, Свердловская область, Екатеринбург, улица Малышева, 51", - "kind": "house", - "Address": { - "country_code": "RU", - "formatted": "Россия, Свердловская область, Екатеринбург, улица Малышева, 51", - "postal_code": "620075", - "Components": [ - {"kind": "country", "name": "Россия"}, - {"kind": "province", "name": "Уральский федеральный округ"}, - {"kind": "province", "name": "Свердловская область"}, - {"kind": "area", "name": "городской округ Екатеринбург"}, - {"kind": "locality", "name": "Екатеринбург"}, - {"kind": "street", "name": "улица Малышева"}, - {"kind": "house", "name": "51"} - ] - }, - "AddressDetails": { - "Country": { - "AddressLine": "Россия, Свердловская область, Екатеринбург, улица Малышева, 51", - "CountryNameCode": "RU", - "CountryName": "Россия", - "AdministrativeArea": { - "AdministrativeAreaName": "Свердловская область", - "SubAdministrativeArea": { - "SubAdministrativeAreaName": "городской округ Екатеринбург", - "Locality": { - "LocalityName": "Екатеринбург", - "Thoroughfare": { - "ThoroughfareName": "улица Малышева", - "Premise": { - "PremiseNumber": "51", - "PostalCode": {"PostalCodeNumber": "620075"} - } - } - } - } - } - } - } - } - }, - "name": "улица Малышева, 51", - "description": "Екатеринбург, Россия", - "boundedBy": { - "Envelope": { - "lowerCorner": "60.585217 56.837461", - "upperCorner": "60.587094 56.838547" - } - }, - "Point": { - "pos": "60.586155 56.838004" - } - } - } - ] - } - } -} diff --git a/tradein-mvp/backend/tests/test_audit_address_mismatch.py b/tradein-mvp/backend/tests/test_audit_address_mismatch.py deleted file mode 100644 index 1c64288b..00000000 --- a/tradein-mvp/backend/tests/test_audit_address_mismatch.py +++ /dev/null @@ -1,373 +0,0 @@ -"""Unit tests for the Phase-1 address-mismatch audit (issue #582). - -Coverage: - - `_first_street_token` / `_street_differs` — normalization-driven diff. - - `_distance_meters` — verified against a MagicMock'd DB that returns a - canned distance, plus a Haversine cross-check on the bind values to - catch lat/lon swaps. - - `reverse_via_api` via httpx MockTransport with the fixture file. - - `main()` resumability — call twice with the same batch, second call - inserts 0 (uses MagicMock DB session). - -Why no real Postgres in unit tests: -The repo doesn't bundle pytest-postgresql / testcontainers and the existing -tests all use `MagicMock` for the DB. We follow that convention here. The -distance and SQL-level resumability are validated by: - - The Haversine cross-check (pure-Python expected ≈ PostGIS result for - same coords, see `test_distance_calc_matches_haversine`). - - Calling `main()` twice in `test_audit_script_resumable` — first run - inserts N rows, second run sees the same set of house_ids in the - "already processed" query and processes 0. -""" - -from __future__ import annotations - -import json -import math -import os -from pathlib import Path -from unittest.mock import AsyncMock, MagicMock, patch - -# Settings requires DATABASE_URL at init time — set dummy DSN before any -# `app.*` import (same pattern as test_cian_valuation.py). -os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") - -import httpx -import pytest - -from scripts._yandex_reverse import ( - YandexBlockedError, - YandexReverseResult, - _parse_api_payload, - reverse_via_api, -) -from scripts.audit_address_mismatch import ( - SampleRow, - _distance_meters, - _first_street_token, - _resolve_mode, - _run_api_mode, - _street_differs, - main, -) - -_FIXTURES = Path(__file__).parent / "fixtures" - - -# --------------------------------------------------------------------------- -# _first_street_token / _street_differs -# --------------------------------------------------------------------------- - - -def test_normalize_address_street_token_basic(): - """First identifying token of a normalized address — skips street type.""" - assert _first_street_token("ул Малышева 51") == "малышева" - - -def test_normalize_address_street_token_skips_leading_numbers(): - """Numeric tokens are skipped — the street name carries identity.""" - # No type prefix → first non-numeric token is the street name itself. - assert _first_street_token("123 Постовского") == "постовского" - - -def test_normalize_address_street_token_handles_none(): - assert _first_street_token(None) is None - assert _first_street_token("") is None - - -def test_street_differs_true_when_streets_differ(): - assert _street_differs("ул Малышева 51", "ул Ленина 51") is True - - -def test_street_differs_false_when_same_after_normalization(): - # 'ул' expands to 'улица' on both sides → same first token. - assert _street_differs("ул Малышева 51", "улица Малышева, 51") is False - - -def test_street_differs_none_on_empty_side(): - assert _street_differs(None, "ул Малышева 51") is None - assert _street_differs("ул Малышева 51", "") is None - - -# --------------------------------------------------------------------------- -# _distance_meters — MagicMock DB + Haversine cross-check -# --------------------------------------------------------------------------- - - -def _haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float: - """Reference implementation for sanity-checking the PostGIS call.""" - r = 6_371_000.0 - p1 = math.radians(lat1) - p2 = math.radians(lat2) - dp = math.radians(lat2 - lat1) - dl = math.radians(lon2 - lon1) - a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2 - return 2 * r * math.asin(math.sqrt(a)) - - -def test_distance_calc_passes_correct_bindings(): - """Test the helper passes lat/lon in correct order to the SQL bind names.""" - db = MagicMock() - # PostGIS would return one row, single column (distance in meters). - db.execute.return_value.first.return_value = (123.45,) - - out = _distance_meters(db, 56.838, 60.586, 56.840, 60.590) - - assert out == 123.45 - # Verify the bind dict — guard against lat/lon swap regressions. - args, _kwargs = db.execute.call_args - bound = args[1] - assert bound == { - "olat": 56.838, - "olon": 60.586, - "slat": 56.840, - "slon": 60.590, - } - - -def test_distance_calc_returns_none_when_postgis_null(): - """ST_Distance can return NULL — caller must propagate None, not 0.""" - db = MagicMock() - db.execute.return_value.first.return_value = (None,) - assert _distance_meters(db, 56.0, 60.0, 56.0, 60.0) is None - - -def test_distance_calc_matches_haversine_within_tolerance(): - """Sanity check: if PostGIS returned 555.7m for a known pair, that's - within ~1% of the Haversine reference (PostGIS uses Vincenty on - geography which is slightly more accurate).""" - expected = _haversine_m(56.838, 60.586, 56.843, 60.591) - # Just assert reference is in a sensible range — proves the test helper - # works; the actual call is mocked. - assert 500 < expected < 700 - - -# --------------------------------------------------------------------------- -# Yandex API: payload parsing + reverse_via_api with MockTransport -# --------------------------------------------------------------------------- - - -def test_yandex_parse_api_fixture(): - """Sanity check: parse the bundled fixture into a YandexReverseResult.""" - data = json.loads((_FIXTURES / "yandex_geocode_sample.json").read_text("utf-8")) - res = _parse_api_payload(data) - assert res.address is not None - assert "Малышева" in res.address - # Fixture Point.pos = "60.586155 56.838004" → lon then lat. - assert res.snapped_lon == pytest.approx(60.586155, abs=1e-6) - assert res.snapped_lat == pytest.approx(56.838004, abs=1e-6) - assert res.raw == data - - -def test_yandex_parse_api_no_match(): - """Empty featureMember → all-None result, raw still preserved.""" - data = {"response": {"GeoObjectCollection": {"featureMember": []}}} - res = _parse_api_payload(data) - assert res.address is None - assert res.snapped_lat is None - assert res.snapped_lon is None - assert res.raw == data - - -async def test_yandex_reverse_api_mock(): - """End-to-end: reverse_via_api hits a MockTransport, returns parsed result.""" - fixture = json.loads((_FIXTURES / "yandex_geocode_sample.json").read_text("utf-8")) - - captured: dict[str, httpx.Request] = {} - - def handler(request: httpx.Request) -> httpx.Response: - captured["req"] = request - return httpx.Response(200, json=fixture) - - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient(transport=transport) as client: - res = await reverse_via_api(56.838004, 60.586155, "DUMMY_KEY", client=client) - - assert res.address is not None and "Малышева" in res.address - # Verify the request shape — lon,lat order + apikey + kind=house. - req = captured["req"] - qs = dict(httpx.QueryParams(req.url.query)) - assert qs["apikey"] == "DUMMY_KEY" - assert qs["geocode"] == "60.586155,56.838004" - assert qs["format"] == "json" - assert qs["kind"] == "house" - - -async def test_yandex_blocked_error_raised_on_captcha(): - """`reverse_via_playwright` must raise YandexBlockedError on captcha. - - We mock the page object so we don't need an actual browser. - """ - from scripts._yandex_reverse import reverse_via_playwright - - page = MagicMock() - page.goto = AsyncMock() - page.wait_for_load_state = AsyncMock() - page.query_selector = AsyncMock( - side_effect=lambda sel: MagicMock() if sel == ".CheckboxCaptcha" else None - ) - page.evaluate = AsyncMock(return_value=[None, None]) - - with pytest.raises(YandexBlockedError): - await reverse_via_playwright(56.838, 60.586, page) - - -# --------------------------------------------------------------------------- -# Mode resolver -# --------------------------------------------------------------------------- - - -def test_resolve_mode_auto_with_key(): - assert _resolve_mode("auto", "abc") == "api" - - -def test_resolve_mode_auto_without_key(): - assert _resolve_mode("auto", None) == "playwright" - assert _resolve_mode("auto", "") == "playwright" - - -def test_resolve_mode_explicit_passes_through(): - assert _resolve_mode("api", None) == "api" - assert _resolve_mode("playwright", "abc") == "playwright" - - -# --------------------------------------------------------------------------- -# Resumability — main() twice with same batch -# --------------------------------------------------------------------------- - - -def _make_db_mock(initial_sample: list[dict], processed_ids: set[int]): - """Build a MagicMock SQLAlchemy session that: - - returns `initial_sample` for the sampling SQL (text() with limit_per_district) - - returns `processed_ids` for the resume SQL (text() with batch only) - - records INSERTs so the test can count them - """ - inserted: list[dict] = [] - - db = MagicMock() - db.begin_nested.return_value.__enter__ = lambda self: self - db.begin_nested.return_value.__exit__ = lambda self, *a: False - - def execute_side_effect(sql, params=None): - sql_str = str(sql) - result = MagicMock() - if "FROM houses h" in sql_str or "houses_in_districts" in sql_str: - result.mappings.return_value.all.return_value = initial_sample - elif "FROM address_mismatch_audit" in sql_str and "house_id" in sql_str: - # Resume query — returns list of (house_id,) tuples. - result.all.return_value = [(hid,) for hid in processed_ids] - elif "INSERT INTO address_mismatch_audit" in sql_str: - inserted.append(dict(params)) - # Simulate ON CONFLICT DO NOTHING — track id locally for re-run. - processed_ids.add(params["house_id"]) - result = MagicMock() - elif "ST_Distance" in sql_str: - result.first.return_value = (42.0,) - else: - result = MagicMock() - return result - - db.execute.side_effect = execute_side_effect - db.commit = MagicMock() - db.rollback = MagicMock() - db.close = MagicMock() - return db, inserted - - -async def test_audit_script_resumable(monkeypatch): - """Run main() twice with the same batch — second pass inserts 0.""" - sample = [ - { - "id": 1, - "address": "ул Малышева 51", - "lat": 56.838, - "lon": 60.586, - "district": "Кировский", - }, - {"id": 2, "address": "ул Ленина 5", "lat": 56.840, "lon": 60.600, "district": "Ленинский"}, - ] - processed_ids: set[int] = set() - db, inserted = _make_db_mock(sample, processed_ids) - - # Force API mode without needing a real key. - monkeypatch.setenv("YANDEX_GEOCODER_API_KEY", "TEST_KEY") - - fake_result = YandexReverseResult( - address="Россия, Екатеринбург, улица Малышева, 51", - snapped_lat=56.838004, - snapped_lon=60.586155, - raw={"ok": True}, - ) - - with ( - patch("scripts.audit_address_mismatch.SessionLocal", return_value=db), - patch( - "scripts.audit_address_mismatch.reverse_via_api", - new=AsyncMock(return_value=fake_result), - ), - ): - # First run — both rows processed. - n1 = await main(["--batch", "test_batch_1", "--mode", "api"]) - assert n1 == 2 - assert len(inserted) == 2 - - # Second run with same batch — nothing left to do. - inserted.clear() - n2 = await main(["--batch", "test_batch_1", "--mode", "api"]) - assert n2 == 0 - assert inserted == [] - - -async def test_audit_script_api_mode_marks_error(monkeypatch): - """When the reverse call raises, the row is still inserted with status=error.""" - sample = [ - { - "id": 99, - "address": "ул Малышева 51", - "lat": 56.838, - "lon": 60.586, - "district": "Кировский", - }, - ] - processed_ids: set[int] = set() - db, inserted = _make_db_mock(sample, processed_ids) - - monkeypatch.setenv("YANDEX_GEOCODER_API_KEY", "TEST_KEY") - - with ( - patch("scripts.audit_address_mismatch.SessionLocal", return_value=db), - patch( - "scripts.audit_address_mismatch.reverse_via_api", - new=AsyncMock(side_effect=httpx.HTTPError("boom")), - ), - ): - n = await main(["--batch", "err_batch", "--mode", "api"]) - assert n == 1 - - assert len(inserted) == 1 - assert inserted[0]["audit_status"] == "error" - assert "boom" in (inserted[0]["error_message"] or "") - - -# --------------------------------------------------------------------------- -# Internal _run_api_mode no-match path -# --------------------------------------------------------------------------- - - -async def test_api_mode_no_match_path(): - """If Yandex returns address=None, row goes in with status=no_match.""" - sample = [SampleRow(id=7, address="ул X 1", lat=56.0, lon=60.0, district="Кировский")] - processed_ids: set[int] = set() - db, inserted = _make_db_mock([], processed_ids) - - res = YandexReverseResult(address=None, snapped_lat=None, snapped_lon=None, raw={"empty": True}) - - with patch( - "scripts.audit_address_mismatch.reverse_via_api", - new=AsyncMock(return_value=res), - ): - n = await _run_api_mode(db, sample, "b1", "key") - - assert n == 1 - assert inserted[0]["audit_status"] == "no_match" - assert inserted[0]["snapped_address"] is None diff --git a/tradein-mvp/backend/tests/test_backfill_house_coords.py b/tradein-mvp/backend/tests/test_backfill_house_coords.py deleted file mode 100644 index 2b8da977..00000000 --- a/tradein-mvp/backend/tests/test_backfill_house_coords.py +++ /dev/null @@ -1,510 +0,0 @@ -"""Unit tests for the Phase 2-3 backfill/audit script (issue #582). - -Coverage: - - `forward_via_api` request shape — verifies geocode/format/locality bias. - - `_parse_api_payload` precision + kind extraction. - - `_classify_backfill_status` precision filter rules. - - `_update_house_coords` — UPDATE shape + raw_payload merge. - - `_run_backfill_mode` — happy path UPDATE + audit row, plus imprecise-skip. - - `_run_audit_mode` — ok / mismatch / no_match distinction. - - `main()` resumability — second pass on same batch inserts 0. - -No real Postgres in unit tests (same convention as test_audit_address_mismatch). -DB is a MagicMock that records INSERT/UPDATE calls and routes SELECT side-effects. -""" - -from __future__ import annotations - -import json -import os -from unittest.mock import AsyncMock, MagicMock, patch - -# Same dance as test_audit_address_mismatch — settings needs a DSN at import. -os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") - -import httpx -import pytest - -from scripts._yandex_reverse import ( - YandexReverseResult, - _parse_api_payload, - forward_via_api, -) -from scripts.backfill_house_coords import ( - HouseRow, - _classify_backfill_status, - _run_audit_mode, - _run_backfill_mode, - _update_house_coords, - main, -) - -# --------------------------------------------------------------------------- -# forward_via_api — request shape -# --------------------------------------------------------------------------- - - -async def test_forward_api_request_shape(): - """Verify the GET param dict — address as `geocode`, kind=house, EKB bias.""" - fixture = { - "response": { - "GeoObjectCollection": { - "featureMember": [ - { - "GeoObject": { - "metaDataProperty": { - "GeocoderMetaData": { - "text": "Россия, Свердловская область, Екатеринбург, " - "улица Малышева, 51", - "precision": "exact", - "kind": "house", - } - }, - "name": "улица Малышева, 51", - "Point": {"pos": "60.586155 56.838004"}, - } - } - ] - } - } - } - captured: dict[str, httpx.Request] = {} - - def handler(request: httpx.Request) -> httpx.Response: - captured["req"] = request - return httpx.Response(200, json=fixture) - - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient(transport=transport) as client: - res = await forward_via_api("ул Малышева 51", "DUMMY_KEY", client=client) - - assert res.address is not None and "Малышева" in res.address - assert res.precision == "exact" - assert res.kind == "house" - assert res.snapped_lon == pytest.approx(60.586155, abs=1e-6) - assert res.snapped_lat == pytest.approx(56.838004, abs=1e-6) - - qs = dict(httpx.QueryParams(captured["req"].url.query)) - assert qs["apikey"] == "DUMMY_KEY" - assert qs["geocode"] == "ул Малышева 51" - assert qs["format"] == "json" - assert qs["kind"] == "house" - # EKB locality bias for forward geocode — important so addresses without - # the city resolve to the correct Малышева (there's one in Moscow too). - assert "ll" in qs - assert "spn" in qs - - -# --------------------------------------------------------------------------- -# Precision / kind passthrough in _parse_api_payload -# --------------------------------------------------------------------------- - - -def test_parse_api_payload_propagates_precision_and_kind(): - data = { - "response": { - "GeoObjectCollection": { - "featureMember": [ - { - "GeoObject": { - "metaDataProperty": { - "GeocoderMetaData": { - "text": "ул Ленина 5", - "precision": "exact", - "kind": "house", - } - }, - "name": "ул Ленина 5", - "Point": {"pos": "60.6 56.8"}, - } - } - ] - } - } - } - res = _parse_api_payload(data) - assert res.precision == "exact" - assert res.kind == "house" - - -# --------------------------------------------------------------------------- -# _classify_backfill_status — precision filter rules -# --------------------------------------------------------------------------- - - -def test_classify_backfill_status_exact_match(): - res = YandexReverseResult( - address="ул Малышева 51", - snapped_lat=56.838, - snapped_lon=60.586, - precision="exact", - kind="house", - ) - assert _classify_backfill_status(res) == "backfill" - - -def test_classify_backfill_status_number_match(): - res = YandexReverseResult( - address="ул Ленина 5", - snapped_lat=56.840, - snapped_lon=60.600, - precision="number", - kind="house", - ) - assert _classify_backfill_status(res) == "backfill" - - -def test_classify_backfill_status_street_is_imprecise(): - res = YandexReverseResult( - address="ул Ленина", - snapped_lat=56.840, - snapped_lon=60.600, - precision="street", - kind="street", - ) - assert _classify_backfill_status(res) == "imprecise" - - -def test_classify_backfill_status_other_is_imprecise(): - res = YandexReverseResult( - address="Свердловская область", - snapped_lat=56.8, - snapped_lon=60.6, - precision="other", - kind="locality", - ) - assert _classify_backfill_status(res) == "imprecise" - - -def test_classify_backfill_status_no_match(): - res = YandexReverseResult(address=None, snapped_lat=None, snapped_lon=None) - assert _classify_backfill_status(res) == "no_match" - - -def test_classify_backfill_status_none(): - assert _classify_backfill_status(None) == "no_match" - - -def test_classify_backfill_status_precision_ok_but_no_coords(): - """Defensive: precision=exact but snapped point missing → no_match, not backfill.""" - res = YandexReverseResult( - address="ул Малышева 51", - snapped_lat=None, - snapped_lon=None, - precision="exact", - kind="house", - ) - assert _classify_backfill_status(res) == "no_match" - - -# --------------------------------------------------------------------------- -# _update_house_coords — UPDATE shape verification -# --------------------------------------------------------------------------- - - -def test_update_house_coords_passes_bindings(): - db = MagicMock() - _update_house_coords( - db, - house_id=42, - lat=56.838, - lon=60.586, - payload={"address": "ул Малышева 51", "precision": "exact"}, - ) - args, _kw = db.execute.call_args - sql_str = str(args[0]) - binds = args[1] - assert "UPDATE houses" in sql_str - assert "raw_payload" in sql_str - assert "yandex_geocode" in sql_str - assert binds["id"] == 42 - assert binds["lat"] == 56.838 - assert binds["lon"] == 60.586 - # payload bound as JSON string for CAST(:payload AS jsonb) - decoded = json.loads(binds["payload"]) - assert decoded["address"] == "ул Малышева 51" - - -# --------------------------------------------------------------------------- -# DB mock helper — same approach as test_audit_address_mismatch -# --------------------------------------------------------------------------- - - -def _make_db_mock( - backfill_sample: list[dict] | None = None, - audit_sample: list[dict] | None = None, - processed_ids: set[int] | None = None, - distance_value: float = 12.5, -): - """MagicMock DB that: - - returns `backfill_sample` for `lat IS NULL OR lon IS NULL` SELECT - - returns `audit_sample` for `lat IS NOT NULL` SELECT - - returns `processed_ids` for the resume SELECT - - records INSERTs and UPDATEs - - returns `distance_value` for ST_Distance calls - """ - backfill_sample = backfill_sample or [] - audit_sample = audit_sample or [] - processed_ids = processed_ids if processed_ids is not None else set() - - inserted: list[dict] = [] - updated: list[dict] = [] - - db = MagicMock() - db.begin_nested.return_value.__enter__ = lambda self: self - db.begin_nested.return_value.__exit__ = lambda self, *a: False - - def execute_side_effect(sql, params=None): - sql_str = str(sql) - result = MagicMock() - if "FROM houses" in sql_str and "lat IS NULL OR lon IS NULL" in sql_str: - result.mappings.return_value.all.return_value = backfill_sample - elif "FROM houses" in sql_str and "lat IS NOT NULL" in sql_str: - result.mappings.return_value.all.return_value = audit_sample - elif "FROM address_mismatch_audit" in sql_str and "house_id" in sql_str: - result.all.return_value = [(hid,) for hid in processed_ids] - elif "INSERT INTO address_mismatch_audit" in sql_str: - inserted.append(dict(params)) - processed_ids.add(params["house_id"]) - elif "UPDATE houses" in sql_str: - updated.append(dict(params)) - elif "ST_Distance" in sql_str: - result.first.return_value = (distance_value,) - return result - - db.execute.side_effect = execute_side_effect - db.commit = MagicMock() - db.rollback = MagicMock() - db.close = MagicMock() - return db, inserted, updated - - -# --------------------------------------------------------------------------- -# _run_backfill_mode — happy path + imprecise-skip -# --------------------------------------------------------------------------- - - -async def test_run_backfill_mode_writes_update_and_audit(): - sample = [ - HouseRow(id=1, address="ул Малышева 51", lat=None, lon=None), - ] - db, inserted, updated = _make_db_mock() - res = YandexReverseResult( - address="Россия, Екатеринбург, улица Малышева, 51", - snapped_lat=56.838, - snapped_lon=60.586, - precision="exact", - kind="house", - raw={"ok": True}, - ) - with patch( - "scripts.backfill_house_coords.forward_via_api", - new=AsyncMock(return_value=res), - ): - n = await _run_backfill_mode(db, sample, "b1", "KEY") - assert n == 1 - assert len(updated) == 1 - assert updated[0]["id"] == 1 - assert updated[0]["lat"] == 56.838 - assert updated[0]["lon"] == 60.586 - assert len(inserted) == 1 - assert inserted[0]["audit_status"] == "backfill" - assert inserted[0]["snapped_address"] == "Россия, Екатеринбург, улица Малышева, 51" - - -async def test_run_backfill_mode_imprecise_skips_update(): - """precision='street' → audit row written with status=imprecise, no UPDATE.""" - sample = [HouseRow(id=2, address="ул Ленина", lat=None, lon=None)] - db, inserted, updated = _make_db_mock() - res = YandexReverseResult( - address="ул Ленина", - snapped_lat=56.840, - snapped_lon=60.600, - precision="street", - kind="street", - raw={"oh_well": True}, - ) - with patch( - "scripts.backfill_house_coords.forward_via_api", - new=AsyncMock(return_value=res), - ): - n = await _run_backfill_mode(db, sample, "b2", "KEY") - assert n == 1 - assert updated == [] - assert len(inserted) == 1 - assert inserted[0]["audit_status"] == "imprecise" - - -async def test_run_backfill_mode_no_match(): - """Yandex returns empty result → status=no_match, no UPDATE.""" - sample = [HouseRow(id=3, address="несуществующая улица 99", lat=None, lon=None)] - db, inserted, updated = _make_db_mock() - res = YandexReverseResult( - address=None, snapped_lat=None, snapped_lon=None, raw={"empty": True} - ) - with patch( - "scripts.backfill_house_coords.forward_via_api", - new=AsyncMock(return_value=res), - ): - n = await _run_backfill_mode(db, sample, "b3", "KEY") - assert n == 1 - assert updated == [] - assert inserted[0]["audit_status"] == "no_match" - - -async def test_run_backfill_mode_http_error_marks_error(): - sample = [HouseRow(id=4, address="ул X 1", lat=None, lon=None)] - db, inserted, updated = _make_db_mock() - with patch( - "scripts.backfill_house_coords.forward_via_api", - new=AsyncMock(side_effect=httpx.HTTPError("boom")), - ): - n = await _run_backfill_mode(db, sample, "b4", "KEY") - assert n == 1 - assert updated == [] - assert inserted[0]["audit_status"] == "error" - assert "boom" in (inserted[0]["error_message"] or "") - - -# --------------------------------------------------------------------------- -# _run_audit_mode — ok / mismatch / no_match -# --------------------------------------------------------------------------- - - -async def test_run_audit_mode_ok_within_50m(): - sample = [HouseRow(id=10, address="ул Малышева 51", lat=56.838, lon=60.586)] - db, inserted, _updated = _make_db_mock(distance_value=12.5) - res = YandexReverseResult( - address="Россия, Екатеринбург, улица Малышева, 51", - snapped_lat=56.838004, - snapped_lon=60.586155, - precision="exact", - kind="house", - raw={"r": 1}, - ) - with patch( - "scripts.backfill_house_coords.reverse_via_api", - new=AsyncMock(return_value=res), - ): - n = await _run_audit_mode(db, sample, "ba1", "KEY") - assert n == 1 - assert inserted[0]["audit_status"] == "ok" - assert inserted[0]["distance_m"] == 12.5 - - -async def test_run_audit_mode_mismatch_above_50m(): - sample = [HouseRow(id=11, address="ул Ленина 5", lat=56.840, lon=60.600)] - db, inserted, _updated = _make_db_mock(distance_value=312.0) - res = YandexReverseResult( - address="Россия, Екатеринбург, улица Ленина, 7", - snapped_lat=56.841, - snapped_lon=60.601, - precision="exact", - kind="house", - raw={"r": 2}, - ) - with patch( - "scripts.backfill_house_coords.reverse_via_api", - new=AsyncMock(return_value=res), - ): - n = await _run_audit_mode(db, sample, "ba2", "KEY") - assert n == 1 - assert inserted[0]["audit_status"] == "mismatch" - assert inserted[0]["distance_m"] == 312.0 - - -async def test_run_audit_mode_no_match(): - sample = [HouseRow(id=12, address="ул X 99", lat=56.0, lon=60.0)] - db, inserted, _updated = _make_db_mock() - res = YandexReverseResult( - address=None, snapped_lat=None, snapped_lon=None, raw={"empty": True} - ) - with patch( - "scripts.backfill_house_coords.reverse_via_api", - new=AsyncMock(return_value=res), - ): - n = await _run_audit_mode(db, sample, "ba3", "KEY") - assert n == 1 - assert inserted[0]["audit_status"] == "no_match" - - -# --------------------------------------------------------------------------- -# Resumability — second pass on same batch inserts 0 -# --------------------------------------------------------------------------- - - -async def test_main_resumable_skips_processed(monkeypatch): - """Run main() twice with same batch — second pass processes nothing.""" - backfill_sample = [ - {"id": 1, "address": "ул Малышева 51", "lat": None, "lon": None}, - {"id": 2, "address": "ул Ленина 5", "lat": None, "lon": None}, - ] - processed_ids: set[int] = set() - db, inserted, updated = _make_db_mock( - backfill_sample=backfill_sample, processed_ids=processed_ids - ) - - monkeypatch.setenv("YANDEX_GEOCODER_API_KEY", "TEST_KEY") - fake = YandexReverseResult( - address="ул Малышева 51", - snapped_lat=56.838, - snapped_lon=60.586, - precision="exact", - kind="house", - raw={"ok": True}, - ) - - with ( - patch("scripts.backfill_house_coords.SessionLocal", return_value=db), - patch( - "scripts.backfill_house_coords.forward_via_api", - new=AsyncMock(return_value=fake), - ), - ): - n1 = await main(["--batch", "resume_test"]) - assert n1 == 2 - assert len(inserted) == 2 - assert len(updated) == 2 - - inserted.clear() - updated.clear() - n2 = await main(["--batch", "resume_test"]) - assert n2 == 0 - assert inserted == [] - assert updated == [] - - -async def test_main_requires_api_key(monkeypatch): - """Without YANDEX_GEOCODER_API_KEY the script exits cleanly.""" - monkeypatch.delenv("YANDEX_GEOCODER_API_KEY", raising=False) - with pytest.raises(SystemExit): - await main(["--batch", "no_key"]) - - -async def test_main_audit_only_flag_routes_to_audit_loop(monkeypatch): - """--audit-only switches sample query + loop, no UPDATE expected.""" - audit_sample = [ - {"id": 50, "address": "ул Малышева 51", "lat": 56.838, "lon": 60.586}, - ] - db, inserted, updated = _make_db_mock(audit_sample=audit_sample, distance_value=8.0) - monkeypatch.setenv("YANDEX_GEOCODER_API_KEY", "TEST_KEY") - fake = YandexReverseResult( - address="Россия, Екатеринбург, улица Малышева, 51", - snapped_lat=56.838004, - snapped_lon=60.586155, - precision="exact", - kind="house", - raw={"r": 1}, - ) - with ( - patch("scripts.backfill_house_coords.SessionLocal", return_value=db), - patch( - "scripts.backfill_house_coords.reverse_via_api", - new=AsyncMock(return_value=fake), - ), - ): - n = await main(["--batch", "audit_run", "--audit-only"]) - assert n == 1 - assert updated == [] # audit mode never updates houses - assert inserted[0]["audit_status"] == "ok" - assert inserted[0]["distance_m"] == 8.0 diff --git a/tradein-mvp/backend/tests/test_geocoder_nominatim_lookup.py b/tradein-mvp/backend/tests/test_geocoder_nominatim_lookup.py new file mode 100644 index 00000000..b70b3c36 --- /dev/null +++ b/tradein-mvp/backend/tests/test_geocoder_nominatim_lookup.py @@ -0,0 +1,91 @@ +"""Тесты `_nominatim_lookup` — city реально доходит до исходящего HTTP-запроса. + +#2593 (часть 3): Yandex Geocoder полностью удалён из проекта, вместе с ним ушли +`_yandex_reverse.py` + `tests/test_audit_address_mismatch.py` + +`tests/test_backfill_house_coords.py` — они были единственной проверкой, что +`city`/`city_hint` реально передаётся во внешний геокодер, а не только влияет на +cache-ключ (см. `tests/test_geocoder_city_hint.py`, который мокает +`_nominatim_lookup`/`_nominatim_suggest` целиком и потому не видит их внутренности). + +Nominatim теперь единственный живой внешний провайдер (`_nominatim_lookup` +docstring, `app/services/geocoder.py`) — этот файл закрывает получившуюся дыру: +мокает HTTP-транспорт (`httpx.MockTransport`, паттерн из `test_geocoder_bbox.py` / +`tests/services/test_dadata.py`) и проверяет параметр `q` реального исходящего +GET-запроса к `nominatim.openstreetmap.org/search`. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from unittest.mock import patch + +import httpx + +from app.services.geocoder import _nominatim_lookup + +# EKB-центр (Плотинка) — внутри tight EKB bbox, `_nominatim_query` его примет +# без похода во второй (typo-variant) тир. +_SAMPLE_ITEM = { + "lat": "56.838", + "lon": "60.605", + "class": "building", + "display_name": "ул. Малышева, 30, Екатеринбург", + "address": {"state": "Свердловская область"}, +} + +# Snapshot реального httpx.AsyncClient ДО patch'а — фабрика ниже использует именно +# его с подменённым transport (паттерн tests/services/test_dadata.py: избегает +# recursion, если бы `httpx.AsyncClient` патчился поверх самого себя). +_REAL_ASYNC_CLIENT = httpx.AsyncClient + + +def _async_client_factory(transport: httpx.MockTransport): + def factory(*_: object, **__: object) -> httpx.AsyncClient: + return _REAL_ASYNC_CLIENT(transport=transport) + + return factory + + +def _capturing_transport(captured_q: list[str]) -> httpx.MockTransport: + def handler(request: httpx.Request) -> httpx.Response: + captured_q.append(request.url.params.get("q", "")) + return httpx.Response(200, json=[_SAMPLE_ITEM]) + + return httpx.MockTransport(handler) + + +async def test_nominatim_lookup_sends_city_hint_in_query_param() -> None: + """city_hint="Нижний Тагил" должен попасть в q= реального GET-запроса. + + Регрессия, о которой явно предупреждает docstring `_nominatim_lookup` (#2580 C): + city_hint обязан влиять на сам запрос к провайдеру, не только на cache-ключ. + """ + captured_q: list[str] = [] + transport = _capturing_transport(captured_q) + + with patch("app.services.geocoder.httpx.AsyncClient", _async_client_factory(transport)): + result = await _nominatim_lookup("Ленина, 1", city_hint="Нижний Тагил") + + assert captured_q, "запрос к Nominatim не был отправлен" + assert captured_q[0] == "Нижний Тагил, Ленина, 1" + assert result is not None + assert result.provider == "nominatim" + + +async def test_nominatim_lookup_no_city_sends_bare_address() -> None: + """Без city_hint и без маркера города в тексте — q= остаётся bare-адресом. + + Guard против регрессии в молчаливый дефолт на конкретный город (#2576/#2593) + — до фикса #2576 сюда молча подставлялся "Екатеринбург". + """ + captured_q: list[str] = [] + transport = _capturing_transport(captured_q) + + with patch("app.services.geocoder.httpx.AsyncClient", _async_client_factory(transport)): + result = await _nominatim_lookup("Малышева, 30") + + assert captured_q == ["Малышева, 30"] + assert result is not None From 10a62a0a566cd80344b218b2d28664b94b74502b Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 31 Jul 2026 23:36:52 +0300 Subject: [PATCH 043/141] =?UTF-8?q?chore(tradein/geocoder):=20=D1=83=D0=B1?= =?UTF-8?q?=D1=80=D0=B0=D1=82=D1=8C=20=D0=BC=D1=91=D1=80=D1=82=D0=B2=D1=83?= =?UTF-8?q?=D1=8E=20YANDEX=5FGEOCODER=5FAPI=5FKEY=20=D0=B8=D0=B7=20.env.ex?= =?UTF-8?q?ample=20(#2593)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/.env.example | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tradein-mvp/.env.example b/tradein-mvp/.env.example index 7bacad4a..456ebecd 100644 --- a/tradein-mvp/.env.example +++ b/tradein-mvp/.env.example @@ -6,12 +6,6 @@ DATABASE_URL=postgresql+psycopg://tradein:tradein@postgres:5432/tradein CORS_ORIGINS=["http://localhost:8080","http://localhost:3000"] ENVIRONMENT=dev -# Yandex Geocoder API key (25k req/day free tier). -# Required for backfill scripts (scripts/backfill_house_coords.py + audit_address_mismatch.py). -# Empty = Nominatim fallback для backend геокодинга; backfill scripts требуют этот ключ -# и упадут с SystemExit без него. -YANDEX_GEOCODER_API_KEY= - # DaData /clean/address — обогащение target адреса в estimate flow (PR Q1). # Возвращает canonical-форму, kadastr_num, ФИАС, координаты, ближайшее метро. # Demo tier: 100 req/день — хватит для тестов и low-traffic prod. From 41e3cb906b67f287abde116549120324e2311cca Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 31 Jul 2026 23:47:26 +0300 Subject: [PATCH 044/141] =?UTF-8?q?fix(tradein/geocode):=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B4=D0=B0=D0=B2=D0=B0=D1=82=D1=8C=20=D0=B3=D0=BE?= =?UTF-8?q?=D1=80=D0=BE=D0=B4=20=D0=BE=D0=B1=D1=8A=D1=8F=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=BA=D0=B0=D0=BA=20city=5Fhint=20=D0=B2?= =?UTF-8?q?=20=D0=B3=D0=B5=D0=BE=D0=BA=D0=BE=D0=B4=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B5=20(#2594)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Замыкает петлю "город объявления -> геокодирование" (issue #2594, шаг 2/3). listings.city (миграция 196) заполняется скрапером из контекста развёртки, но три caller-места геокодера решали город по ТЕКСТУ адреса и игнорировали колонку - голый адрес без города в тексте ("ул. Победы, 30", тагильский) уходил в Екатеринбург. - app/tasks/geocode_missing.py: группировка по (address, city) вместо address, city_hint в geocode(), UPDATE/tried_at-пометка по паре через city IS NOT DISTINCT FROM :city (обычный `=` не поймал бы NULL-город и не даёт нужной симметрии между группами). - app/tasks/backfill_listings_coords_geoportal.py: гейт по колонке city ПЕРЕД матчем против EKB-only ekb_geoportal_buildings, ПЕРЕД текстовым гейтом _names_non_ekb_city (сохранён как fallback для city IS NULL). Это окно идёт раньше geocode_missing_listings, поэтому раньше успевало испортить координаты первым. - app/api/v1/admin.py: per-ID endpoint /geocode-missing читает city из SELECT (listings.city / deals.city) и передаёт как city_hint. geocoder.py не тронут (запрещено ТЗ). Тесты: falsification-прогон (stash impl, тесты остаются) - 7 failed / 36 passed на старом коде, все 7 - новые тесты на новое поведение; после stash pop - 43 passed / 0 failed. Полный pytest tradein-mvp/backend: 2970 passed, 1 failed (pre-existing tests/test_search_api.py::test_search_cache_hit, несвязан), 9 skipped. --- tradein-mvp/backend/app/api/v1/admin.py | 10 +- .../backfill_listings_coords_geoportal.py | 48 ++-- .../backend/app/tasks/geocode_missing.py | 84 +++++-- ...test_backfill_listings_coords_geoportal.py | 86 +++++++ .../tests/tasks/test_geocode_missing.py | 213 ++++++++++++++++++ 5 files changed, 400 insertions(+), 41 deletions(-) diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py index c03e024e..5ab89505 100644 --- a/tradein-mvp/backend/app/api/v1/admin.py +++ b/tradein-mvp/backend/app/api/v1/admin.py @@ -276,7 +276,7 @@ async def geocode_missing( db.execute( text( f""" - SELECT id, address + SELECT id, address, city FROM {target} WHERE lat IS NULL AND COALESCE(address, '') != '' @@ -310,7 +310,13 @@ async def geocode_missing( ) break clean = _clean_address_for_geocode(row["address"]) - result = await geocode(clean, db) + # city (#2594 шаг 2/3) — известен вызывающему коду через listings.city + # (миграция 196) / deals.city (миграция 177), проставляется из контекста + # развёртки/импорта. Прокидываем как city_hint, а не полагаемся на то, что + # геокодер угадает город по тексту address (голый "ул. Победы, 30" без + # города в тексте иначе уходит в Екатеринбург). + city = row.get("city") + result = await geocode(clean, db, city_hint=city) if result is None: # Помечаем что пробовали — иначе ретрай на каждом cron. db.execute( 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 f95ae077..9bd37f38 100644 --- a/tradein-mvp/backend/app/tasks/backfill_listings_coords_geoportal.py +++ b/tradein-mvp/backend/app/tasks/backfill_listings_coords_geoportal.py @@ -10,15 +10,21 @@ Парсинг адреса — _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). +Городской гейт (#2583, находка H3; расширен #2594 шаг 2/3): `ekb_geoportal_buildings` — +EKB-only реестр: улица+дом могут буквально совпасть между Екатеринбургом и другим городом +области (например, «проспект Ленина 1» есть и в ЕКБ, и в Нижнем Тагиле). Без проверки +города такой листинг получает екатеринбургские координаты, хотя находится в другом городе. +Гейт — ДВЕ проверки перед вызовом _geoportal_house_match: + 1. Колонка `listings.city` (#2594, миграция 196) — если проставлена НЕ-Екатеринбургом, + листинг пропускается сразу, без обращения к тексту адреса. Это надёжный сигнал из + контекста развёртки (скрапер знает город явно), тогда как текстовый гейт полагается + на то, что город явно упомянут в самом тексте адреса. + 2. _names_non_ekb_city(address) (та же функция, что гейтит EKB-only тиры внутри + geocoder.geocode()) — СОХРАНЕНА как fallback для листингов, у которых city IS NULL + (записаны до миграции 196 или путём, ещё не проставляющим город, например admin + ad-hoc /admin/scrape) — там единственный сигнал о городе — текст адреса. +Оба пути пропуска считаются в 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 (сотни-тысячи строк за @@ -79,7 +85,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 гейт) + skipped_non_ekb: int = 0 # non-ЕКБ гейт: колонка city (#2594) ИЛИ текст адреса (#2583) errors: int = 0 # исключения при обработке отдельной записи duration_sec: float = field(default=0.0) @@ -179,7 +185,7 @@ def backfill_coords_from_geoportal( rows = ( db.execute( text(f""" - SELECT id, address + SELECT id, address, city FROM listings WHERE lat IS NULL AND geom IS NULL @@ -210,9 +216,23 @@ def backfill_coords_from_geoportal( res.no_address += 1 continue - # Городской гейт (#2583, H3) — ekb_geoportal_buildings EKB-only, - # улица+дом могут совпасть с другим городом области. Адрес, явно - # называющий другой город региона, пропускаем — остаётся + # Городской гейт по колонке (#2594 шаг 2/3) — ПЕРЕД матчем и ПЕРЕД + # текстовым гейтом. listings.city (миграция 196) проставляется из + # контекста развёртки скрапером — надёжнее текста адреса. Голый + # тагильский адрес без города в тексте ("ул. Победы, 30") раньше + # проходил только текстовый гейт и мог ложно сматчиться с + # одноимённым екатеринбургским домом в EKB-only реестре. Это окно + # идёт ПЕРЕД geocode_missing_listings — без гейта по колонке оно + # успевает испортить координаты первым. + city: str | None = row.get("city") + if city is not None and city != "Екатеринбург": + res.skipped_non_ekb += 1 + continue + + # Текстовый гейт (#2583, H3) — fallback для листингов, у которых + # колонка city пуста (записаны до миграции 196 либо путём, ещё не + # проставляющим город, напр. admin ad-hoc /admin/scrape). Адрес, + # явно называющий другой город региона, пропускаем — остаётся # lat IS NULL для oblast-aware geocode_missing_listings. if _names_non_ekb_city(address): res.skipped_non_ekb += 1 diff --git a/tradein-mvp/backend/app/tasks/geocode_missing.py b/tradein-mvp/backend/app/tasks/geocode_missing.py index 38776907..5bad11f3 100644 --- a/tradein-mvp/backend/app/tasks/geocode_missing.py +++ b/tradein-mvp/backend/app/tasks/geocode_missing.py @@ -5,15 +5,21 @@ - Scheduled: nightly via scrape_schedules (source='geocode_missing_listings', migration 110) — wired into in-app scheduler, window 06:00-09:00 UTC. -Pattern: dedup по address (1 unique address → 1 geocode call → UPDATE all listings). +Pattern: dedup по паре (address, city) — 1 уникальная пара → 1 geocode call → UPDATE +всех listings с этим address+city (#2594 шаг 2/3: listings.city теперь заполняется +скрапером из контекста развёртки — один и тот же текст адреса в разных городах +(«ул. Победы, 30» в ЕКБ и в Нижнем Тагиле) должен получать РАЗНЫЕ координаты, а +не схлопываться в один geocode-вызов и один UPDATE по тексту адреса). Rate limit: Nominatim 1 req/sec (#2593: Yandex Geocoder tier удалён из geocoder). Отличие от /admin/geocode-missing (per-ID): - - Этот модуль группирует по address → меньше API calls (dedup). + - Этот модуль группирует по (address, city) → меньше API calls (dedup), но не + схлопывает разные города с одинаковым текстом адреса. - Поддерживает all sources включая Avito (после PR #487 убрали jitter). - Возвращает GeocodeBackfillResult с детальными counters. - Loop-safe: SELECT фильтрует geocode_tried_at IS NULL OR tried_at < 7 days; - при geocode failure помечает tried_at=NOW() → адрес не переотбирается в этом же run. + при geocode failure помечает tried_at=NOW() → пара (address, city) не + переотбирается в этом же run. """ from __future__ import annotations @@ -53,13 +59,20 @@ async def geocode_missing_listings( """Geocode listings с NULL coords (любой source). Steps: - 1. SELECT DISTINCT address FROM listings WHERE lat IS NULL AND address IS NOT NULL - GROUP BY address ORDER BY COUNT(*) DESC LIMIT batch_size - (приоритет адресам с большим числом listings — больший ROI per geocode call) + 1. SELECT address, city FROM listings WHERE lat IS NULL AND address IS NOT NULL + GROUP BY address, city ORDER BY COUNT(*) DESC LIMIT batch_size + (приоритет парам address+city с большим числом listings — больший ROI per + geocode call; группировка по паре, НЕ только по address — #2594 шаг 2/3: + один и тот же текст адреса в разных городах — разные записи) - 2. Для каждого address: - - geocode(address, db) — auto-cache (hit или miss) - - Если есть результат: UPDATE listings SET lat, lon WHERE address = :addr AND lat IS NULL + 2. Для каждой пары (address, city): + - geocode(address, db, city_hint=city) — auto-cache (hit или miss) + - Если есть результат: UPDATE listings SET lat, lon + WHERE address = :addr AND city IS NOT DISTINCT FROM :city AND lat IS NULL + (IS NOT DISTINCT FROM, а не `=` — стандартная SQL NULL-семантика: `city = NULL` + никогда не true, поэтому обычным `=` группа с city IS NULL не обновилась бы + вообще ни для одной строки; `IS NOT DISTINCT FROM` трактует NULL=NULL как + совпадение, оставаясь строгим при непустом city — нужная нам симметрия) - PostGIS trigger (listings_set_geom_trg) автоматически обновит geom 3. Log progress каждые 50 addresses. @@ -74,25 +87,30 @@ async def geocode_missing_listings( start = time.monotonic() result = GeocodeBackfillResult() - # 1. Найти top-N адресов с NULL coords (DESC by occurrence count). - # Фильтруем адреса, по которым геокодер уже пробовал и не нашёл — они помечены + # 1. Найти top-N пар (address, city) с NULL coords (DESC by occurrence count). + # Группировка по паре, а не только по address (#2594 шаг 2/3) — один и тот же + # текст адреса в разных городах (напр. «ул. Победы, 30» в ЕКБ и в Нижнем Тагиле) + # это разные записи с разными координатами, их нельзя схлопывать в один + # geocode-вызов. GROUP BY address, city трактует NULL city как отдельную + # группу (стандартная SQL-семантика группировки NULL как равных друг другу). + # Фильтруем пары, по которым геокодер уже пробовал и не нашёл — они помечены # geocode_tried_at. Повторяем попытку только если tried_at старше 7 дней (возможен # переезд адреса в кэше или смена провайдера), либо tried_at IS NULL (ещё не пробовали). # Это делает функцию loop-safe: при вызове несколько раз в одном прогоне - # failed-адреса не переотбираются бесконечно. + # failed-пары не переотбираются бесконечно. rows = ( db.execute( text( """ - SELECT address, COUNT(*) AS listings_count + SELECT address, city, COUNT(*) AS listings_count FROM listings WHERE lat IS NULL AND address IS NOT NULL AND length(trim(address)) >= 5 AND (geocode_tried_at IS NULL OR geocode_tried_at < NOW() - INTERVAL '7 days') - GROUP BY address - ORDER BY listings_count DESC, address ASC + GROUP BY address, city + ORDER BY listings_count DESC, address ASC, city ASC NULLS FIRST LIMIT :limit """ ), @@ -117,23 +135,28 @@ async def geocode_missing_listings( for idx, row in enumerate(rows): address: str = row["address"] + city: str | None = row.get("city") listings_count: int = row["listings_count"] result.addresses_processed += 1 try: - geo = await geocode(address, db) + geo = await geocode(address, db, city_hint=city) except Exception as exc: logger.warning("geocode_missing: geocode raised for '%s': %s", address[:60], exc) result.addresses_failed += 1 if not dry_run: - # Пометить tried_at чтобы адрес не переотбирался в следующих batch'ах - # этого же прогона (loop-safe backoff 7 дней). + # Пометить tried_at чтобы пара (address, city) не переотбиралась + # в следующих batch'ах этого же прогона (loop-safe backoff 7 дней). + # IS NOT DISTINCT FROM — city=NULL это отдельная группа, обычное + # `=` не поймает NULL-город и не должно задеть другой город с тем + # же текстом адреса. db.execute( text( "UPDATE listings SET geocode_tried_at = NOW()" - " WHERE address = :addr AND lat IS NULL" + " WHERE address = :addr AND city IS NOT DISTINCT FROM :city" + " AND lat IS NULL" ), - {"addr": address}, + {"addr": address, "city": city}, ) db.commit() continue @@ -141,8 +164,9 @@ async def geocode_missing_listings( if geo is None: result.addresses_failed += 1 logger.info( - "geocode_missing: NOT FOUND '%s' (used in %d listings)", + "geocode_missing: NOT FOUND '%s' city=%r (used in %d listings)", address[:60], + city, listings_count, ) if not dry_run: @@ -150,9 +174,10 @@ async def geocode_missing_listings( db.execute( text( "UPDATE listings SET geocode_tried_at = NOW()" - " WHERE address = :addr AND lat IS NULL" + " WHERE address = :addr AND city IS NOT DISTINCT FROM :city" + " AND lat IS NULL" ), - {"addr": address}, + {"addr": address, "city": city}, ) db.commit() continue @@ -183,16 +208,25 @@ async def geocode_missing_listings( # UPDATE listings — PostGIS trigger (listings_set_geom_trg) обновит geom автоматически. # geo_precision и geocode_tried_at проставляются одновременно с координатами. + # city IS NOT DISTINCT FROM :city — обновляем ТОЛЬКО пару (address, city), из + # которой был geocode-запрос; иначе тот же текст адреса в другом городе + # (city IS NULL или другой явный город) перезаписался бы чужими координатами. update_result = db.execute( text( """ UPDATE listings SET lat = :lat, lon = :lon, geo_precision = :precision, geocode_tried_at = NOW() - WHERE address = :addr AND lat IS NULL + WHERE address = :addr AND city IS NOT DISTINCT FROM :city AND lat IS NULL """ ), - {"lat": geo.lat, "lon": geo.lon, "precision": precision, "addr": address}, + { + "lat": geo.lat, + "lon": geo.lon, + "precision": precision, + "addr": address, + "city": city, + }, ) db.commit() result.listings_updated += update_result.rowcount 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 23be177c..709ab7e0 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 @@ -273,6 +273,92 @@ def test_ekb_address_still_matched_with_real_gate() -> None: mock_geo.assert_called_once_with(db, "проспект ленина", "1") +# ── городской гейт по колонке listings.city (#2594, миграция 196, шаг 2/3) ────── + + +def test_city_column_non_ekb_skips_before_text_gate_and_match() -> None: + """listings.city='Нижний Тагил', но address НЕ называет город в тексте + ("ул. Победы, 30" — bare form). Текстовый гейт (_names_non_ekb_city) пропустил + бы этот адрес дальше (город нигде явно не назван в тексте), но колонка city — + надёжный сигнал из контекста развёртки скрапера — должна перехватить его + раньше матча против EKB-only ekb_geoportal_buildings, иначе адрес получил бы + ложные екатеринбургские координаты (issue #2594).""" + rows = [{"id": 200, "address": "ул. Победы, 30", "city": "Нижний Тагил"}] + db = _make_db([rows, []]) + + # Sanity: текстовый гейт САМ ПО СЕБЕ не поймал бы этот bare-адрес. + assert _names_non_ekb_city("ул. Победы, 30") 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=("победы", "30"), + ) 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 + # Ни парсер, ни 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_city_column_ekb_still_matched_not_a_regression() -> None: + """listings.city='Екатеринбург' — гейт по колонке пропускает дальше, как раньше.""" + rows = [{"id": 201, "address": "ул. Победы, 30", "city": "Екатеринбург"}] + db = _make_db([rows, []]) + + 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=("победы", "30"), + ), + ): + 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, "победы", "30") + + +def test_city_column_null_falls_back_to_text_gate() -> None: + """listings.city IS NULL (записан до миграции 196) — гейт по колонке молчит, + решение остаётся за текстовым гейтом _names_non_ekb_city (не деградация #2583).""" + rows = [{"id": 202, "address": "г. Нижний Тагил, проспект Ленина, 1", "city": None}] + db = _make_db([rows, []]) + + 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") 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 + mock_parse.assert_not_called() + mock_geo.assert_not_called() + + # ── idempotency ─────────────────────────────────────────────────────────────── diff --git a/tradein-mvp/backend/tests/tasks/test_geocode_missing.py b/tradein-mvp/backend/tests/tasks/test_geocode_missing.py index b8ccdc75..ac3c6ede 100644 --- a/tradein-mvp/backend/tests/tasks/test_geocode_missing.py +++ b/tradein-mvp/backend/tests/tasks/test_geocode_missing.py @@ -409,6 +409,157 @@ async def test_run_geocode_missing_listings_mark_failed_on_exception() -> None: mock_runs.mark_done.assert_not_called() +# ── (address, city) pair grouping / city_hint (#2594 шаг 2/3) ──────────────── + + +@pytest.mark.asyncio +async def test_geocode_missing_same_address_different_city_independent_calls_and_updates() -> None: + """Ключевой сценарий #2594: два ряда с ОДИНАКОВЫМ текстом address, но РАЗНЫМИ + city — каждый должен получить свой geocode-вызов (city_hint) и свой UPDATE, не + затрагивающий другую пару. Раньше группировка была только по address: SELECT + группировал по тексту, а UPDATE бил по WHERE address = :addr без city — второй + UPDATE (для Нижнего Тагила) перезаписал бы координаты, уже проставленные первым + (для Екатеринбурга), и наоборот. + """ + rows = [ + {"address": "ул. Победы, 30", "city": "Екатеринбург", "listings_count": 1}, + {"address": "ул. Победы, 30", "city": "Нижний Тагил", "listings_count": 1}, + ] + + ekb_geo = GeocodeResult( + lat=56.838, + lon=60.605, + full_address="Екатеринбург, ул. Победы, 30", + provider="nominatim", # type: ignore[arg-type] + confidence="exact", + ) + tagil_geo = GeocodeResult( + lat=57.910, + lon=59.985, + full_address="Нижний Тагил, ул. Победы, 30", + provider="nominatim", # type: ignore[arg-type] + confidence="exact", + ) + + db = MagicMock() + select_result = MagicMock() + select_result.mappings.return_value.all.return_value = rows + update_ekb = MagicMock() + update_ekb.rowcount = 1 + update_tagil = MagicMock() + update_tagil.rowcount = 1 + db.execute.side_effect = [select_result, update_ekb, update_tagil] + + with patch( + "app.tasks.geocode_missing.geocode", + new_callable=AsyncMock, + side_effect=[ekb_geo, tagil_geo], + ) as mock_geo: + result = await geocode_missing_listings(db, batch_size=200) + + # 2 отдельных geocode-вызова — по одному на пару (address, city), не 1 на address. + assert mock_geo.call_count == 2 + ekb_call = mock_geo.call_args_list[0] + tagil_call = mock_geo.call_args_list[1] + assert ekb_call.args[0] == "ул. Победы, 30" + assert ekb_call.kwargs["city_hint"] == "Екатеринбург" + assert tagil_call.args[0] == "ул. Победы, 30" + assert tagil_call.kwargs["city_hint"] == "Нижний Тагил" + + # 2 отдельных UPDATE, каждый со своим city в WHERE — не задевает другую пару. + update_calls = db.execute.call_args_list[1:] + assert len(update_calls) == 2 + expected = [("Екатеринбург", 56.838), ("Нижний Тагил", 57.910)] + for call, (expected_city, expected_lat) in zip(update_calls, expected, strict=True): + sql = str(call.args[0]) + params = call.args[1] + assert "IS NOT DISTINCT FROM" in sql + assert params["addr"] == "ул. Победы, 30" + assert params["city"] == expected_city + assert params["lat"] == pytest.approx(expected_lat) + + assert result.addresses_processed == 2 + assert result.addresses_geocoded == 2 + assert result.listings_updated == 2 + + +@pytest.mark.asyncio +async def test_geocode_missing_null_city_group_uses_is_not_distinct_from() -> None: + """city IS NULL — своя группа. city_hint=None передаётся геокодеру, UPDATE + использует IS NOT DISTINCT FROM (обычный `=` никогда не совпал бы с NULL — + группа NULL-city вообще не обновилась бы обычным equality-сравнением).""" + rows = [{"address": "ул. Дружинина, 33", "city": None, "listings_count": 2}] + + db = MagicMock() + select_result = MagicMock() + select_result.mappings.return_value.all.return_value = rows + update_result = MagicMock() + update_result.rowcount = 2 + db.execute.side_effect = [select_result, update_result] + + with patch( + "app.tasks.geocode_missing.geocode", + new_callable=AsyncMock, + return_value=_make_geocode_result("nominatim"), + ) as mock_geo: + result = await geocode_missing_listings(db, batch_size=200) + + mock_geo.assert_called_once_with("ул. Дружинина, 33", db, city_hint=None) + + update_call = db.execute.call_args_list[1] + sql = str(update_call.args[0]) + params = update_call.args[1] + assert "IS NOT DISTINCT FROM" in sql + assert params["city"] is None + assert result.listings_updated == 2 + + +@pytest.mark.asyncio +async def test_geocode_missing_select_groups_by_address_and_city() -> None: + """SELECT содержит GROUP BY address, city — НЕ только по address (#2594).""" + db = MagicMock() + select_result = MagicMock() + select_result.mappings.return_value.all.return_value = [] + db.execute.return_value = select_result + + with patch("app.tasks.geocode_missing.geocode", new_callable=AsyncMock): + await geocode_missing_listings(db, batch_size=10) + + first_call = db.execute.call_args_list[0] + sql_text = str(first_call[0][0]) + assert "GROUP BY address, city" in sql_text + assert "SELECT address, city, COUNT(*)" in sql_text + + +@pytest.mark.asyncio +async def test_geocode_missing_failed_pair_tried_at_update_scoped_to_city() -> None: + """Failed geocode (geo=None) для (address, city) → UPDATE tried_at ограничен + ЭТОЙ парой (IS NOT DISTINCT FROM city), не всеми строками с тем же address.""" + rows = [{"address": "несуществующий адрес", "city": "Нижний Тагил", "listings_count": 1}] + + db = MagicMock() + select_result = MagicMock() + select_result.mappings.return_value.all.return_value = rows + tried_at_result = MagicMock() + db.execute.side_effect = [select_result, tried_at_result] + + with patch( + "app.tasks.geocode_missing.geocode", + new_callable=AsyncMock, + return_value=None, + ) as mock_geo: + result = await geocode_missing_listings(db, batch_size=200) + + mock_geo.assert_called_once_with("несуществующий адрес", db, city_hint="Нижний Тагил") + assert result.addresses_failed == 1 + + update_call = db.execute.call_args_list[1] + sql = str(update_call.args[0]) + params = update_call.args[1] + assert "IS NOT DISTINCT FROM" in sql + assert params["city"] == "Нижний Тагил" + + # ── Integration-style: estimator Avito exclusion removed ───────────────────── @@ -494,3 +645,65 @@ def test_admin_geocode_missing_post_dry_run_endpoint_exists() -> None: data = resp.json() assert "status" in data assert "addresses_total" in data + + +# ── admin.geocode_missing (per-ID endpoint) city_hint (#2594 шаг 2/3) ──────── + + +@pytest.mark.asyncio +async def test_admin_geocode_missing_passes_city_hint() -> None: + """POST /admin/geocode-missing читает city из SELECT и передаёт как city_hint. + + Раньше endpoint читал только row["address"] и звал geocode(clean, db) без + города — голый тагильский адрес без города в тексте уходил в Екатеринбург. + """ + from app.api.v1 import admin as admin_module + + rows = [{"id": 55, "address": "ул. Победы, 30", "city": "Нижний Тагил"}] + + db = MagicMock() + select_result = MagicMock() + select_result.mappings.return_value.all.return_value = rows + update_result = MagicMock() + remaining_result = MagicMock() + remaining_result.scalar.return_value = 0 + db.execute.side_effect = [select_result, update_result, remaining_result] + + geo = GeocodeResult( + lat=57.910, + lon=59.985, + full_address="Нижний Тагил, ул. Победы, 30", + provider="nominatim", # type: ignore[arg-type] + confidence="exact", + ) + + with patch( + "app.api.v1.admin.geocode", + new_callable=AsyncMock, + return_value=geo, + ) as mock_geo: + result = await admin_module.geocode_missing(db, limit=100, target="listings") + + mock_geo.assert_called_once_with("ул. Победы, 30", db, city_hint="Нижний Тагил") + assert result["geocoded"] == 1 + assert result["skipped"] == 0 + + +@pytest.mark.asyncio +async def test_admin_geocode_missing_select_includes_city_column() -> None: + """SELECT в admin.geocode_missing содержит колонку city (#2594).""" + from app.api.v1 import admin as admin_module + + db = MagicMock() + select_result = MagicMock() + select_result.mappings.return_value.all.return_value = [] + remaining_result = MagicMock() + remaining_result.scalar.return_value = 0 + db.execute.side_effect = [select_result, remaining_result] + + with patch("app.api.v1.admin.geocode", new_callable=AsyncMock): + await admin_module.geocode_missing(db, limit=100, target="listings") + + first_call = db.execute.call_args_list[0] + sql_text = str(first_call[0][0]) + assert "SELECT id, address, city" in sql_text From b5976c0cc92ab1789ce348c187adb16d637970a4 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 1 Aug 2026 00:28:37 +0300 Subject: [PATCH 045/141] =?UTF-8?q?feat(auth):=20=D1=80=D0=BE=D0=BB=D0=B8?= =?UTF-8?q?=20=D0=B8=20=D1=82=D1=80=D1=91=D1=85=D0=B7=D0=BD=D0=B0=D1=87?= =?UTF-8?q?=D0=BD=D0=BE=D0=B5=20=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=B4=D0=BE=D1=81=D1=82=D1=83=D0=BF=D0=B0=20?= =?UTF-8?q?=D0=B2=20=D0=91=D0=94=20auth=20[PR-2a/6]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Схема под решения владельца от 2026-07-31 по эпику «единый вход». Python-кода нет, поведение прода не меняется — в БД auth пока никто не ходит. Развилка А закрыта в пользу ПОЛНОГО переезда: tradein_users (БД tradein) в итоге удаляется, auth.users становится единственным реестром людей. Значит role и manager_id переезжают сюда — это отменяет решение 001:15-19 («ролей здесь нет — сознательно»), что зафиксировано в шапке файла и переписанным COMMENT ON TABLE, а не оставлено расходиться молча. Развилка Б закрыта в пользу трёх состояний: is_active заменён на access_state (active / trial_expired / disabled). Булев флаг схлопывал «пускаем, но объясняем» и «не пускаем вовсе» в одно значение — trial-экран исчезал бы без падения тестов. Семантика зафиксирована в COMMENT: trial_expired при ВЕРНОМ пароле даёт 403 с отдельным кодом и НЕ выдаёт сессию, disabled — generic 401; неверный пароль в любом состоянии остаётся generic 401, то есть защита от перечисления логинов сохраняется. user2 («Брусника») → trial_expired. Колонки role/manager_id зеркалят м.192 побуквенно (CHECK ролей, иерархический CHECK, partial index, self-FK ON DELETE SET NULL), чтобы код «Меры» переехал на auth.users без правок. Добавлен users_manager_not_self_ck — на уровне БД самоназначение менеджером иначе проходит, а второй потребитель (Птица) валидации «Меры» не имеет. Гранты. INSERT выдан — без него переезд не состоится (создание сотрудника из «Команды»). DELETE НЕ выдан: потребителя нет (в team.py только POST и PATCH), а 002:22-33 отклоняла ровно такие гранты-на-будущее; появится хендлер — появится строка GRANT в той же миграции. Табличный UPDATE из 002:80 сужен до column-level: иначе auth_app молча получил бы право писать role и access_state, и ошибка в PATCH-эндпоинте превращалась бы в тихое повышение до админа или тихое снятие блокировки. role и manager_id в список не включены — их сегодня не пишет никто. Гранта на users_id_seq нет намеренно: для GENERATED ALWAYS AS IDENTITY PostgreSQL использует NextValueExpr → nextval_internal(check_permissions := false), ACL последовательности не проверяется. Утверждение 002:26-27 («идентичность требует nextval») фактически неверно; проверено обратным экспериментом — REVOKE, затем INSERT. Проверено исполнением на postgres:16, не по комментариям: - чистая сборка 001→002→003→004 — 13 строк, роли admin/manager×2/employee×10, user2 = trial_expired, is_active отсутствует, все 6 констрейнтов на месте; - повторный прогон 004 ×2 идемпотентен; - ручные прод-правки (user2 → active, user3 → manager) переживают повтор — backfill не затирает решения владельца; - периметр auth_app: INSERT users ✓, UPDATE access_state ✓, INSERT sessions ✓; UPDATE role ✗, UPDATE manager_id ✗, DELETE ✗, CREATE TABLE ✗; - CHECK'и ловят: admin с manager_id, self-manager, access_state вне списка, role вне списка, INSERT без role. Тест: 6 passed. Добавлена проверка запрета CREATE INDEX CONCURRENTLY — в связке с обязательной обёрткой BEGIN/COMMIT это комбинация, невыполнимая на проде (25001), а отдельной проверки на неё не было. --- backend/tests/sql/test_auth_sql_migrations.py | 22 + .../auth/004_users_roles_and_access_state.sql | 408 ++++++++++++++++++ 2 files changed, 430 insertions(+) create mode 100644 data/sql/auth/004_users_roles_and_access_state.sql diff --git a/backend/tests/sql/test_auth_sql_migrations.py b/backend/tests/sql/test_auth_sql_migrations.py index 492fcd84..3c02f7b9 100644 --- a/backend/tests/sql/test_auth_sql_migrations.py +++ b/backend/tests/sql/test_auth_sql_migrations.py @@ -120,6 +120,28 @@ def test_migrations_are_transactional() -> None: ), f"Миграции без обёртки BEGIN;/COMMIT;: {broken} (.claude/rules/sql.md → Structure)." +def test_no_concurrent_index_in_migrations() -> None: + """Ни одной CREATE/DROP INDEX CONCURRENTLY в data/sql/auth/*.sql. + + Red => миграция гарантированно падает на проде: CONCURRENTLY нельзя выполнять внутри + транзакционного блока (Postgres: 25001 «CREATE INDEX CONCURRENTLY cannot run inside a + transaction block»), а обёртка BEGIN;/COMMIT; здесь обязательна для всех файлов + (test_migrations_are_transactional). Две проверки по отдельности зелёные, а вместе + невыполнимые — поэтому запрет нужен явный: комбинация ловится только здесь. + Нужен CONCURRENTLY на большой таблице — это отдельный ручной прогон вне auto-apply, + а не файл в этом каталоге. + """ + hits: list[str] = [] + for path in _auth_sql_files(): + text = path.read_text(encoding="utf-8") + for line_no, line in enumerate(text.splitlines(), start=1): + if line.lstrip().startswith("--"): + continue # комментарий может объяснять запрет, не нарушая его + if re.search(r"\bCONCURRENTLY\b", line, re.IGNORECASE): + hits.append(f"{path.name}:{line_no}: {line.strip()}") + assert not hits, "CONCURRENTLY внутри BEGIN/COMMIT — упадёт на деплое: " + "; ".join(hits) + + def test_no_password_material_in_auth_sql() -> None: """Ни в data/sql/auth, ни в ops/db-bootstrap нет plaintext-паролей и bcrypt-хешей. diff --git a/data/sql/auth/004_users_roles_and_access_state.sql b/data/sql/auth/004_users_roles_and_access_state.sql new file mode 100644 index 00000000..5a83e356 --- /dev/null +++ b/data/sql/auth/004_users_roles_and_access_state.sql @@ -0,0 +1,408 @@ +-- auth/004: продуктовые роли + org-иерархия + трёхзначный access_state вместо булева is_active. +-- +-- ⚠️ ЭТА МИГРАЦИЯ СОЗНАТЕЛЬНО ОТМЕНЯЕТ РЕШЕНИЯ, ЗАПИСАННЫЕ В 001 И 002. +-- Это не рассинхрон и не ошибка автора: решение владельца продукта от 2026-07-31 принято +-- ПОСЛЕ того, как 001-003 были написаны и применены на проде. Применённую миграцию править +-- нельзя (повторно она не выполнится — трекинг в _schema_migrations), поэтому актуальная +-- правда живёт здесь, а в 001/002 остаются исторические формулировки: +-- * 001:15-19 «Здесь НЕТ колонки role — сознательно» → ОТМЕНЕНО, см. WHY-1; +-- * 002:22-33 «users — INSERT/DELETE НЕ выдаются, сознательно» → ОТМЕНЕНО ЧАСТИЧНО: INSERT +-- выдаётся (без него переезд не состоится), DELETE — по-прежнему нет, см. Часть 4; +-- * 002:26-27 «идентичность требует nextval» (грант USAGE на sequence) → ФАКТИЧЕСКИ +-- НЕВЕРНО, гранта не требуется; проверено, разбор в Части 4; +-- * 003:78-90 «открытая развилка про trial-экран, решается в PR-2/3» → ЗАКРЫТА, см. WHY-2. +-- Ориентир для читателя: актуальное состояние колонок описано COMMENT'ами в БД, они +-- переписаны здесь. Заголовок 001 — археология, а не спецификация. +-- +-- WHY-1 — продуктовые роли переезжают в `auth` (отмена решения 001): +-- 001 строилась на схеме «идентичность общая, полномочия у продукта»: auth.users знает, КТО +-- человек, tradein_users знает, ЧТО ему можно. Владелец выбрал другой сценарий — ПОЛНЫЙ +-- переезд: tradein_users (БД tradein) в итоге удаляется, auth.users остаётся единственным +-- реестром людей. Как только реестр один, роль перестаёт быть «знанием продукта»: без неё в +-- auth.users нельзя ни завести сотрудника, ни собрать раздел «Команда», ни ответить на вопрос +-- «чьи заявки видит этот менеджер» — а спросить больше не у кого, второй таблицы не будет. +-- Промежуточный вариант (человек в auth.users, его роль в tradein_users) — это два реестра, +-- которые кто-то обязан держать синхронными руками; их расхождение выглядит как «пользователь +-- есть, но он никто» и чинится только вручную по факту жалобы. +-- Цена решения ровно та, которую 001 и называла: новая роль в любом из продуктов = миграция +-- этой БД. Принято сознательно — это дешевле, чем двойной реестр людей. +-- +-- WHY-2 — три состояния доступа вместо булева is_active (закрытие развилки из 003): +-- Булев флаг схлопывает два РАЗНЫХ события в одно значение: «пробный период закончился» и +-- «доступ закрыт владельцем». Для пользователя разница видимая и она уже реализована в +-- сегодняшнем стеке: expired-аккаунт доходит до фронта и видит осмысленный экран «пробный +-- доступ закончился» (auth/roles.yaml → expired: paths: [] + deny "/**"; frontend +-- NoAccessScreen variant="trial"), а закрытый — просто не входит. Переключившись на единую +-- форму входа с булевым is_active, мы бы потеряли trial-экран МОЛЧА: состояние перестало бы +-- существовать, и ни один тест бы не упал. Ровно это и было записано как открытая развилка в +-- 003:78-90. Решение: состояний три. +-- active — доступ есть, обычный вход. +-- trial_expired — пароль ВЕРНЫЙ, но пробный период истёк: логин отвечает 403 с отдельным +-- кодом и текстом «пробный доступ закончился», сессия НЕ выдаётся. +-- disabled — жёсткая блокировка: generic 401, для пользователя неотличимо от «неверный +-- пароль». +-- Неверный пароль в ЛЮБОМ состоянии → generic 401. Иначе отдельный 403 превращается в оракул +-- существования логина: перебором можно перечислить аккаунты, не зная ни одного пароля. +-- Осмысленный ответ полагается только тому, кто пароль уже доказал. +-- text + CHECK, а не enum-тип: добавить четвёртое состояние — это ALTER одного констрейнта в +-- обычной миграции, тогда как ALTER TYPE ... ADD VALUE нельзя использовать в той же +-- транзакции, где значение добавлено (PG16), и enum тянет за собой отдельный тип в дампах. +-- Enum-типов в репозитории нет вовсе — не заводим первый ради трёх значений. +-- +-- WHAT: +-- 1. role — text NOT NULL + CHECK ('admin','manager','employee'). Тип, набор значений +-- и отсутствие DEFAULT — зеркало tradein_users.role (м.192:42). +-- 2. manager_id — self-FK ON DELETE SET NULL + иерархический CHECK + запрет self-manager + +-- partial index. Зеркало м.192:43/50-52/84-86, чтобы код «Меры» переехал на +-- auth.users без правок. +-- 3. access_state — text NOT NULL DEFAULT 'active' + CHECK на три значения; backfill из +-- is_active, точечный перевод user2 («Брусника») в trial_expired, затем +-- DROP COLUMN is_active. +-- 4. Гранты auth_app — INSERT на users (DELETE НЕ выдаётся) + сужение табличного UPDATE (002:80) до +-- column-level: новые колонки role/access_state не должны попасть под него +-- молча. +-- +-- IDEMPOTENCY: +-- ADD COLUMN IF NOT EXISTS / DROP COLUMN IF EXISTS / CREATE INDEX IF NOT EXISTS; констрейнты — +-- через DO-блок с проверкой pg_constraint (в PostgreSQL нет ADD CONSTRAINT IF NOT EXISTS для +-- CHECK/FK, паттерн из м.193:80-90); GRANT идемпотентен по определению; UPDATE-backfill'ы +-- отфильтрованы так, что второй прогон не находит строк (детали у каждого блока). +-- Проверка pg_constraint здесь фильтрует ДОПОЛНИТЕЛЬНО по conrelid (в отличие от м.193, где +-- только conname): имена констрейнтов уникальны в пределах таблицы, а не БД — одноимённый +-- констрейнт на соседней таблице заставил бы миграцию молча пропустить создание своего. +-- +-- ⚠️ ПОСЛЕ 004 ФАЙЛЫ 001 И 003 БОЛЬШЕ НЕ ПЕРЕИГРЫВАЮТСЯ ПООТДЕЛЬНОСТИ. +-- Обе ссылаются на колонку is_active, которой после этой миграции нет, и обе падают на уже +-- мигрированной БД с «column is_active does not exist»: +-- * 001 — на `COMMENT ON COLUMN users.is_active` (001:75). CREATE TABLE IF NOT EXISTS +-- пропускается, а COMMENT выполняется всегда — то есть ручной `psql -f 001` падает +-- РАНЬШЕ 003, вопреки интуиции «ломается только сид». +-- * 003 — на INSERT со списком колонок, включающим is_active (а если бы и не упал — +-- role NOT NULL без DEFAULT не даст вставить строку). +-- Это следствие требования «применённые миграции не правим», а не регресс. Поддерживаемый +-- сценарий восстановления — прогон каталога ЦЕЛИКОМ по возрастанию номеров (001→002→003→004) +-- на пустой БД; он рабочий, порядок гарантирован сортировкой имён в deploy.yml. Нужно добить +-- сид на живой БД — пиши новый файл 00N, не переигрывай 003. +-- +-- Dependencies: 001_identity_schema.sql (users), 002_auth_app_role.sql (роль auth_app — гранты +-- Части 4 её предполагают), 003_users_seed.sql (13 строк, которым backfill проставляет role). +-- Deploy order: применяется на прод авто-циклом deploy.yml по data/sql/auth/*.sql. Python-кода в +-- этом PR нет и поведение прода не меняется — в БД `auth` пока никто не ходит; код логина, +-- чтение role/access_state и удаление tradein_users — отдельные PR'ы ПОСЛЕ (см. +-- .claude/rules/sql.md «Migration order»: схема первой). + +BEGIN; + +-- --------------------------------------------------------------------------------------------- +-- Часть 1: role +-- --------------------------------------------------------------------------------------------- +-- DEFAULT сознательно НЕТ (как в м.192): роль — осознанное решение того, кто заводит человека. +-- С дефолтом INSERT, забывший указать роль, тихо создал бы работающий аккаунт с полномочиями +-- «по умолчанию»; без дефолта он падает на NOT NULL — это и есть нужное поведение. +-- Колонка добавляется NULLable, заполняется backfill'ом ниже и только потом получает NOT NULL: +-- прямой ADD COLUMN ... NOT NULL без DEFAULT упал бы на 13 уже существующих строках сида. +ALTER TABLE users ADD COLUMN IF NOT EXISTS role text; + +-- Backfill. Источник истины — м.193:101-113 (org-карта владельца продукта от 2026-07-30), +-- сверено построчно по файлу, не по памяти. Роли не являются секретом: они уже лежат в git +-- (м.193 и auth/roles.yaml) — запрет на git касается паролей и хешей, не полномочий. +-- `role IS NULL` в каждом WHERE даёт сразу две вещи: идемпотентность (второй прогон не находит +-- строк) и защиту от отката ручных решений — повышение сотрудника до manager, сделанное после +-- первого прогона, повторным применением файла не вернётся к seed-значению. +UPDATE users SET role = 'admin' WHERE role IS NULL AND username = 'admin'; +UPDATE users SET role = 'manager' WHERE role IS NULL AND username IN ('kopylov', 'praktika'); +-- Catch-all — ПОСЛЕДНИМ и именно employee: любая строка, попавшая в auth.users мимо сида +-- (ручная вставка, восстановление из дампа, будущий аккаунт), получает НАИМЕНЕЕ +-- привилегированную роль. Fail-safe: ошибка в этом месте не должна раздавать admin. +UPDATE users SET role = 'employee' WHERE role IS NULL; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'users_role_ck' AND conrelid = 'users'::regclass + ) THEN + ALTER TABLE users + ADD CONSTRAINT users_role_ck CHECK (role IN ('admin', 'manager', 'employee')); + END IF; +END $$; + +-- SET NOT NULL идемпотентен (на уже NOT NULL колонке — no-op) и стоит ПОСЛЕ backfill: на строке +-- с NULL он упал бы, а catch-all выше гарантирует, что таких строк не осталось. +ALTER TABLE users ALTER COLUMN role SET NOT NULL; + +-- --------------------------------------------------------------------------------------------- +-- Часть 2: manager_id (org-иерархия) +-- --------------------------------------------------------------------------------------------- +-- FK и CHECK объявлены ОТДЕЛЬНЫМИ шагами, а не inline в ADD COLUMN (как в м.192, где это было +-- частью CREATE TABLE IF NOT EXISTS — «всё или ничего»). Причина: `ADD COLUMN IF NOT EXISTS ... +-- REFERENCES ...` пропускает ВЕСЬ оператор, если колонка уже есть, — на БД, где manager_id +-- когда-то завели руками без FK, миграция отчиталась бы об успехе и оставила связь без +-- ссылочной целостности. Раздельные идемпотентные шаги такого состояния не допускают. +-- Имя FK задано явно тем же, которое сгенерировал бы PostgreSQL для inline-формы, — чтобы схема +-- на проде и схема из чистой сборки не различались именами констрейнтов. +ALTER TABLE users ADD COLUMN IF NOT EXISTS manager_id bigint; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'users_manager_id_fkey' AND conrelid = 'users'::regclass + ) THEN + -- ON DELETE SET NULL (зеркало м.192:43): удаление менеджера не должно каскадом сносить + -- его сотрудников — они остаются в реестре без привязки, и это чинится назначением + -- нового менеджера, а не восстановлением строк из бэкапа. + ALTER TABLE users + ADD CONSTRAINT users_manager_id_fkey + FOREIGN KEY (manager_id) REFERENCES users(id) ON DELETE SET NULL; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'users_role_manager_hierarchy_ck' AND conrelid = 'users'::regclass + ) THEN + ALTER TABLE users + ADD CONSTRAINT users_role_manager_hierarchy_ck CHECK ( + role NOT IN ('admin', 'manager') OR manager_id IS NULL + ); + END IF; +END $$; + +-- Запрет self-manager. users_role_manager_hierarchy_ck выше держит только admin/manager; для +-- employee self-FK допускает ссылку строки на саму себя, и `UPDATE users SET manager_id = id` +-- прошёл бы. Через сегодняшний API это недостижимо (team.py:398-406 требует role='manager' у +-- цели, PATCH manager_id вообще не меняет), но 004 делает auth.users ЕДИНСТВЕННЫМ реестром — в +-- него начнёт писать и «Птица», у которой этой валидации нет, а любой будущий WITH RECURSIVE по +-- manager_id на такой строке зациклится. Строчный CHECK ловит самый вероятный случай (опечатка +-- или копипаста собственного id) и стоит ноль. +-- Чего этот констрейнт НЕ ловит: взаимную пару employee↔employee (A.manager_id=B, +-- B.manager_id=A) и ссылку на строку с role<>'manager' — оба требуют чтения ДРУГОЙ строки, +-- строчным CHECK'ом это не выражается (нужен триггер или FK на несуществующий уникальный ключ +-- (id, role)). Инвариант зафиксирован COMMENT'ом к колонке — он живёт в приложении. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'users_manager_not_self_ck' AND conrelid = 'users'::regclass + ) THEN + ALTER TABLE users + ADD CONSTRAINT users_manager_not_self_ck CHECK ( + manager_id IS NULL OR manager_id <> id + ); + END IF; +END $$; + +-- Partial index (зеркало м.192:84-86): у admin/manager и у свободных слотов manager_id = NULL, +-- и эти строки никогда не участвуют в выборке «сотрудники этого менеджера». Индексировать NULL'ы +-- значит платить за большую часть таблицы, которая по этому пути не читается. +CREATE INDEX IF NOT EXISTS users_manager_id_idx + ON users (manager_id) + WHERE manager_id IS NOT NULL; + +-- --------------------------------------------------------------------------------------------- +-- Часть 3: access_state вместо is_active +-- --------------------------------------------------------------------------------------------- +-- DEFAULT 'active' здесь, в отличие от role, уместен: «доступ есть» — это состояние, в котором +-- заводят любого нового сотрудника, и молчаливый дефолт не расширяет ничьих полномочий. +ALTER TABLE users ADD COLUMN IF NOT EXISTS access_state text NOT NULL DEFAULT 'active'; + +-- CHECK ставится СРАЗУ после колонки, до backfill'а: тогда он проверяет и сам backfill — +-- опечатка в значении ниже уронит миграцию, а не просочится в данные. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'users_access_state_ck' AND conrelid = 'users'::regclass + ) THEN + ALTER TABLE users + ADD CONSTRAINT users_access_state_ck CHECK ( + access_state IN ('active', 'trial_expired', 'disabled') + ); + END IF; +END $$; + +-- Backfill из is_active — под проверкой существования колонки, потому что в конце этого же +-- блока она удаляется: повторный прогон файла обязан пройти без ошибок, а прямое обращение к +-- несуществующей колонке — ошибка парсинга, не «0 строк». +-- EXECUTE (динамический SQL), а не обычные UPDATE внутри IF: обычные операторы уцелели бы лишь +-- благодаря ленивой подготовке операторов в PL/pgSQL (невыполненная ветка не разбирается). Это +-- рабочая, но недокументированная в самом файле деталь реализации; EXECUTE делает независимость +-- от отсутствующей колонки явной для читателя. +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM pg_attribute + WHERE attrelid = 'users'::regclass + AND attname = 'is_active' + AND NOT attisdropped + ) THEN + -- Механическое отображение старой семантики: булев «доступ закрыт» = жёсткая блокировка. + -- `access_state = 'active'` в WHERE — не мёртвое условие: оно фиксирует, что переписывается + -- только значение, доставшееся из DEFAULT, и никогда — уже осмысленно проставленное. + EXECUTE $q$ + UPDATE users + SET access_state = 'disabled' + WHERE is_active = false + AND access_state = 'active' + $q$; + + -- Точечно: user2 («Брусника», доступ закрыт владельцем 2026-07-30) — не disabled, а + -- trial_expired. Основание: в auth/roles.yaml у него role=expired, то есть исторически он + -- видит trial-экран, а не отказ входа; решение владельца от 2026-07-31 эту семантику + -- сохраняет. + -- Условие `access_state = 'disabled'` — это защита от затирания ручного решения: + -- переводится РОВНО то значение, которое механическая ветка выше только что и вывела. + -- Если к моменту повторного прогона владелец уже открыл «Бруснике» доступ (active) или + -- перевёл её в другое состояние, WHERE не сматчится и решение человека переживёт миграцию. + -- Безусловный UPDATE по username возвращал бы аккаунт в trial_expired после каждого + -- прогона, и разбор «почему у клиента снова экран пробного периода» стоил бы часов при + -- нулевой пользе. Хардкод одного username оправдан: это разовая фиксация конкретного + -- исторического факта, а не правило — общего признака «пробный доступ» в схеме до сих пор + -- не было, выводить его задним числом не из чего. + EXECUTE $q$ + UPDATE users + SET access_state = 'trial_expired' + WHERE username = 'user2' + AND access_state = 'disabled' + $q$; + END IF; +END $$; + +-- Снятие is_active. Деструктивный шаг — но именно он и есть смысл решения: оставить обе колонки +-- значило бы два источника правды о доступе, расходящихся при первой же правке через UI. +-- Безопасно: на момент этого PR БД `auth` не читается ни одним работающим кодом (Caddy basic_auth +-- + tradein_users по-прежнему обслуживают прод), а данные колонки полностью перенесены выше. +-- DROP обязан жить именно здесь, а не в 003: 003 применён на проде и правке не подлежит. +ALTER TABLE users DROP COLUMN IF EXISTS is_active; + +-- --------------------------------------------------------------------------------------------- +-- Часть 4: гранты auth_app под режим единственного реестра (отмена решения 002:22-33) +-- + сужение унаследованного табличного UPDATE до column-level +-- --------------------------------------------------------------------------------------------- +-- 002 намеренно не выдавала INSERT/DELETE на users, и её аргумент был верным для своего момента: +-- в PR-1 не существовало ни кода, ни UI создания аккаунтов, а грант «на будущее» — это открытая +-- операция, которой никто не пользуется и которую никто не тестирует. Аргумент перестаёт +-- применяться ровно сейчас: после полного переезда auth.users — единственный реестр людей, а +-- раздел «Команда» «Меры» (tradein-mvp/backend/app/api/v1/team.py: POST /employees заводит +-- сотрудника, PATCH правит) — единственный интерфейс, которым сотрудника заводят и убирают. +-- Без INSERT переезд физически не состоится: сегодняшний INSERT идёт в tradein_users, а её не +-- станет. +-- DELETE здесь НЕ выдаётся, хотя первая редакция этой миграции его содержала. Причина отказа: +-- DELETE-эндпоинта в team.py нет (только POST /employees и PATCH — проверено), то есть потребителя +-- у права нет ни одного, а 002:22-33 отклоняла ровно такие гранты-на-будущее. Симметричный +-- контраргумент («снять неиспользуемое право дешевле, чем добавлять его в момент релиза») здесь не +-- перевешивает: DELETE по users каскадит на sessions (001:94), то есть цена ошибки в коде выше +-- обычной, а добавить строку GRANT в миграцию того PR, где появится DELETE-хендлер, стоит ровно +-- столько же. Право выдаётся вместе с кодом, который им пользуется, — не раньше. +-- DELETE ≠ закрытие доступа. Закрытие — это access_state ('disabled' / 'trial_expired'): +-- обратимо, сохраняет строку и историю. Именно оно, а не удаление строки, закрывает сегодняшний +-- сценарий «Команды»; удаление понадобилось бы только чтобы убрать ошибочно заведённый слот. +GRANT INSERT ON users TO auth_app; + +-- Гранта на последовательность users_id_seq здесь НЕТ — и это не забывчивость. +-- 002:26-27 записала как факт, что «идентичность требует nextval», то есть INSERT из auth_app +-- якобы упадёт с «permission denied for sequence» без USAGE на последовательности. Для +-- `GENERATED ALWAYS AS IDENTITY` (001:53) это неверно: PostgreSQL подставляет не вызов +-- nextval('...'), а узел NextValueExpr, который дёргает nextval_internal(seqid, +-- check_permissions := false) — ACL последовательности не проверяется вовсе. Это документированное +-- отличие identity от serial, и оно проверено живьём на postgres:16, а не выведено из +-- документации: после `REVOKE ALL ON SEQUENCE users_id_seq FROM app` INSERT в identity-таблицу +-- прошёл и вернул id, тогда как в контрольной таблице с bigserial тот же INSERT в тех же +-- условиях упал ровно с «permission denied for sequence». +-- Отсюда два следствия. Первое: грант не нужен — он выдал бы auth_app право звать +-- nextval('users_id_seq') напрямую (жечь идентификаторы) и читать last_value (число заведённых +-- аккаунтов), при том что ни один путь кода этого не делает; это прямо противоречило бы +-- REVOKE ALL ON ALL SEQUENCES из 002:73. Второе: «живая проверка» вида «auth_app сделал INSERT, +-- значит грант рабочий» ничего не доказывает — тот же INSERT проходит и после REVOKE, поэтому +-- проверять надо обратное (REVOKE, затем INSERT). +-- Если users.id когда-нибудь переведут на обычный DEFAULT nextval(...) — грант станет +-- обязательным, и его придётся добавить той же миграцией, что меняет колонку. + +-- Сужение UPDATE до column-level. 002:80 выдала ТАБЛИЧНЫЙ `GRANT SELECT, UPDATE ON users`, +-- обосновав его узко («смена пароля самим пользователем и проставление хеша админом»), — но +-- табличный UPDATE автоматически распространяется на любые колонки, добавленные позже. Не сузь +-- мы его здесь, auth_app молча получил бы право писать role и access_state, и периметр 002 +-- расширился бы ровно тем, что 004 добавила, без единой строки GRANT. +-- Почему это важно именно для этих двух колонок: любая SQL-инъекция или логическая ошибка в +-- UPDATE-эндпоинте (сегодня такой ровно один — team.py PATCH /employees, COALESCE-список полей +-- по WHERE id = :id) из «испортил профиль» превращалась бы в `SET role='admin' WHERE id=<свой>` +-- или `SET access_state='active' WHERE username='user2'` — тихое повышение до админа и тихое +-- снятие блокировки, без смены пароля, то есть без внешнего признака компрометации. Это ровно +-- тот класс, ради которого 002 и заводила отдельную роль (002:5-6). +-- role в список НЕ включена сознательно: сегодня её не пишет никто (team.py POST вставляет +-- литерал 'employee', PATCH в SET-списке role/manager_id не имеет вовсе). Появится админский +-- путь смены роли — добавится одной строкой новой миграции; это дешевле, чем держать открытым +-- право на эскалацию привилегий «на всякий случай». +-- manager_id по той же причине не включён: назначение сотрудника менеджеру сегодня делается +-- только при создании (INSERT), а не UPDATE'ом. +-- access_state включён — блокировка/разблокировка через «Команду» (сегодняшний +-- `is_active = COALESCE(...)` в PATCH) переезжает именно в эту колонку. +-- REVOKE перед GRANT обязателен и идемпотентен: REVOKE табличной привилегии снимает и +-- колоночные, поэтому повторный прогон файла даёт то же состояние (внутри одной транзакции, +-- то есть без окна «прав нет» для работающего приложения). +REVOKE UPDATE ON users FROM auth_app; +GRANT UPDATE (password_hash, display_name, org_name, email, access_state, updated_at) + ON users TO auth_app; + +-- --------------------------------------------------------------------------------------------- +-- COMMENT'ы: переписываем то, что 004 сделала неверным в 001 +-- --------------------------------------------------------------------------------------------- +COMMENT ON TABLE users IS + 'Единый реестр людей для «Меры» (trade-in) и «Птицы» (Site Finder): идентичность И ' + 'полномочия. Решение владельца продукта 2026-07-31 — ПОЛНЫЙ переезд: tradein_users ' + 'удаляется, второго реестра не будет. Прежняя формулировка («роли остаются в продуктовых ' + 'БД», 001) отменена миграцией 004 — см. её заголовок.'; + +COMMENT ON COLUMN users.role IS + 'Полномочия: admin | manager | employee. Зеркало tradein_users.role (tradein м.192) — код ' + '«Меры» должен переехать на эту таблицу без правок в проверках роли. DEFAULT намеренно нет: ' + 'роль выбирает тот, кто заводит человека; INSERT без роли обязан падать, а не создавать ' + 'аккаунт с полномочиями «по умолчанию».'; + +COMMENT ON COLUMN users.manager_id IS + 'Self-FK на users(id), ON DELETE SET NULL: удаление менеджера оставляет его сотрудников в ' + 'реестре без привязки, а не сносит их каскадом. NULL для admin/manager (top-level роли, ' + 'констрейнт users_role_manager_hierarchy_ck) и для employee без организации. ' + 'ИНВАРИАНТЫ, КОТОРЫЕ БД НЕ ПРОВЕРЯЕТ (обязан держать КАЖДЫЙ пишущий сюда код — реестр общий ' + 'для «Меры» и «Птицы»): цель ссылки обязана иметь role = ''manager''; циклы (A→B, B→A) ' + 'запрещены — рекурсивный обход иерархии на них зациклится. Схемой ловится только ссылка ' + 'строки на саму себя (users_manager_not_self_ck): остальное требует чтения другой строки и ' + 'строчным CHECK не выражается. Отсутствие проверки в БД — не разрешение.'; + +COMMENT ON COLUMN users.access_state IS + 'Состояние доступа, три значения — заменило булев is_active (миграция 004). ' + 'active: вход разрешён. ' + 'trial_expired: пробный период истёк — при ВЕРНОМ пароле логин отвечает 403 с отдельным ' + 'кодом и текстом «пробный доступ закончился», сессия не выдаётся (аккаунт видит осмысленный ' + 'экран, а не «неверный пароль»). ' + 'disabled: доступ закрыт — generic 401, неотличимо от неверного пароля. ' + 'Неверный пароль в любом состоянии → generic 401: иначе отдельный ответ для trial_expired ' + 'стал бы оракулом существования логина. Булев флаг схлопывал бы trial_expired и disabled в ' + 'одно значение, и trial-экран исчез бы молча. ' + 'ИНВАРИАНТ ДЛЯ API (в БД не выразим): перевод ПОСЛЕДНЕГО active-админа в любое другое ' + 'состояние обязан отклоняться на уровне приложения. Констрейнт с role не связан, ' + 'UPDATE ... SET access_state = ''disabled'' WHERE username = ''admin'' в БД проходит, а после ' + 'перехода на единую форму входа это self-lockout: не остаётся аккаунта, способного открыть ' + 'доступ обратно через UI, восстановление — только psql на прод-БД. Сегодня путь закрыт тем, ' + 'что «Команда» не отдаёт строки с role = ''admin'' никому (team.py); любой новый админский ' + 'экран, пишущий access_state, обязан проверку восстановить.'; + +COMMENT ON CONSTRAINT users_role_manager_hierarchy_ck ON users IS + 'admin/manager обязаны иметь manager_id IS NULL — это top-level роли, «начальника» у них в ' + 'этой модели нет (зеркало tradein м.192). Для employee manager_id любой, включая NULL ' + '(свободный слот без организации допустим).'; + +COMMENT ON CONSTRAINT users_manager_not_self_ck ON users IS + 'Строка не может быть собственным менеджером (manager_id <> id). Ловит опечатку/копипасту ' + 'id при ручной правке и у второго потребителя реестра («Птица»), где валидации «Команды» ' + 'нет. Взаимные пары и ссылку на не-менеджера строчный CHECK не ловит — см. COMMENT к ' + 'users.manager_id.'; + +COMMENT ON CONSTRAINT users_access_state_ck ON users IS + 'Фиксирует ровно три состояния доступа. Расширение — новой миграцией с ALTER этого ' + 'констрейнта; тип text + CHECK выбран вместо enum именно ради дешёвого расширения.'; + +COMMIT; From bd472b9b57ab445f9639f27d34bd37f1f2e5c220 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 1 Aug 2026 01:09:50 +0300 Subject: [PATCH 046/141] =?UTF-8?q?fix(tradein/geocode):=20=D0=B3=D0=B5?= =?UTF-8?q?=D0=BE=D0=BA=D0=BE=D0=B4=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=B0=D0=BA?= =?UTF-8?q?=D1=82=D0=B8=D0=B2=D0=BD=D1=8B=D0=B5=20=D0=BE=D0=B1=D1=8A=D1=8F?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20(#2604)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ночная очередь geocode_missing_listings была на 98.5% забита is_active=false объявлениями чужих регионов (Новосибирск/Казань/Челябинск/Тюмень/Ижевск...) без улицы и дома. ORDER BY listings_count DESC ставил такой мусор в начало очереди (у 'Новосибирская обл.,Новосибирск' — 214 listings, у реального адреса — 1-2), поэтому Nominatim-бюджет (1 req/sec) съедался мусором и до активных адресов дело не доходило: 8 ночных прогонов подряд saved=0. Добавлен AND is_active в SELECT. UPDATE (lat/lon и оба tried_at) намеренно оставлены без этого фильтра — координаты и backoff-метка принадлежат паре (address, city) как тексту, не конкретному listing; is_active=false дубликат той же пары и так навсегда исключён из будущих SELECT, а unfiltered UPDATE проставляет ему ответ бесплатно (Nominatim-вызов уже оплачен активным листингом) на случай реактивации. Refs #2604 --- .../backend/app/tasks/geocode_missing.py | 58 ++++++++- .../tests/tasks/test_geocode_missing.py | 110 ++++++++++++++++++ 2 files changed, 165 insertions(+), 3 deletions(-) diff --git a/tradein-mvp/backend/app/tasks/geocode_missing.py b/tradein-mvp/backend/app/tasks/geocode_missing.py index 5bad11f3..bcb1d18d 100644 --- a/tradein-mvp/backend/app/tasks/geocode_missing.py +++ b/tradein-mvp/backend/app/tasks/geocode_missing.py @@ -12,6 +12,14 @@ Pattern: dedup по паре (address, city) — 1 уникальная пара не схлопываться в один geocode-вызов и один UPDATE по тексту адреса). Rate limit: Nominatim 1 req/sec (#2593: Yandex Geocoder tier удалён из geocoder). +SELECT фильтрует `is_active` (#2604 п.1): на проде очередь была на 98.5% забита +мёртвыми объявлениями чужих регионов (Новосибирск/Казань/Челябинск/…) без is_active — +`ORDER BY listings_count DESC` ставил их В НАЧАЛО (у мусорного адреса вида +«Новосибирская обл.,Новосибирск» — сотни listings, у реального адреса — 1-2), поэтому +весь batch-бюджет (Nominatim 1 req/sec) съедался мусором и до настоящих адресов дело +не доходило (8 ночных прогонов подряд: saved=0). UPDATE после успешного/неуспешного +geocode НЕ фильтрует is_active — см. комментарии у соответствующих UPDATE ниже. + Отличие от /admin/geocode-missing (per-ID): - Этот модуль группирует по (address, city) → меньше API calls (dedup), но не схлопывает разные города с одинаковым текстом адреса. @@ -59,11 +67,14 @@ async def geocode_missing_listings( """Geocode listings с NULL coords (любой source). Steps: - 1. SELECT address, city FROM listings WHERE lat IS NULL AND address IS NOT NULL - GROUP BY address, city ORDER BY COUNT(*) DESC LIMIT batch_size + 1. SELECT address, city FROM listings WHERE lat IS NULL AND is_active + AND address IS NOT NULL GROUP BY address, city ORDER BY COUNT(*) DESC + LIMIT batch_size (приоритет парам address+city с большим числом listings — больший ROI per geocode call; группировка по паре, НЕ только по address — #2594 шаг 2/3: - один и тот же текст адреса в разных городах — разные записи) + один и тот же текст адреса в разных городах — разные записи. `is_active` — + #2604 п.1: не тратим Nominatim-бюджет на мёртвые объявления, которые никогда + не попадут в выдачу пользователю) 2. Для каждой пары (address, city): - geocode(address, db, city_hint=city) — auto-cache (hit или miss) @@ -98,6 +109,14 @@ async def geocode_missing_listings( # переезд адреса в кэше или смена провайдера), либо tried_at IS NULL (ещё не пробовали). # Это делает функцию loop-safe: при вызове несколько раз в одном прогоне # failed-пары не переотбираются бесконечно. + # + # AND is_active (#2604 п.1) — очередь без этого фильтра на 98.5% состояла из + # is_active=false объявлений чужих регионов (Новосибирск/Казань/Челябинск/…), + # а ORDER BY listings_count DESC ставил самый мусорный адрес («Новосибирская + # обл.,Новосибирск», сотни listings) В НАЧАЛО — весь batch съедался мусором, + # который пользователь никогда не увидит (is_active=false), 8 ночных прогонов + # подряд saved=0. Активные объявления с валидным адресом почти всегда попадают + # в topN только теперь, когда мусор не конкурирует за место в LIMIT. rows = ( db.execute( text( @@ -105,6 +124,7 @@ async def geocode_missing_listings( SELECT address, city, COUNT(*) AS listings_count FROM listings WHERE lat IS NULL + AND is_active AND address IS NOT NULL AND length(trim(address)) >= 5 AND (geocode_tried_at IS NULL @@ -150,6 +170,14 @@ async def geocode_missing_listings( # IS NOT DISTINCT FROM — city=NULL это отдельная группа, обычное # `=` не поймает NULL-город и не должно задеть другой город с тем # же текстом адреса. + # Намеренно БЕЗ `AND is_active` (#2604 п.2): tried_at — backoff-метка + # для (address, city) КАК ТЕКСТА, а не для конкретного listing. + # is_active=false дубликат этой пары и так никогда не будет выбран + # SELECT'ом заново (is_active=false исключён там навсегда) — фильтр + # здесь был бы no-op для неактивных строк. Единственный случай когда + # это имеет значение — если строка позже реактивируется (is_active + # → true): тогда tried_at уже стоит и backoff корректно защищает от + # немедленного повторного запроса того же заведомо неудачного адреса. db.execute( text( "UPDATE listings SET geocode_tried_at = NOW()" @@ -171,6 +199,11 @@ async def geocode_missing_listings( ) if not dry_run: # Пометить tried_at — geocoder не нашёл адрес, backoff 7 дней. + # Намеренно БЕЗ `AND is_active` (#2604 п.2) — то же обоснование, что + # и в except-ветке выше: backoff привязан к тексту (address, city), + # не к конкретному listing, is_active=false строка и так не выбирается + # SELECT'ом заново; при реактивации backoff корректно защитит от + # немедленного повтора заведомо неудачного запроса. db.execute( text( "UPDATE listings SET geocode_tried_at = NOW()" @@ -211,6 +244,18 @@ async def geocode_missing_listings( # city IS NOT DISTINCT FROM :city — обновляем ТОЛЬКО пару (address, city), из # которой был geocode-запрос; иначе тот же текст адреса в другом городе # (city IS NULL или другой явный город) перезаписался бы чужими координатами. + # + # Намеренно БЕЗ `AND is_active` (#2604 п.1): координаты — свойство физического + # адреса, а не свойство конкретного объявления. Если у этой же пары + # (address, city) есть is_active=false дубликат с lat IS NULL, он получит те же + # координаты бесплатно — Nominatim-вызов уже оплачен геокодом активного + # листинга, доп. запроса не будет. SELECT выше и так навсегда исключает + # is_active=false строки из очереди — без этого UPDATE такой дубликат остался + # бы с NULL lat/lon НАВСЕГДА (переезд в EKB-only локальные реестры/analytics по + # координатам сломан для него), хотя ответ уже есть в руках. Единственный + # довод «за» фильтр — консистентность с SELECT — не перевешивает: это не + # ошибка данных (координаты адреса объективны и не зависят от активности), + # а чистый выигрыш (та же строка при реактивации уже готова, доп. cost = 0). update_result = db.execute( text( """ @@ -327,6 +372,13 @@ async def run_geocode_missing_listings( ) break if res.addresses_total < batch_size: + # #2604 п.3: с is_active-фильтром в SELECT очередь резко уже (была + # 14294 строк/98.5% мёртвых, стало ~220 активных → десятки уникальных + # пар address+city после GROUP BY) — этот дренаж почти всегда сработает + # уже на первой итерации (addresses_total < default batch_size=200), и + # это ПРАВИЛЬНОЕ поведение: разгребли всё что было, ждём следующего + # прогона. Никакого деления тут нет (только сравнение int), пустая + # очередь (addresses_total=0) ловится веткой выше, а не этой. logger.info( "run_geocode_missing_listings: run_id=%d — дренаж " "(addresses_total=%d < batch_size=%d), завершаем", diff --git a/tradein-mvp/backend/tests/tasks/test_geocode_missing.py b/tradein-mvp/backend/tests/tasks/test_geocode_missing.py index ac3c6ede..c14cd8e1 100644 --- a/tradein-mvp/backend/tests/tasks/test_geocode_missing.py +++ b/tradein-mvp/backend/tests/tasks/test_geocode_missing.py @@ -342,6 +342,31 @@ async def test_geocode_missing_recent_tried_at_excluded_via_where() -> None: assert "7 days" in sql_text +@pytest.mark.asyncio +async def test_geocode_missing_select_filters_is_active() -> None: + """SELECT содержит `AND is_active` (#2604 п.1). + + На проде очередь без этого фильтра была на 98.5% забита is_active=false + объявлениями чужих регионов (Новосибирск/Казань/Челябинск/…) без улицы и дома; + `ORDER BY listings_count DESC` ставил самый мусорный адрес («Новосибирская + обл.,Новосибирск», 214 listings) В НАЧАЛО очереди — весь Nominatim-бюджет + (1 req/sec) съедался мусором, до реальных активных адресов дело не доходило + (8 ночных прогонов подряд: saved=0). Falsification-проба: на коде ДО фикса + `"AND is_active" in sql_text` ложно, тест падает; после фикса проходит. + """ + db = MagicMock() + select_result = MagicMock() + select_result.mappings.return_value.all.return_value = [] + db.execute.return_value = select_result + + with patch("app.tasks.geocode_missing.geocode", new_callable=AsyncMock): + await geocode_missing_listings(db, batch_size=10) + + first_call = db.execute.call_args_list[0] + sql_text = str(first_call[0][0]) + assert "AND is_active" in sql_text + + @pytest.mark.asyncio async def test_run_geocode_missing_listings_terminates_on_drained() -> None: """run_geocode_missing_listings завершается когда addresses_total == 0 (ничего pending).""" @@ -560,6 +585,91 @@ async def test_geocode_missing_failed_pair_tried_at_update_scoped_to_city() -> N assert params["city"] == "Нижний Тагил" +# ── #2604 п.1/п.2: UPDATE decisions — locked in by test, not just comment ──── + + +@pytest.mark.asyncio +async def test_geocode_missing_success_update_not_filtered_by_is_active() -> None: + """Decision #2604 п.1 (UPDATE lat/lon): намеренно БЕЗ `is_active` в WHERE. + + Координаты — свойство физического адреса (address, city), не свойство + конкретного listing. is_active=false дубликат ЭТОЙ ЖЕ пары никогда не будет + независимо отобран SELECT'ом (он навсегда исключён оттуда) — без unfiltered + UPDATE такой дубликат остался бы с NULL lat/lon навсегда, хотя ответ уже + получен и оплачен Nominatim-вызовом активного листинга. + """ + rows = [{"address": "ул. Тестовая, 1", "city": "Екатеринбург", "listings_count": 2}] + db = MagicMock() + select_result = MagicMock() + select_result.mappings.return_value.all.return_value = rows + update_result = MagicMock() + update_result.rowcount = 2 + db.execute.side_effect = [select_result, update_result] + + with patch( + "app.tasks.geocode_missing.geocode", + new_callable=AsyncMock, + return_value=_make_geocode_result("nominatim"), + ): + await geocode_missing_listings(db, batch_size=200) + + update_call = db.execute.call_args_list[1] + sql = str(update_call.args[0]) + assert "is_active" not in sql + + +@pytest.mark.asyncio +async def test_geocode_missing_notfound_tried_at_update_not_filtered_by_is_active() -> None: + """Decision #2604 п.2 (geo is None → tried_at UPDATE): намеренно БЕЗ `is_active`. + + tried_at — backoff-метка для (address, city) КАК ТЕКСТА, не для конкретного + listing; is_active=false дубликат и так никогда не переотбирается SELECT'ом. + Единственный сценарий где это важно — реактивация (is_active → true) той же + строки: backoff уже стоит и корректно защищает от немедленного повтора + заведомо неудачного адреса. + """ + rows = [{"address": "несуществующий адрес", "city": None, "listings_count": 1}] + db = MagicMock() + select_result = MagicMock() + select_result.mappings.return_value.all.return_value = rows + tried_at_result = MagicMock() + db.execute.side_effect = [select_result, tried_at_result] + + with patch( + "app.tasks.geocode_missing.geocode", + new_callable=AsyncMock, + return_value=None, + ): + await geocode_missing_listings(db, batch_size=200) + + update_call = db.execute.call_args_list[1] + sql = str(update_call.args[0]) + assert "is_active" not in sql + + +@pytest.mark.asyncio +async def test_geocode_missing_exception_tried_at_update_not_filtered_by_is_active() -> None: + """Decision #2604 п.2 (geocode() raises → tried_at UPDATE): та же логика, что и + в NOT-FOUND ветке выше — намеренно БЕЗ `is_active`, зафиксировано тестом.""" + rows = [{"address": "ул. Битая, 99", "city": None, "listings_count": 1}] + db = MagicMock() + select_result = MagicMock() + select_result.mappings.return_value.all.return_value = rows + tried_at_result = MagicMock() + db.execute.side_effect = [select_result, tried_at_result] + + with patch( + "app.tasks.geocode_missing.geocode", + new_callable=AsyncMock, + side_effect=RuntimeError("timeout"), + ): + await geocode_missing_listings(db, batch_size=200) + + update_call = db.execute.call_args_list[1] + sql = str(update_call.args[0]) + assert "is_active" not in sql + + # ── Integration-style: estimator Avito exclusion removed ───────────────────── From ad76fe844a7413832b5669578588684345b29e0f Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 1 Aug 2026 01:46:03 +0300 Subject: [PATCH 047/141] =?UTF-8?q?fix(tradein/geocode):=20=D0=B1=D1=8D?= =?UTF-8?q?=D0=BA=D1=84=D0=B8=D0=BB=D0=BB=20listings.city=20=D0=B8=D0=B7?= =?UTF-8?q?=20=D1=81=D0=BB=D0=B0=D0=B3=D0=B0=20=D0=B3=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=B0=20=D0=B2=20URL=20=D0=90=D0=B2=D0=B8=D1=82=D0=BE=20?= =?UTF-8?q?(#2594)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../197_backfill_listings_city_from_url.sql | 92 ++++++++++ ...ion_197_backfill_listings_city_from_url.py | 158 ++++++++++++++++++ 2 files changed, 250 insertions(+) create mode 100644 tradein-mvp/backend/data/sql/197_backfill_listings_city_from_url.sql create mode 100644 tradein-mvp/backend/tests/test_migration_197_backfill_listings_city_from_url.py diff --git a/tradein-mvp/backend/data/sql/197_backfill_listings_city_from_url.sql b/tradein-mvp/backend/data/sql/197_backfill_listings_city_from_url.sql new file mode 100644 index 00000000..490b4177 --- /dev/null +++ b/tradein-mvp/backend/data/sql/197_backfill_listings_city_from_url.sql @@ -0,0 +1,92 @@ +-- 197_backfill_listings_city_from_url.sql +-- Issue #2594 шаг 3 — бэкфилл listings.city (миграция 196) для УЖЕ накопленных +-- Avito-объявлений из слага города в source_url. +-- +-- ПРОБЛЕМА: 196 добавила колонку listings.city и write-path проставляет её +-- ТОЛЬКО для новых листингов (см. заголовок 196). Накопленные ранее строки +-- остались с city IS NULL. Для Avito-объявлений вне ЕКБ (city-sweep областных +-- городов) адрес в тексте часто без города («пр-т Вагоностроителей,18» вместо +-- «Нижний Тагил, пр-т Вагоностроителей,18»), а у части улиц есть тёзки в +-- Екатеринбурге (Хохрякова, Калинина — центральные ЕКБ-улицы). Без явного +-- city такой адрес при геокодировании (app/tasks/geocode_missing.py, +-- app/services/geocoder.py city_hint) считается «город не назван» → рискует +-- получить координаты Екатеринбурга (тот же баг-класс, что и #2594 основной). +-- Ночной прогон geocode_missing_listings 2026-08-01 заберёт в очередь 148 +-- активных объявлений Нижнего Тагила без city — этот бэкфилл проставляет им +-- city ДО того, как очередь начнёт их обрабатывать. +-- +-- ИСТОЧНИК: первый сегмент пути URL после хоста — +-- https://www.avito.ru/nizhniy_tagil/kvartiry/... -> 'nizhniy_tagil' +-- извлекается regex `substring(source_url from 'avito\.ru/([^/]+)/')`. +-- Маппинг ТОЛЬКО наших шести городов Свердловской обл. (region 66); слаги и +-- человекочитаемые названия сверены с CITY_DISPLAY_NAMES/CITY_LOCATIONS +-- (tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py) +-- — значения побайтно совпадают с тем, что теперь пишет скрапер (go-forward +-- write-path 196), чтобы не расщепить один город на две разные метки. +-- +-- Проверено на проде (SELECT, read-only) перед миграцией: +-- avito_slug наш город city IS NULL (Avito) +-- 'ekaterinburg' -> 'Екатеринбург' 26770 +-- 'nizhniy_tagil' -> 'Нижний Тагил' 551 (148 сегодня в очереди геокода) +-- 'kamensk-uralskiy' -> 'Каменск-Уральский' 244 +-- 'pervouralsk' -> 'Первоуральск' 95 +-- 'verhnyaya_pyshma' -> 'Верхняя Пышма' 21 +-- 'serov' -> 'Серов' 25 +-- ИТОГО 27706 +-- ⚠️ avito_slug у Каменска-Уральского — ЧЕРЕЗ ДЕФИС ('kamensk-uralskiy'), не +-- через подчёркивание, в отличие от нашего внутреннего city_slug +-- 'kamensk_uralskiy' (CITY_LOCATIONS ключ). У Верхней Пышмы наоборот — +-- у Avito 'verhnyaya_pyshma' (kh -> h, БЕЗ 'k'), совпадает с +-- CityLocation("verhnyaya_pyshma", ...).avito_slug в pipeline.py, но +-- отличается от нашего внутреннего ключа 'verkhnyaya_pyshma' (с 'k'). +-- В фактических данных встретился ТОЛЬКО вариант 'verhnyaya_pyshma' — второй +-- вариант написания в WHERE не нужен (дал бы 0 доп. строк). +-- +-- ВНЕ SCOPE (сознательно не трогаем, обоснование): +-- - Cian: хост НЕ индикатор города (ekb.cian.ru отдаёт областные объявления, +-- включая тагильские, через тот же хост с параметром региона) — бэкфилл +-- по хосту дал бы неверный результат. +-- - Domclick: у объявлений без координат город не критичен (0 rows без +-- lat), 13 строк на голом domclick.ru — отдельный разбор, не эта миграция. +-- - Yandex: в URL (realty.yandex.ru/offer/) города нет вовсе. +-- - listings.region_code: у 16912 чужих-региона строк он неверный (стоит +-- 66) — отдельный пункт issue #2604, ждёт решения владельца, здесь НЕ +-- трогаем. +-- - Слаги вне наших шести городов (1644 distinct на Avito, 16930 строк +-- city IS NULL) остаются NULL — по ним отдельное решение владельца. +-- +-- Idempotency: +-- `WHERE city IS NULL` — не перетирает то, что уже проставил скрапер +-- (write-path 196) или предыдущий прогон этой же миграции. Повторный +-- прогон обновляет 0 строк (все затронутые строки уже НЕ city IS NULL). +-- CASE ветки строго совпадают со списком в WHERE ... IN (...), поэтому +-- для любой строки, прошедшей WHERE, CASE НЕ может вернуть NULL. +-- +-- НЕ DDL — только UPDATE данных (колонка listings.city уже существует, +-- миграция 196). Ни одна строка не удаляется и не деактивируется. +-- +-- Dependencies: 196_listings_city.sql (колонка listings.city). + +BEGIN; + +UPDATE listings +SET city = CASE substring(source_url from 'avito\.ru/([^/]+)/') + WHEN 'ekaterinburg' THEN 'Екатеринбург' + WHEN 'nizhniy_tagil' THEN 'Нижний Тагил' + WHEN 'kamensk-uralskiy' THEN 'Каменск-Уральский' + WHEN 'pervouralsk' THEN 'Первоуральск' + WHEN 'verhnyaya_pyshma' THEN 'Верхняя Пышма' + WHEN 'serov' THEN 'Серов' +END +WHERE source = 'avito' + AND city IS NULL + AND substring(source_url from 'avito\.ru/([^/]+)/') IN ( + 'ekaterinburg', + 'nizhniy_tagil', + 'kamensk-uralskiy', + 'pervouralsk', + 'verhnyaya_pyshma', + 'serov' + ); + +COMMIT; diff --git a/tradein-mvp/backend/tests/test_migration_197_backfill_listings_city_from_url.py b/tradein-mvp/backend/tests/test_migration_197_backfill_listings_city_from_url.py new file mode 100644 index 00000000..08e51361 --- /dev/null +++ b/tradein-mvp/backend/tests/test_migration_197_backfill_listings_city_from_url.py @@ -0,0 +1,158 @@ +"""Static guards for migration 197 (issue #2594 шаг 3 — бэкфилл listings.city +из слага города в Avito source_url для накопленных объявлений). + +Прод применяет data/sql построчно строго (ON_ERROR_STOP). Полный DB-прогон +требует живой БД; здесь фиксируем структурные инварианты, которые ГАРАНТИРУЮТ +идемпотентность, скоуп (только Avito, только city IS NULL, только 6 наших +городов) и НЕдеструктивность к самим listings-строкам по построению. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql" +_MIGRATION_197 = _SQL_DIR / "197_backfill_listings_city_from_url.sql" + + +def _sql() -> str: + return _MIGRATION_197.read_text(encoding="utf-8") + + +def _executable_sql() -> str: + """SQL без построчных `--`-комментариев — только исполняемый код.""" + lines = [] + for raw in _sql().splitlines(): + code = raw.split("--", 1)[0] + if code.strip(): + lines.append(code) + return "\n".join(lines) + + +def _flat(text: str) -> str: + return re.sub(r"\s+", " ", text).strip().lower() + + +def test_migration_197_exists() -> None: + assert _MIGRATION_197.exists(), f"missing migration: {_MIGRATION_197}" + + +def test_migration_197_is_transactional() -> None: + sql = _sql() + assert "BEGIN;" in sql + assert "COMMIT;" in sql + + +def test_migration_197_only_avito_city_null() -> None: + """WHERE ограничен source='avito' AND city IS NULL — не перетирает то, что + уже проставил скрапер (196), не трогает Cian/Domclick/Yandex.""" + flat = _flat(_executable_sql()) + assert "where source = 'avito'" in flat + assert "and city is null" in flat + + +def test_migration_197_covers_exactly_six_cities() -> None: + """CASE и WHERE ... IN покрывают ровно наши шесть городов Свердловской + обл. — ни больше (не расползаемся на чужие регионы), ни меньше.""" + flat = _flat(_executable_sql()) + expected_pairs = { + "'ekaterinburg'": "екатеринбург", + "'nizhniy_tagil'": "нижний тагил", + "'kamensk-uralskiy'": "каменск-уральский", + "'pervouralsk'": "первоуральск", + "'verhnyaya_pyshma'": "верхняя пышма", + "'serov'": "серов", + } + for slug, _city_lower in expected_pairs.items(): + assert slug in flat, f"missing avito slug branch: {slug}" + # Ровно 6 веток WHEN в CASE (по числу городов). + assert flat.count(" when ") == len(expected_pairs) + + +def test_migration_197_kamensk_slug_uses_dash_not_underscore() -> None: + """Avito отдаёт 'kamensk-uralskiy' (дефис) — НЕ наш внутренний city_slug + 'kamensk_uralskiy' (подчёркивание, CITY_LOCATIONS ключ в pipeline.py). + Регресс на подчёркивание означало бы 0 подхваченных строк на проде.""" + flat = _flat(_executable_sql()) + assert "'kamensk-uralskiy'" in flat + assert "'kamensk_uralskiy'" not in flat + + +def test_migration_197_pyshma_slug_matches_avito_not_internal_key() -> None: + """Avito слаг — 'verhnyaya_pyshma' (без 'k'), а не наш внутренний ключ + 'verkhnyaya_pyshma' (с 'k', CITY_DISPLAY_NAMES/CITY_LOCATIONS в + pipeline.py). На проде встретился только вариант без 'k' — второй сюда + сознательно не добавлен (см. заголовок миграции).""" + flat = _flat(_executable_sql()) + assert "'verhnyaya_pyshma'" in flat + assert "'verkhnyaya_pyshma'" not in flat + + +def test_migration_197_city_names_match_pipeline_display_names() -> None: + """Человекочитаемые названия городов побайтно совпадают с + CITY_DISPLAY_NAMES / EKATERINBURG_CITY_NAME в scraper_kit.orchestration + .pipeline — иначе один и тот же город расщепится на две разные метки + (старые backfilled-строки vs новые, проставленные скрапером).""" + pipeline_path = ( + Path(__file__).resolve().parents[2] + / "packages" + / "scraper-kit" + / "src" + / "scraper_kit" + / "orchestration" + / "pipeline.py" + ) + pipeline_src = pipeline_path.read_text(encoding="utf-8") + + sql = _sql() + expected_names = [ + "Екатеринбург", + "Нижний Тагил", + "Каменск-Уральский", + "Первоуральск", + "Верхняя Пышма", + "Серов", + ] + for name in expected_names: + assert name in sql, f"missing display name in migration: {name}" + assert name in pipeline_src, ( + f"display name {name!r} in migration 197 не найден в pipeline.py " + "CITY_DISPLAY_NAMES/EKATERINBURG_CITY_NAME — риск расщепления " + "одного города на две метки" + ) + + +def test_migration_197_no_ddl() -> None: + """Только UPDATE данных — колонка listings.city уже существует (196), + никакого ALTER/CREATE/DROP здесь быть не должно.""" + flat = _flat(_executable_sql()) + assert "alter table" not in flat + assert "create table" not in flat + assert "drop table" not in flat + assert flat.count("update listings") == 1 + + +def test_migration_197_no_destructive_ddl() -> None: + """Миграция не должна содержать DROP TABLE / TRUNCATE / DELETE.""" + flat = _flat(_executable_sql()) + assert "drop table" not in flat + assert "truncate" not in flat + assert "delete from" not in flat + + +def test_migration_197_does_not_touch_other_sources_or_region_code() -> None: + """Явно вне scope (#2601/#2604): cian/yandex/domclick и region_code не + упоминаются в исполняемом SQL этой миграции.""" + flat = _flat(_executable_sql()) + assert "cian" not in flat + assert "yandex" not in flat + assert "domclick" not in flat + assert "region_code" not in flat + + +def test_migration_197_no_psycopg_trap() -> None: + """Никаких :param::type — psycopg v3 требует CAST(... AS type) (не + применимо в чистом .sql без bind params, но проверяем на регресс + copy-paste из Python-кода).""" + assert not re.search(r":\w+::", _sql()) From eccb895db1e1987e321182547ea923dffa8d5e1b Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 1 Aug 2026 02:50:14 +0300 Subject: [PATCH 048/141] =?UTF-8?q?feat(tradein):=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=BA=D0=BB=D1=8E=D1=87=D0=B0=D0=B5=D0=BC=D1=8B=D0=B9=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B5=D1=81=D1=82=D1=80=20=D0=BB=D1=8E=D0=B4=D0=B5?= =?UTF-8?q?=D0=B9=20=E2=80=94=20=D0=BF=D0=BE=D0=B4=D0=B3=D0=BE=D1=82=D0=BE?= =?UTF-8?q?=D0=B2=D0=BA=D0=B0=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B5=D0=B7=D0=B4?= =?UTF-8?q?=D0=B0=20=C2=AB=D0=9C=D0=B5=D1=80=D1=8B=C2=BB=20=D0=B2=20=D0=91?= =?UTF-8?q?=D0=94=20auth=20[PR-2b/6]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Дефолт не меняет ничего: IDENTITY_STORE="tradein" — это сегодняшний прод, tradein_users/tradein_sessions, соединение с БД auth не открывается вообще. Переключение делается одной переменной окружения ПОСЛЕ того, как на проде появится пароль auth_app и будут скопированы данные. Так сделано намеренно: мерж, который зависит от невыполненного ручного шага, — это мерж, который ломает прод в момент невнимательности. Ядро. app/services/identity_store.py — единственное место, знающее, в какой БД и в каких таблицах живёт реестр. Имена таблиц берутся из фиксированного словаря по значению флага, не конкатенацией с вводом. app/core/auth_db.py — ЛЕНИВЫЙ engine БД auth (core/db.py создаёт свой на импорте; такое же для auth роняло бы старт без DSN). Одно понятие состояния доступа вместо двух. В tradein_users состояние — булев is_active, в auth.users — access_state из трёх значений. Конверсия живёт в одной функции to_access_state(): True→active, False→disabled, а неизвестная строка, NULL или чужой тип → disabled с WARNING. Fail-closed выбран сознательно: если следующая миграция добавит четвёртое состояние, оно по умолчанию НЕ будет пускать. Проверка доступа — свойство can_sign_in, а не сравнение со строкой. Логин в режиме auth. Пароль проверяется ВСЕГДА и ДО ветвления по состоянию — иначе появляется timing-oracle и перечисление логинов. Верный пароль + trial_expired → 403 с машиночитаемым code="access_expired", сессия НЕ создаётся. Верный пароль + disabled → тот же generic 401, что и при неверном пароле. Резолв уже выданной сессии пропускает только active — блокировка обрывает сессию немедленно, а не по истечении sliding-refresh. Старт падает явно, если IDENTITY_STORE=auth, а DSN не задан. Без этого ошибка конфигурации не похожа на аварию: продуктовая БД жива, приложение работает, а rbac_guard ловит исключение резолва вместе с любым другим сбоем и падает в legacy trusted-header ветку — то есть сутками раздаёт права из roles.yaml мимо реестра, включая аккаунты с disabled. Форма входа понимает новый код ответа. Ветвление по detail.code, а не по тексту: текст бэк вправе менять, код — нет. Гранты соблюдены, а не обойдены: auth_app не имеет UPDATE на role/manager_id и не имеет DELETE на users (миграция 004, column-level). Тесты: 2996 passed (+59). Единственный красный — test_search_cache_hit — предсуществующий: проверен контрольным полным прогоном на чистом main (2937 passed, тот же красный). --- tradein-mvp/backend/app/api/v1/auth.py | 63 ++- tradein-mvp/backend/app/api/v1/me.py | 14 +- tradein-mvp/backend/app/api/v1/team.py | 271 +++++++--- tradein-mvp/backend/app/core/auth_db.py | 125 +++++ tradein-mvp/backend/app/core/config.py | 23 + tradein-mvp/backend/app/core/rbac.py | 38 +- tradein-mvp/backend/app/main.py | 22 + .../backend/app/services/auth_session.py | 101 +++- .../backend/app/services/identity_store.py | 291 +++++++++++ .../backend/tests/support/identity_modes.py | 200 ++++++++ tradein-mvp/backend/tests/test_auth_api.py | 290 ++++++++++- .../backend/tests/test_auth_session.py | 166 +++++- .../backend/tests/test_identity_store.py | 481 ++++++++++++++++++ tradein-mvp/backend/tests/test_team_api.py | 337 ++++++++++-- tradein-mvp/frontend/src/app/login/page.tsx | 20 + 15 files changed, 2224 insertions(+), 218 deletions(-) create mode 100644 tradein-mvp/backend/app/core/auth_db.py create mode 100644 tradein-mvp/backend/app/services/identity_store.py create mode 100644 tradein-mvp/backend/tests/support/identity_modes.py create mode 100644 tradein-mvp/backend/tests/test_identity_store.py diff --git a/tradein-mvp/backend/app/api/v1/auth.py b/tradein-mvp/backend/app/api/v1/auth.py index 9933bc2e..a839d936 100644 --- a/tradein-mvp/backend/app/api/v1/auth.py +++ b/tradein-mvp/backend/app/api/v1/auth.py @@ -6,9 +6,14 @@ это `/trade-in/api/v1/auth/*` снаружи. Security: - - Неверные creds (неизвестный username / неактивен / password_hash NULL / + - Неверные creds (неизвестный username / доступ закрыт / password_hash NULL / неверный пароль) → ОДИНАКОВЫЙ 401 с generic сообщением — не раскрываем, существует ли username (user-enumeration защита). + - Состояние доступа проверяется ТОЛЬКО ПОСЛЕ проверки пароля, и осмысленный + ответ (403 «пробный доступ закончился») получает исключительно тот, кто + пароль уже доказал. Ветвление ДО пароля превратило бы отдельный статус в + оракул существования логина: перебором можно было бы перечислить аккаунты, + не зная ни одного пароля (миграция data/sql/auth/004, WHY-2). - #2552 post-review Medium 2: `verify_password` ВСЕГДА вызывается ровно один раз — для несуществующего username / NULL password_hash сверяем против статичного dummy-хеша (`_DUMMY_PASSWORD_HASH`, сгенерирован один @@ -38,10 +43,10 @@ from pydantic import BaseModel from sqlalchemy.orm import Session from app.core.config import settings -from app.core.db import get_db from app.core.password import hash_password, verify_password from app.core.ratelimit import SlidingWindowLimiter, _client_ip from app.services.auth_session import create_session, get_user_by_username, revoke_session +from app.services.identity_store import AccessState, get_identity_db from app.services.user_events import schedule_event logger = logging.getLogger(__name__) @@ -67,6 +72,16 @@ _DUMMY_PASSWORD_HASH = hash_password(secrets.token_urlsafe(16)) _INVALID_CREDENTIALS_DETAIL = "неверный логин или пароль" +# Единственный ответ логина, который НЕ generic 401: пароль верный, но пробный +# период истёк. `code` — машиночитаемый контракт для фронта (текст можно менять, +# ветку по нему — нет). Потребитель: `loginErrorMessage` в +# tradein-mvp/frontend/src/app/login/page.tsx — читает `detail.code` из +# `HTTPError.body` (frontend/src/lib/api.ts отдаёт тело ответа как есть) и +# показывает экран про пробный период вместо generic «Проверьте подключение». +# Меняешь значение здесь — меняй и там. +_ACCESS_EXPIRED_CODE = "access_expired" +_ACCESS_EXPIRED_MESSAGE = "Пробный доступ закончился" + class LoginRequest(BaseModel): username: str @@ -82,7 +97,7 @@ async def login( body: LoginRequest, request: Request, response: Response, - db: Annotated[Session, Depends(get_db)], + db: Annotated[Session, Depends(get_identity_db)], ) -> LoginResponse: ip = _client_ip(request) user_agent = request.headers.get("user-agent") @@ -105,9 +120,44 @@ async def login( # ВСЕГДА вызывается — 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: + # Пароль проверен ВЫШЕ и безусловно — только теперь смотрим на состояние + # доступа. Порядок несущий, а не стилистический: см. модульный docstring. + if user is None or not password_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) + + access_state = user["access_state"] + if access_state is AccessState.TRIAL_EXPIRED: + # Пароль верный, сессия НЕ создаётся. Единственный не-generic ответ: + # аккаунт существует и владелец это уже доказал паролем, так что + # осмысленный текст ничего не раскрывает постороннему. + # В режиме identity_store="tradein" эта ветка недостижима: булев + # is_active даёт только active/disabled (identity_store.to_access_state). + schedule_event( + event_type="login_blocked_expired", + username=user["username"], + ip=ip, + user_agent=user_agent, + path="/api/v1/auth/login", + method="POST", + ) + raise HTTPException( + status_code=403, + detail={"code": _ACCESS_EXPIRED_CODE, "message": _ACCESS_EXPIRED_MESSAGE}, + ) + + if not access_state.can_sign_in: + # disabled (и любое нераспознанное состояние — to_access_state fail-closed) + # → ТОТ ЖЕ generic 401 и то же событие, что при неверном пароле: + # заблокированный аккаунт неотличим от несуществующего. schedule_event( event_type="login_failed", username=body.username, @@ -118,7 +168,6 @@ async def login( ) 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( @@ -147,7 +196,7 @@ async def login( async def logout( request: Request, response: Response, - db: Annotated[Session, Depends(get_db)], + db: Annotated[Session, Depends(get_identity_db)], ) -> dict[str, bool]: token = request.cookies.get(settings.session_cookie_name) if token: diff --git a/tradein-mvp/backend/app/api/v1/me.py b/tradein-mvp/backend/app/api/v1/me.py index f5e74b22..ef1dac95 100644 --- a/tradein-mvp/backend/app/api/v1/me.py +++ b/tradein-mvp/backend/app/api/v1/me.py @@ -9,10 +9,15 @@ Caddy basic_auth пропускает `X-Authenticated-User: ` чер кому что показывать. #2552: session-first. Валидная DB-session cookie (см. app.services.auth_session) -отдаёт scope из tradein_users (role/display_name/org/email) БЕЗ похода в +отдаёт scope из реестра людей (role/display_name/org/email) БЕЗ похода в roles.yaml. Без cookie (или невалидная/истёкшая) — legacy X-Authenticated-User путь, БЕЗ ИЗМЕНЕНИЙ (regression недопустим — существующие тесты держат его бит-в-бит). + +Сессия БД берётся у `identity_store.get_identity_db` (реестр), а не у +`app.core.db.get_db` (продуктовая БД): при `IDENTITY_STORE=auth` люди и сессии +живут в другой БД. В дефолтном режиме это ТОТ ЖЕ объект `Session`, что отдал бы +`get_db`, — поведение прода не меняется. """ from __future__ import annotations @@ -25,8 +30,8 @@ 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 +from app.services.identity_store import get_identity_db logger = logging.getLogger(__name__) @@ -36,14 +41,15 @@ router = APIRouter() @router.get("/me") async def me( request: Request, - db: Annotated[Session, Depends(get_db)], + db: Annotated[Session, Depends(get_identity_db)], x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None, ) -> 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 + а DB-роли (реестр: tradein_users.role / auth.users.role) — + `"admin"/"manager"/"employee"`. FastAPI строит response-схему из return-аннотации; жёсткий `UserScope` завернул бы "employee"/"manager" в ResponseValidationError. Итоговая JSON-форма ОДИНАКОВАЯ (те же 8 ключей) для обеих веток. diff --git a/tradein-mvp/backend/app/api/v1/team.py b/tradein-mvp/backend/app/api/v1/team.py index 592f4f13..d42bd250 100644 --- a/tradein-mvp/backend/app/api/v1/team.py +++ b/tradein-mvp/backend/app/api/v1/team.py @@ -15,10 +15,35 @@ Mounted at `/api/v1/team`; через Caddy `uri strip_prefix /trade-in` это - Роль должна быть `admin` или `manager` — иначе 403. Org-изоляция (главный инвариант фичи): manager видит/меняет ТОЛЬКО своих -employee (`tradein_users.manager_id = actor.user_id`). Чужой/несуществующий +employee (`<реестр>.manager_id = actor.user_id`). Чужой/несуществующий employee_id → 404 (НЕ 403) — не подтверждаем/не опровергаем существование чужого сотрудника перед manager'ом. См. `_authorize_employee`. +ДВЕ СЕССИИ БД, и это не дублирование: + - `identity_db` (`Depends(get_identity_db)`) — реестр людей: строка сотрудника + и его сессии. При `IDENTITY_STORE=auth` это ДРУГАЯ БД (`auth`). + - `db` (`Depends(get_db)`) — продуктовые таблицы «Меры», которые в общий + реестр не переезжают: `account_quota_overrides`, `account_estimate_usage`, + `user_events`, `trade_in_estimates`. +В дефолтном режиме (`IDENTITY_STORE=tradein`) это ОДИН И ТОТ ЖЕ объект `Session` +(см. `identity_store.get_identity_db`), поэтому всё по-прежнему коммитится одной +транзакцией — прод не меняется. В режиме `auth` транзакции физически две: +порядок коммитов выбран так, чтобы при сбое второго коммита оставалось менее +вредное состояние (см. комментарии у `db.commit()`), а `db is not identity_db` — +рантайм-признак «БД разные». + +Гранты роли `auth_app` (data/sql/auth/004, Часть 4) этот роутер соблюдает без +обходов: он ПИШЕТ только `password_hash, display_name, org_name, email, +access_state, updated_at` (ровно column-level GRANT UPDATE), вставляет строку +целиком (табличный GRANT INSERT) и НИКОГДА не пишет `role`/`manager_id` +UPDATE'ом и не делает DELETE по `users`. + +DELETE по `sessions` реестра — штатный и грантом предусмотрен (data/sql/auth/002, +GRANT DELETE на sessions): блокировка и смена пароля обязаны рвать живые сессии +немедленно, это `revoke_user_sessions` из `app.services.auth_session`, вызываемый +из `update_employee`. То есть периметр DELETE у этого роутера — ровно `sessions` +и ничего больше; грант DELETE на sessions не лишний. + Кого именно можно менять через этот роутер (`_MANAGEABLE_ROLES_BY_ACTOR`): - actor manager → только `role='employee'` И только своих (как было). - actor admin → `role IN ('employee','manager')`. @@ -51,6 +76,7 @@ from sqlalchemy import text from sqlalchemy.engine import RowMapping from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session +from sqlalchemy.sql.elements import TextClause from app.core.auth import get_role from app.core.config import settings @@ -65,6 +91,14 @@ from app.schemas.team import ( ) from app.services import account_quota from app.services.auth_session import get_session_user, revoke_user_sessions +from app.services.identity_store import ( + AccessState, + IdentitySchema, + access_state_param, + get_identity_db, + identity_schema, + to_access_state, +) from app.services.user_events import schedule_event logger = logging.getLogger(__name__) @@ -83,18 +117,19 @@ class TeamActor: async def current_team_actor( request: Request, - db: Annotated[Session, Depends(get_db)], + identity_db: Annotated[Session, Depends(get_identity_db)], ) -> TeamActor: """Dependency: session-only identity, роль admin|manager, иначе 401/403. Намеренно НЕ читает `X-Authenticated-User` — см. модульный docstring. + Сессия резолвится в БД РЕЕСТРА (см. про две сессии в модульном 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) + session_user = get_session_user(identity_db, token) except Exception: logger.exception("team: session lookup failed") raise HTTPException(status_code=401, detail="valid session required") from None @@ -159,30 +194,40 @@ def _require_same_origin(request: Request) -> None: # --------------------------------------------------------------------------- +# Имена таблицы и колонки состояния доступа приходят из `identity_schema()` — +# фиксированный словарь в `app.services.identity_store`, единственный источник +# этих имён (в SQL-строку не попадает ничего пришедшего снаружи; значения +# по-прежнему биндятся параметрами). +# +# `AS access_state` в КАЖДОМ SELECT'е — не косметика: колонка называется +# по-разному в двух схемах, и без алиаса вызывающий код читал бы то `is_active`, +# то `access_state`, то есть завёл бы то самое второе представление состояния, +# которого быть не должно. Дальше значение всегда идёт через `to_access_state()`. +def _employee_columns(schema: IdentitySchema) -> str: + return ( + "id, username, role, display_name, org_name, email, " + f"{schema.access_state_column} AS access_state, manager_id, created_at" + ) + + # Два статических варианта — НЕ динамическая сборка WHERE (та же мотивация, что -# у `_LIST_EMPLOYEES_*_SQL` ниже: значения и так биндятся параметрами, но +# у `_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_sql(actor_role: str) -> TextClause: + schema = identity_schema() + cols = _employee_columns(schema) + if actor_role == "admin": + return text( + f"SELECT {cols} FROM {schema.users_table} " + "WHERE id = :id AND role IN ('employee', 'manager')" + ) + return text(f"SELECT {cols} FROM {schema.users_table} WHERE id = :id AND role = 'employee'") -def _fetch_employee_row(db: Session, employee_id: int, actor: TeamActor) -> RowMapping | None: +def _fetch_employee_row( + identity_db: Session, employee_id: int, actor: TeamActor +) -> RowMapping | None: """Строка управляемого юзера в пределах прав *actor* — иначе None (→ 404). Фильтр по роли делается ЗДЕСЬ, в SQL, а не в `_authorize_employee` ниже: @@ -191,8 +236,8 @@ def _fetch_employee_row(db: Session, employee_id: int, actor: TeamActor) -> RowM тебе не по зубам») — тот же принцип, что и 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() + sql = _fetch_employee_sql(actor.role) + return identity_db.execute(sql, {"id": employee_id}).mappings().fetchone() def _authorize_employee(actor: TeamActor, row: RowMapping | None) -> RowMapping: @@ -343,6 +388,13 @@ def _batch_quota_status(db: Session, usernames: list[str]) -> dict[str, dict[str def _employee_out(row: RowMapping, quota: dict[str, Any]) -> EmployeeOut: + """Строка реестра → ответ API. + + `is_active` в контракте API остаётся булевым (форма ответа не меняется — + фронт «Команды» не трогаем этим PR), и считается он ровно как «пустят ли + входить»: `trial_expired` показывается как заблокированный. Отдельное + отображение пробного периода в «Команде» — вопрос UI-PR'а, не этого. + """ return EmployeeOut( id=row["id"], username=row["username"], @@ -350,7 +402,7 @@ def _employee_out(row: RowMapping, quota: dict[str, Any]) -> EmployeeOut: display_name=row["display_name"], org_name=row["org_name"], email=row["email"], - is_active=row["is_active"], + is_active=to_access_state(row["access_state"]).can_sign_in, manager_id=row["manager_id"], created_at=row["created_at"], quota=QuotaStatusOut(**quota), @@ -367,6 +419,7 @@ async def create_employee( body: EmployeeCreateRequest, actor: Annotated[TeamActor, Depends(current_team_actor)], db: Annotated[Session, Depends(get_db)], + identity_db: Annotated[Session, Depends(get_identity_db)], _origin_check: Annotated[None, Depends(_require_same_origin)], ) -> EmployeeOut: """Создать сотрудника. Роль всегда `employee`. @@ -375,9 +428,13 @@ async def create_employee( значение из тела ИГНОРИРУЕТСЯ, org-изоляция инвариант #2554). Для actor.role == admin — опционально из тела, валидируется что указанный id существует и role='manager' (иначе 422). + + `identity_db` — реестр (строка сотрудника), `db` — продуктовая квота; + в дефолтном режиме это одна и та же сессия и одна транзакция. """ - existing = db.execute( - text("SELECT id FROM tradein_users WHERE username = :u"), + schema = identity_schema() + existing = identity_db.execute( + text(f"SELECT id FROM {schema.users_table} WHERE username = :u"), {"u": body.username}, ).fetchone() if existing is not None: @@ -396,8 +453,8 @@ async def create_employee( 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'"), + mgr = identity_db.execute( + text(f"SELECT id FROM {schema.users_table} WHERE id = :id AND role = 'manager'"), {"id": manager_id}, ).fetchone() if mgr is None: @@ -408,17 +465,16 @@ async def create_employee( try: row = ( - db.execute( + identity_db.execute( text( - """ - INSERT INTO tradein_users + f""" + INSERT INTO {schema.users_table} (username, password_hash, role, manager_id, display_name, org_name, - email, is_active) + email, {schema.access_state_column}) VALUES (:username, :password_hash, 'employee', :manager_id, :display_name, - :org_name, :email, true) - RETURNING id, username, role, display_name, org_name, email, is_active, - manager_id, created_at + :org_name, :email, :access_state) + RETURNING {_employee_columns(schema)} """ ), { @@ -428,6 +484,10 @@ async def create_employee( "display_name": body.display_name, "org_name": body.org_name, "email": body.email, + # Новый сотрудник заводится с открытым доступом — как и + # раньше (`is_active = true` литералом). Литерала здесь + # больше нет: тип колонки разный, знает о нём identity_store. + "access_state": access_state_param(AccessState.ACTIVE), }, ) .mappings() @@ -435,8 +495,8 @@ async def create_employee( ) except IntegrityError: # TOCTOU: два конкурентных POST с одинаковым username между pre-check - # выше и этим INSERT — UNIQUE-констрейнт на tradein_users.username ловит. - db.rollback() + # выше и этим INSERT — UNIQUE-констрейнт на username в реестре ловит. + identity_db.rollback() raise HTTPException(status_code=409, detail="username already exists") from None assert row is not None # RETURNING на успешный INSERT всегда отдаёт строку @@ -444,7 +504,15 @@ async def create_employee( if body.monthly_limit is not None: _upsert_quota_override(db, body.username, body.monthly_limit, actor.username) - db.commit() + # Реестр коммитится ПЕРВЫМ. В дефолтном режиме это один коммит на одну + # транзакцию (identity_db is db) — ровно как было. В режиме `auth` БД две, + # и порядок выбран по цене сбоя: не доехавшая квота — это сотрудник с + # глобальным лимитом (чинится повторным PATCH), тогда как не доехавшая + # строка сотрудника при уже сохранённой квоте — висящий override на + # несуществующего человека. + identity_db.commit() + if db is not identity_db: + db.commit() schedule_event( event_type="employee_created", @@ -471,6 +539,7 @@ async def update_employee( body: EmployeeUpdateRequest, actor: Annotated[TeamActor, Depends(current_team_actor)], db: Annotated[Session, Depends(get_db)], + identity_db: Annotated[Session, Depends(get_identity_db)], _origin_check: Annotated[None, Depends(_require_same_origin)], ) -> EmployeeOut: """Частичное обновление сотрудника — block/unblock, лимит, профиль, пароль. @@ -483,8 +552,14 @@ async def update_employee( КАЖДОМ запросе, так что скомпрометированная/чужая сессия живёт неограниченно долго, а не «до TTL». `revoke_user_sessions` сам называет смену пароля своим use-case — см. его докстринг. + + `is_active` в теле остаётся булевым (контракт API не меняется): true → + `active`, false → `disabled`. Перевести аккаунт В `trial_expired` этим + роутом нельзя — это состояние проставляется миграцией/владельцем, а + выразить его булевым полем нечем; is_active=true на таком аккаунте открывает + доступ (снимает пробное ограничение), is_active=false закрывает жёстко. """ - row = _fetch_employee_row(db, employee_id, actor) + row = _fetch_employee_row(identity_db, employee_id, actor) row = _authorize_employee(actor, row) new_password_hash: str | None = None @@ -494,14 +569,27 @@ async def update_employee( except ValueError as e: raise HTTPException(status_code=422, detail=str(e)) from None - db.execute( + schema = identity_schema() + # Пишутся РОВНО те колонки, на которые у auth_app есть column-level GRANT + # UPDATE (data/sql/auth/004, Часть 4): password_hash, display_name, org_name, + # email, access_state, updated_at. role и manager_id этим роутом не + # обновляются — не «пока не понадобилось», а сознательно: право на их запись + # роли приложения не выдано, и добавлять его в обход миграции нельзя. + # + # CAST обязателен из-за NULL-параметра (поле не пришло в PATCH → COALESCE + # оставляет текущее значение): у нетипизированного NULL Postgres не может + # вывести тип. Имя SQL-типа — из фиксированного словаря identity_store. + identity_db.execute( text( - """ - UPDATE tradein_users + f""" + UPDATE {schema.users_table} 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), + {schema.access_state_column} = COALESCE( + CAST(:access_state AS {schema.access_state_sql_type}), + {schema.access_state_column} + ), password_hash = COALESCE(:password_hash, password_hash), updated_at = now() WHERE id = :id @@ -511,7 +599,13 @@ async def update_employee( "display_name": body.display_name, "org_name": body.org_name, "email": body.email, - "is_active": body.is_active, + "access_state": ( + None + if body.is_active is None + else access_state_param( + AccessState.ACTIVE if body.is_active else AccessState.DISABLED + ) + ), "password_hash": new_password_hash, "id": employee_id, }, @@ -523,13 +617,20 @@ async def update_employee( if body.is_active is False or body.new_password is not None: # Обязательно ПОСЛЕ UPDATE, ДО финального commit — revoke_user_sessions # коммитит сам (см. app.services.auth_session), это флашит и наш - # предшествующий UPDATE/quota-upsert в той же сессии. Self-lockout + # предшествующий UPDATE (а в дефолтном режиме, где сессия одна, — и + # quota-upsert). Сессии живут в БД реестра, вместе с пользователем, + # поэтому рвём их через `identity_db`: с чужой сессией здесь блокировка + # и смена пароля перестали бы действовать немедленно. Self-lockout # невозможен: _fetch_employee_row не отдаёт строки с role='admin' # НИКОМУ, а manager'у — ещё и только role='employee'; т.е. actor # (admin|manager) никогда не может патчить сам себя через этот роут. - revoke_user_sessions(db, employee_id) + revoke_user_sessions(identity_db, employee_id) - db.commit() + # Порядок и смысл — как в create_employee: реестр первым, продуктовая БД + # отдельным коммитом только если она физически другая. + identity_db.commit() + if db is not identity_db: + db.commit() changed_profile_fields = [ f @@ -573,7 +674,7 @@ async def update_employee( }, ) - updated_row = _fetch_employee_row(db, employee_id, actor) + updated_row = _fetch_employee_row(identity_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) @@ -596,50 +697,48 @@ async def update_employee( # постраничном листании. `id` монотонно растёт (BIGINT IDENTITY) — детерминированный # tie-break без доп. индекса (созданные позже = бОльший id, тот же порядок что и # намерение DESC-сортировки по времени). -_LIST_EMPLOYEES_BY_MANAGER_SQL = text( - """ - SELECT id, username, role, display_name, org_name, email, is_active, manager_id, created_at - FROM tradein_users - WHERE role = 'employee' AND manager_id = :manager_id - ORDER BY created_at DESC, id DESC - LIMIT :limit OFFSET :offset - """ -) - -# Admin-ветка: сюда попадают И менеджеры (см. модульный docstring — иначе admin -# не видит в UI строку, которой должен уметь сбросить пароль). `role='admin'` -# по-прежнему невидим и неуправляем. Сортировка по (created_at, id) общая для -# обеих ролей — намеренно: seed (#2557) вставил всех одной транзакцией, так что -# группировка «сначала менеджеры» дала бы ложное ощущение иерархии там, где её -# в данных нет; роль показывается колонкой (`EmployeeOut.role`). -_LIST_EMPLOYEES_ALL_SQL = text( - """ - SELECT id, username, role, display_name, org_name, email, is_active, manager_id, created_at - FROM tradein_users - WHERE role IN ('employee', 'manager') - ORDER BY created_at DESC, id DESC - LIMIT :limit OFFSET :offset - """ -) +# +# Admin-ветка (`by_manager=False`): сюда попадают И менеджеры (см. модульный +# docstring — иначе admin не видит в UI строку, которой должен уметь сбросить +# пароль). `role='admin'` по-прежнему невидим и неуправляем. Сортировка по +# (created_at, id) общая для обеих веток — намеренно: seed (#2557) вставил всех +# одной транзакцией, так что группировка «сначала менеджеры» дала бы ложное +# ощущение иерархии там, где её в данных нет; роль показывается колонкой +# (`EmployeeOut.role`). +def _list_employees_sql(*, by_manager: bool) -> TextClause: + schema = identity_schema() + cols = _employee_columns(schema) + tail = "ORDER BY created_at DESC, id DESC LIMIT :limit OFFSET :offset" + if by_manager: + return text( + f"SELECT {cols} FROM {schema.users_table} " + f"WHERE role = 'employee' AND manager_id = :manager_id {tail}" + ) + return text( + f"SELECT {cols} FROM {schema.users_table} WHERE role IN ('employee', 'manager') {tail}" + ) @router.get("/employees", response_model=list[EmployeeOut]) async def list_employees( actor: Annotated[TeamActor, Depends(current_team_actor)], db: Annotated[Session, Depends(get_db)], + identity_db: Annotated[Session, Depends(get_identity_db)], manager_id: Annotated[int | None, Query()] = None, limit: Annotated[int, Query(ge=1, le=200)] = 50, offset: Annotated[int, Query(ge=0)] = 0, ) -> list[EmployeeOut]: """Список сотрудников. manager видит только своих; admin — всех, опц. ?manager_id=. - Квота — ОДИН батч-запрос на всю страницу (`_batch_quota_status`), не N+1 - (Medium2, review PR #2563: было 2N+3 SQL-запросов на N сотрудников). + Сотрудники читаются из реестра (`identity_db`), квоты — из продуктовой БД + (`db`): `account_quota_overrides`/`account_estimate_usage` в общий реестр не + переезжают. Квота — ОДИН батч-запрос на всю страницу (`_batch_quota_status`), + не N+1 (Medium2, review PR #2563: было 2N+3 SQL-запросов на N сотрудников). """ if actor.role == "manager": rows = ( - db.execute( - _LIST_EMPLOYEES_BY_MANAGER_SQL, + identity_db.execute( + _list_employees_sql(by_manager=True), {"manager_id": actor.user_id, "limit": limit, "offset": offset}, ) .mappings() @@ -647,8 +746,8 @@ async def list_employees( ) elif manager_id is not None: rows = ( - db.execute( - _LIST_EMPLOYEES_BY_MANAGER_SQL, + identity_db.execute( + _list_employees_sql(by_manager=True), {"manager_id": manager_id, "limit": limit, "offset": offset}, ) .mappings() @@ -656,7 +755,11 @@ async def list_employees( ) else: rows = ( - db.execute(_LIST_EMPLOYEES_ALL_SQL, {"limit": limit, "offset": offset}).mappings().all() + identity_db.execute( + _list_employees_sql(by_manager=False), {"limit": limit, "offset": offset} + ) + .mappings() + .all() ) quota_by_username = _batch_quota_status(db, [row["username"] for row in rows]) @@ -673,15 +776,17 @@ async def employee_history( employee_id: int, actor: Annotated[TeamActor, Depends(current_team_actor)], db: Annotated[Session, Depends(get_db)], + identity_db: Annotated[Session, Depends(get_identity_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. + Та же org-проверка что и в PATCH: чужой employee_id → 404. Проверка идёт по + реестру (`identity_db`), сама история — продуктовые таблицы (`db`). """ - row = _fetch_employee_row(db, employee_id, actor) + row = _fetch_employee_row(identity_db, employee_id, actor) row = _authorize_employee(actor, row) rows = ( diff --git a/tradein-mvp/backend/app/core/auth_db.py b/tradein-mvp/backend/app/core/auth_db.py new file mode 100644 index 00000000..a7d1f5b4 --- /dev/null +++ b/tradein-mvp/backend/app/core/auth_db.py @@ -0,0 +1,125 @@ +"""Engine + session-factory для БД `auth` — общего реестра людей (эпик «единый вход»). + +Отдельный модуль, а не ещё пара строк в `app.core.db`, ровно по одной причине: +`app.core.db` создаёт engine НА ИМПОРТЕ (`create_engine(settings.database_url)` в +теле модуля). Сделай мы так же для БД `auth` — приложение начало бы падать на +старте везде, где `AUTH_DATABASE_URL` не задан, а не задан он сейчас ВЕЗДЕ: на +проде роль `auth_app` ещё без пароля, в тестах этой БД нет вовсе. Здесь engine +создаётся ЛЕНИВО, при первом реальном обращении. + +Контракт (⚠️ после мержа прод обязан работать ТОЧНО как сейчас): + + * `settings.identity_store == "tradein"` (дефолт) — в этот модуль не заходит + никто: `app.services.identity_store` берёт сессию из `app.core.db`. Пустой + `AUTH_DATABASE_URL` при этом не ошибка ни на импорте, ни в рантайме; ни одно + соединение с БД `auth` не открывается. + * `settings.identity_store == "auth"` + пустой DSN — первое же обращение + поднимает `AuthDatabaseNotConfiguredError` с внятным текстом. Именно + исключение, а НЕ тихий откат на tradein-таблицы и не пустой результат: + молчаливая деградация auth-пути означала бы «пользователь не найден» вместо + «конфигурация сломана», то есть массовый отказ входа под видом неверных + паролей — либо, в обратную сторону, анонимный доступ. + +`create_engine` сам по себе к серверу не ходит (connection pool ленивый), так что +даже после первого обращения реальный коннект открывается только на первом +запросе — но ошибку конфигурации мы обязаны отдать раньше, чем это станет +похоже на сетевую проблему. +""" + +from __future__ import annotations + +import threading +from collections.abc import Iterator +from contextlib import contextmanager + +from sqlalchemy import Engine, create_engine +from sqlalchemy.orm import Session, sessionmaker + +from app.core.config import settings + + +class AuthDatabaseNotConfiguredError(RuntimeError): + """`IDENTITY_STORE=auth`, но `AUTH_DATABASE_URL` пуст — идентичность негде читать.""" + + +_NOT_CONFIGURED_MSG = ( + "IDENTITY_STORE=auth, но AUTH_DATABASE_URL пуст: подключаться к общему реестру " + "людей (БД `auth`) не к чему. Задай DSN роли auth_app в .env.runtime — либо " + "верни IDENTITY_STORE=tradein (старое поведение на tradein_users/tradein_sessions)." +) + +# Кеш engine/factory + защита от гонки: rbac_guard резолвит сессию на каждом +# non-public запросе, а uvicorn обслуживает их из нескольких потоков (sync-роуты +# уходят в threadpool). Без лока два одновременных первых запроса создали бы два +# engine — то есть два независимых пула коннектов, один из которых потеряется. +_LOCK = threading.Lock() +_engine: Engine | None = None +_session_factory: sessionmaker[Session] | None = None + + +def _build() -> tuple[Engine, sessionmaker[Session]]: + """Создаёт engine + session-factory по текущему DSN. Пустой DSN → явная ошибка.""" + dsn = settings.auth_database_url.strip() + if not dsn: + raise AuthDatabaseNotConfiguredError(_NOT_CONFIGURED_MSG) + engine = create_engine(dsn, pool_pre_ping=True, future=True) + factory = sessionmaker(autocommit=False, autoflush=False, bind=engine, expire_on_commit=False) + return engine, factory + + +def _ensure_built() -> tuple[Engine, sessionmaker[Session]]: + global _engine, _session_factory + if _engine is not None and _session_factory is not None: + return _engine, _session_factory + with _LOCK: + if _engine is None or _session_factory is None: + _engine, _session_factory = _build() + return _engine, _session_factory + + +def get_auth_engine() -> Engine: + """Engine БД `auth` (создаётся при первом вызове). + + Raises: + AuthDatabaseNotConfiguredError: `AUTH_DATABASE_URL` пуст. + """ + engine, _ = _ensure_built() + return engine + + +def get_auth_session_factory() -> sessionmaker[Session]: + """Session-factory БД `auth` (создаётся при первом вызове). + + Raises: + AuthDatabaseNotConfiguredError: `AUTH_DATABASE_URL` пуст. + """ + _, factory = _ensure_built() + return factory + + +@contextmanager +def auth_session() -> Iterator[Session]: + """Сессия к БД `auth`, закрывается на выходе из блока. + + Прямой вызов из роутов/сервисов НЕ предполагается — ходи через + `app.services.identity_store.identity_session()`, он один знает, какая БД + сейчас является реестром. + """ + factory = get_auth_session_factory() + with factory() as db: + yield db + + +def reset_auth_db() -> None: + """Сбрасывает закешированные engine/factory (смена DSN в рантайме, тесты). + + Старый engine `dispose()`-ится вне лока: закрытие пула может блокировать, а + держать в это время лок незачем — ссылки на него уже сняты. + """ + global _engine, _session_factory + with _LOCK: + stale = _engine + _engine = None + _session_factory = None + if stale is not None: + stale.dispose() diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index b4a3f507..7ee6f9e5 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -71,6 +71,29 @@ class Settings(BaseSettings): default=300, validation_alias="LOGIN_RATE_LIMIT_WINDOW_S" ) + # ── Эпик «единый вход»: общий реестр людей в БД `auth` ───────────────────── + # DSN БД `auth` (роль auth_app) — единый реестр людей «Меры» (trade-in) и + # «Птицы» (Site Finder); схема — data/sql/auth/001-004. + # + # ПУСТО ПО УМОЛЧАНИЮ, И ЭТО НЕ ОШИБКА. На проде пароль роли auth_app ещё не + # заведён (переменной AUTH_DATABASE_URL там нет), данные (хеши/роли/живые + # сессии) в `auth` ещё не скопированы. Пока identity_store="tradein" (дефолт) + # к этой БД не обращается ни одна строка кода: engine не создаётся, + # соединение не открывается, пустой DSN на старте ничего не роняет — см. + # app.core.auth_db (ленивое создание engine). ENV: AUTH_DATABASE_URL. + auth_database_url: str = Field(default="", validation_alias="AUTH_DATABASE_URL") + # Где живут identity (люди + сессии): + # "tradein" (ДЕФОЛТ) — БД tradein, таблицы tradein_users/tradein_sessions + # (ровно сегодняшний прод, поведение не меняется); + # "auth" — БД auth, таблицы users/sessions (единый реестр). + # Переключать ТОЛЬКО после того, как на проде заведён пароль auth_app и + # перенесены данные. Дефолт = старое поведение: включить новый путь можно + # исключительно явной сменой этого флага. Единственный потребитель — + # app.services.identity_store. ENV: IDENTITY_STORE. + identity_store: Literal["tradein", "auth"] = Field( + default="tradein", validation_alias="IDENTITY_STORE" + ) + # для User-Agent в Nominatim (Nominatim Usage Policy) contact_email: str = "erginrajpopxbe@outlook.com" diff --git a/tradein-mvp/backend/app/core/rbac.py b/tradein-mvp/backend/app/core/rbac.py index eb9ec090..c391c366 100644 --- a/tradein-mvp/backend/app/core/rbac.py +++ b/tradein-mvp/backend/app/core/rbac.py @@ -10,13 +10,19 @@ so a regression in that check would NOT have failed CI. 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. +DB round trip via ``app.services.identity_store.identity_session`` — но ТОЛЬКО +когда запрос реально несёт 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. + +Сессия открывается через ``identity_session()``, а не через +``app.core.db.SessionLocal`` напрямую: guard — middleware, FastAPI-DI здесь нет, +а реестр людей при ``IDENTITY_STORE=auth`` лежит в другой БД. В дефолтном режиме +``identity_session()`` открывает ровно ``app.core.db.SessionLocal()`` — тот же +коннект-пул и то же поведение, что до эпика «единый вход». """ from __future__ import annotations @@ -32,8 +38,8 @@ 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 +from app.services.identity_store import identity_session logger = logging.getLogger(__name__) @@ -178,9 +184,23 @@ async def rbac_guard( if token: session_user: dict[str, Any] | None = None try: - with SessionLocal() as db: + with identity_session() as db: session_user = get_session_user(db, token) except Exception: + # Сюда попадает и AuthDatabaseNotConfiguredError (IDENTITY_STORE=auth + # без AUTH_DATABASE_URL): резолв сессии не состоялся, дальше работает + # тот же путь, что и при любом сбое БД, — auth_mode решает, пускать ли + # legacy trusted-header. + # + # ⚠️ Этот except НЕ должен быть тем, что ловит сломанный DSN: молча + # деградировать в legacy trusted-header означало бы раздавать права + # из roles.yaml в обход реестра (включая аккаунты с access_state + # 'disabled'/'trial_expired'), причём сутками — продуктовая БД жива, + # приложение работоспособно, сигнал только в логах. Поэтому + # конфигурацию проверяет lifespan (app/main.py): при + # IDENTITY_STORE=auth пустой DSN роняет СТАРТ. Здесь остаётся второй + # рубеж — реестр, отвалившийся уже после успешного старта, не имеет + # права отдавать 500. logger.exception("RBAC: session lookup failed for %s", path) if session_user is not None: username = session_user["username"] diff --git a/tradein-mvp/backend/app/main.py b/tradein-mvp/backend/app/main.py index 0a45d24c..bea049b8 100644 --- a/tradein-mvp/backend/app/main.py +++ b/tradein-mvp/backend/app/main.py @@ -34,6 +34,7 @@ from app.api.v1 import ( team, trade_in, ) +from app.core.auth_db import get_auth_engine from app.core.config import settings from app.core.db import SessionLocal from app.core.fdw import ensure_fdw_user_mapping @@ -121,6 +122,27 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: "которым подпись реально нужна" ) + # Эпик «единый вход»: при IDENTITY_STORE=auth реестр людей обязан быть + # СКОНФИГУРИРОВАН — иначе стартуем сломанными. Ошибка DSN не похожа на «БД + # недоступна»: продуктовая БД жива, приложение полностью работоспособно и + # может так работать сутками, а rbac_guard ловит AuthDatabaseNotConfiguredError + # вместе с любым другим сбоем резолва сессии и падает в legacy + # trusted-header ветку (auth_mode='dual'). То есть любой, кого пропустил + # Caddy basic_auth, молча получал бы права из roles.yaml — даже аккаунт с + # access_state='disabled'/'trial_expired' в реестре. Пусть лучше сломанный + # деплой не поднимется вообще, чем сутки раздаёт доступ мимо реестра. + # + # На ДЕФОЛТНЫЙ режим не влияет: при identity_store="tradein" (прод сегодня) + # ветка не выполняется, engine БД `auth` не создаётся, пустой + # AUTH_DATABASE_URL по-прежнему не ошибка. + if settings.identity_store == "auth": + # Наружу летит AuthDatabaseNotConfiguredError с внятным текстом + # (app.core.auth_db); create_engine к серверу не ходит, так что это + # проверка КОНФИГУРАЦИИ, а не доступности БД — недоступный сервер + # по-прежнему не мешает старту. + get_auth_engine() + logger.info("identity_store=auth: DSN общего реестра людей (БД `auth`) сконфигурирован") + # FDW bootstrap: create/refresh USER MAPPING for gendesign_remote postgres_fdw server. # Best-effort: failure does not abort startup, just logs. try: diff --git a/tradein-mvp/backend/app/services/auth_session.py b/tradein-mvp/backend/app/services/auth_session.py index 385444dc..38071bea 100644 --- a/tradein-mvp/backend/app/services/auth_session.py +++ b/tradein-mvp/backend/app/services/auth_session.py @@ -1,15 +1,26 @@ """Session-сервис для DB-backed auth (#2552, эпик #2549 — auth-core). -Схема: `tradein_users` + `tradein_sessions` (migration `192_tradein_users_auth.sql`). +Схема НЕ зашита: имена таблиц и имя колонки состояния доступа берутся из +`app.services.identity_store.identity_schema()` — эпик «единый вход» переводит +реестр людей с `tradein_users`/`tradein_sessions` (migration +`192_tradein_users_auth.sql`, БД tradein) на `users`/`sessions` (БД `auth`, +миграции data/sql/auth/001-004) флагом `IDENTITY_STORE`, дефолт которого = +сегодняшнее прод-поведение. Никаких других отличий между режимами у этого +модуля нет: SQL один и тот же, подставляются только имена из фиксированного +словаря `identity_store._SCHEMAS`. + Опаковые (`secrets.token_urlsafe`) токены-сессии — не JWT, не подписаны: валидность -проверяется исключительно наличием + `expires_at`/`is_active` строкой в БД, поэтому -`SESSION_SECRET` НЕ обязателен для работы этого модуля (зарезервирован на будущее, -см. `app.core.config.Settings.session_secret` docstring). +проверяется исключительно наличием строки + `expires_at` + состоянием доступа +юзера в БД, поэтому `SESSION_SECRET` НЕ обязателен для работы этого модуля +(зарезервирован на будущее, см. `app.core.config.Settings.session_secret` docstring). Все функции здесь принимают уже открытую `db: Session` — сами НЕ открывают -`SessionLocal()` (вызывающая сторона решает время жизни транзакции: `rbac_guard` -и `app.core.db.get_db()`-роуты открывают её по-разному). Это делает модуль -тривиально unit-тестируемым без патчинга `SessionLocal` — тесты просто передают +сессию (вызывающая сторона решает время жизни транзакции: `rbac_guard` и +роуты открывают её по-разному). ⚠️ Это ОБЯЗАНА быть сессия РЕЕСТРА +(`identity_store.identity_session()` / `Depends(get_identity_db)`), а не +`app.core.db.get_db`: при `IDENTITY_STORE=auth` запрос уйдёт в БД tradein, +где таблиц `users`/`sessions` нет. В дефолтном режиме это один и тот же объект. +Модуль остаётся тривиально unit-тестируемым — тесты просто передают fake/real `Session`. Ни одна функция не должна ронять вызывающий HTTP-запрос: DB-ошибки логируются @@ -30,6 +41,7 @@ from sqlalchemy import text from sqlalchemy.orm import Session from app.core.config import settings +from app.services.identity_store import identity_schema, to_access_state logger = logging.getLogger(__name__) @@ -52,11 +64,13 @@ def create_session( `expires_at = now() + settings.session_ttl_hours`. Коммитит сам (self-contained, как `app.services.user_events.record_event`). """ + schema = identity_schema() token = secrets.token_urlsafe(_TOKEN_BYTES) db.execute( text( - """ - INSERT INTO tradein_sessions (token, user_id, expires_at, ip_address, user_agent) + f""" + INSERT INTO {schema.sessions_table} + (token, user_id, expires_at, ip_address, user_agent) VALUES ( :token, :user_id, now() + make_interval(hours => CAST(:ttl_hours AS integer)), @@ -78,7 +92,16 @@ def create_session( def get_session_user(db: Session, token: str) -> dict[str, Any] | None: """Резолвит сессионный токен в данные юзера, или None если сессия - невалидна (не найдена / истекла / юзер деактивирован). + невалидна (не найдена / истекла / доступ юзера не `active`). + + Состояние доступа: пропускает ТОЛЬКО `AccessState.ACTIVE`. Любое другое + (`disabled`, `trial_expired`, а также нераспознанное — `to_access_state` + fail-closed'ит его в `disabled`) делает уже выданную сессию недействительной + немедленно, без ожидания TTL. Это то же решение, что и в булевой схеме + (`is_active = false` → None), просто теперь состояний больше одного: + «пробный период истёк» гасит живую сессию так же, как блокировка — иначе + сотрудник, залогиненный до истечения пробного доступа, продолжал бы + работать, а sliding-refresh продлевал бы ему сессию бесконечно. Sliding refresh: если с последнего `last_seen_at` прошло >=5 минут — продлевает `expires_at`/`last_seen_at` ОДНИМ UPDATE. Сбой refresh @@ -88,13 +111,15 @@ def get_session_user(db: Session, token: str) -> dict[str, Any] | None: if not token: return None + schema = identity_schema() row = db.execute( text( - """ + f""" 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 + u.username, u.role, u.display_name, u.org_name, u.email, + u.{schema.access_state_column} AS access_state + FROM {schema.sessions_table} s + JOIN {schema.users_table} u ON u.id = s.user_id WHERE s.token = :token """ ), @@ -107,15 +132,16 @@ def get_session_user(db: Session, token: str) -> dict[str, Any] | None: now = datetime.now(UTC) if row.expires_at is None or row.expires_at <= now: return None - if not row.is_active: + access_state = to_access_state(row.access_state) + if not access_state.can_sign_in: 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 + f""" + UPDATE {schema.sessions_table} SET last_seen_at = now(), expires_at = now() + make_interval(hours => CAST(:ttl_hours AS integer)) WHERE token = :token @@ -137,23 +163,35 @@ def get_session_user(db: Session, token: str) -> dict[str, Any] | None: "display_name": row.display_name, "org_name": row.org_name, "email": row.email, - "is_active": row.is_active, + # Всегда AccessState.ACTIVE — не-active сюда не доходит (см. выше). + # Ключ оставлен вместо прежнего `is_active`, чтобы состояние доступа во + # ВСЁМ коде называлось и выражалось одинаково. + "access_state": access_state, } def get_user_by_username(db: Session, username: str) -> dict[str, Any] | None: - """Возвращает строку `tradein_users` по username, или None если не найден. + """Возвращает строку реестра по username, или None если не найден. Используется login-флоу (`app.api.v1.auth.login`) для password-проверки. Отдаёт `password_hash` как есть (может быть NULL — переходный период, см. migration 192 docstring) — вызывающая сторона решает, что с ним делать. + + `access_state` — уже `AccessState` (не сырое значение колонки): решение + «пускать / не пускать / показать экран пробного периода» принимает login, + и принимать его он обязан по ОДНОМУ понятию, а не по boolean в одном режиме + и строке в другом. Отсутствие юзера состоянием НЕ выражается (None остаётся + None) — иначе login потерял бы разницу между «нет такого логина» и + «заблокирован», а она нужна ему для выбора события аудита. """ + schema = identity_schema() row = db.execute( text( - """ - SELECT id, username, password_hash, role, is_active, + f""" + SELECT id, username, password_hash, role, + {schema.access_state_column} AS access_state, display_name, org_name, email - FROM tradein_users + FROM {schema.users_table} WHERE username = :username """ ), @@ -168,7 +206,7 @@ def get_user_by_username(db: Session, username: str) -> dict[str, Any] | None: "username": row.username, "password_hash": row.password_hash, "role": row.role, - "is_active": row.is_active, + "access_state": to_access_state(row.access_state), "display_name": row.display_name, "org_name": row.org_name, "email": row.email, @@ -177,14 +215,19 @@ def get_user_by_username(db: Session, username: str) -> dict[str, Any] | None: def revoke_session(db: Session, token: str) -> None: """Удаляет одну сессию по токену (logout). No-op если токен не найден.""" - db.execute(text("DELETE FROM tradein_sessions WHERE token = :token"), {"token": token}) + schema = identity_schema() + db.execute(text(f"DELETE FROM {schema.sessions_table} 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}) + """Удаляет ВСЕ сессии юзера — смена пароля и блокировка обязаны рвать + активные сессии немедленно (см. `app.api.v1.team.update_employee`).""" + schema = identity_schema() + db.execute( + text(f"DELETE FROM {schema.sessions_table} WHERE user_id = :user_id"), + {"user_id": user_id}, + ) db.commit() @@ -192,7 +235,9 @@ def revoke_user_sessions(db: Session, user_id: int) -> None: # DB-role → RBAC scope (paths/deny) — #2552 dual-mode. # --------------------------------------------------------------------------- # -# tradein_users.role ('admin'|'manager'|'employee', CHECK-констрейнт migration 192) +# Роли реестра ('admin'|'manager'|'employee' — CHECK-констрейнт: tradein м.192 для +# tradein_users.role, auth м.004 для auth.users.role; наборы значений совпадают +# намеренно, чтобы код «Меры» переехал на общий реестр без правок в проверках роли) # НЕ являются ключами auth/roles.yaml (тот файл — legacy Caddy trusted-header путь, # который этот эпик намеренно не трогает). Маппинг ниже даёт DB-ролям тот же # paths/deny-смысл, что и legacy-ролям, БЕЗ правки roles.yaml: diff --git a/tradein-mvp/backend/app/services/identity_store.py b/tradein-mvp/backend/app/services/identity_store.py new file mode 100644 index 00000000..f20ec47e --- /dev/null +++ b/tradein-mvp/backend/app/services/identity_store.py @@ -0,0 +1,291 @@ +"""Единственное место, знающее, В КАКОЙ БД и В КАКИХ ТАБЛИЦАХ живёт identity. + +Эпик «единый вход»: люди «Меры» (trade-in) и «Птицы» (Site Finder) переезжают в +общую БД `auth` (`users` / `sessions`, миграции data/sql/auth/001-004), а +`tradein_users` в итоге удаляется. Переезд идёт под флагом +`settings.identity_store`, дефолт которого = СТАРОЕ поведение: + + "tradein" (ДЕФОЛТ) — БД tradein, tradein_users / tradein_sessions; + "auth" — БД auth, users / sessions. + +Смысл модуля: во всём остальном коде не должно быть ни одного упоминания +конкретной БД, конкретных имён таблиц и того, каким столбцом выражено состояние +доступа. Кто хочет читать/писать людей и сессии — спрашивает здесь. + +Что модуль отдаёт вызывающему: + * `identity_session()` / `get_identity_db()` — сессия ТОЙ БД, которая сейчас + является реестром (для "tradein" это ровно `app.core.db.SessionLocal`, то + есть сегодняшний прод-путь без единого лишнего коннекта); + * `identity_schema()` — имена таблиц users/sessions и имя колонки состояния + доступа; + * `AccessState` + `to_access_state()` — ОДНО понятие «состояние доступа» для + обеих схем. + +Схемы `tradein_users` и `auth.users` совпадают, кроме состояния доступа: +`tradein_users.is_active` — boolean, `auth.users.access_state` — text из трёх +значений (`active` / `trial_expired` / `disabled`, семантика — в COMMENT'е +миграции 004). Вызывающий код обязан работать с ОДНИМ понятием: он читает +колонку `schema.access_state_column` и прогоняет значение через +`to_access_state()`. Второго представления состояния в коде быть не должно — +`if row.is_active` вне этого модуля больше не пишем. + +Как СПРАШИВАТЬ состояние доступа (канонический вызов): + + schema = identity_schema() + with identity_session() as db: + row = db.execute( + text( + f"SELECT u.id, u.username, u.role, " + f" u.{schema.access_state_column} AS access_state " + f" FROM {schema.users_table} u " + f" WHERE u.username = :username" + ), + {"username": username}, + ).fetchone() + state = to_access_state(row.access_state) + if not state.can_sign_in: + ... # 401 для disabled, отдельный 403 для AccessState.TRIAL_EXPIRED + +Значение подставляется bind-параметром (`:username`), имя таблицы и имя колонки — +из `schema`, то есть из фиксированного словаря; в SQL-строку не попадает ничего, +пришедшего снаружи. + +Как ПИСАТЬ состояние доступа (обратное направление, `access_state_param()`): + + db.execute( + text( + f"UPDATE {schema.users_table} " + f" SET {schema.access_state_column} = :access_state " + f" WHERE id = :id" + ), + {"access_state": access_state_param(AccessState.DISABLED), "id": user_id}, + ) + +Литералов `True` / `'active'` по месту быть не должно: тип колонки разный, и +единственное место, знающее какой, — этот модуль. + +⚠️ SQL-инъекция по имени таблицы: имена таблиц/колонок в SQL нельзя передать +bind-параметром, поэтому они подставляются в строку запроса. Единственный +допустимый источник — фиксированный словарь `_SCHEMAS` НИЖЕ. Никакой +конкатенации с внешним вводом (заголовок, тело запроса, переменная окружения, +имя роли) — значение `settings.identity_store` ограничено `Literal` в pydantic, +и лукап по нему делается только здесь. +""" + +from __future__ import annotations + +import logging +from collections.abc import Generator, Iterator +from contextlib import contextmanager +from dataclasses import dataclass +from enum import StrEnum +from typing import Annotated + +from fastapi import Depends +from sqlalchemy.orm import Session + +from app.core import auth_db +from app.core.config import settings +from app.core.db import SessionLocal, get_db + +logger = logging.getLogger(__name__) + + +class AccessState(StrEnum): + """Состояние доступа аккаунта — ЕДИНОЕ понятие для обеих схем. + + Значения дословно совпадают с `auth.users.access_state` (CHECK-констрейнт + `users_access_state_ck`, миграция 004); булев `tradein_users.is_active` + приводится сюда в `to_access_state()`. + + Семантика (COMMENT миграции 004, решение владельца от 2026-07-31): + active — вход разрешён; + trial_expired — пароль ВЕРНЫЙ, но пробный период истёк: отдельный 403 и + экран «пробный доступ закончился», сессия не выдаётся; + disabled — доступ закрыт: generic 401, для пользователя неотличимо от + неверного пароля. + Неверный пароль в ЛЮБОМ состоянии → generic 401, иначе отдельный ответ для + trial_expired превращается в оракул существования логина. + """ + + ACTIVE = "active" + TRIAL_EXPIRED = "trial_expired" + DISABLED = "disabled" + + @property + def can_sign_in(self) -> bool: + """True только для `active` — единственная проверка «пускать ли». + + Вынесена в свойство, чтобы вызывающий не писал `state == "active"`: + добавится четвёртое состояние — оно по умолчанию окажется «не пускать», + а не «пускать, потому что не disabled». + """ + return self is AccessState.ACTIVE + + +@dataclass(frozen=True, slots=True) +class IdentitySchema: + """Где физически лежит identity при текущем значении флага. + + Attributes: + store: значение `settings.identity_store`, которому соответствует схема. + users_table: имя таблицы людей. + sessions_table: имя таблицы сессий. + access_state_column: имя колонки состояния доступа. Значение из неё + ОБЯЗАНО пройти через `to_access_state()` — тип отличается между + схемами (boolean против text). + access_state_sql_type: SQL-тип этой колонки для `CAST(:param AS ...)`. + Нужен там, где параметр может быть NULL (`COALESCE(CAST(:x AS T), col)` + в PATCH «Команды»): без явного типа Postgres не может вывести тип + NULL-параметра. Значение — литерал из `_SCHEMAS`, в SQL-строку + снаружи ничего не попадает. + """ + + store: str + users_table: str + sessions_table: str + access_state_column: str + access_state_sql_type: str + + +# Фиксированный словарь — ЕДИНСТВЕННЫЙ источник имён таблиц/колонок для SQL. +# Ключи = допустимые значения settings.identity_store (Literal в pydantic). +_SCHEMAS: dict[str, IdentitySchema] = { + "tradein": IdentitySchema( + store="tradein", + users_table="tradein_users", + sessions_table="tradein_sessions", + access_state_column="is_active", + access_state_sql_type="boolean", + ), + "auth": IdentitySchema( + store="auth", + # В БД `auth` таблицы лежат без префикса продукта — реестр общий + # (data/sql/auth/001_identity_schema.sql). + users_table="users", + sessions_table="sessions", + access_state_column="access_state", + access_state_sql_type="text", + ), +} + + +def identity_schema() -> IdentitySchema: + """Схема реестра для текущего значения `settings.identity_store`. + + Читается на КАЖДОМ вызове, а не кешируется на импорте: тесты и + переключение флага не должны требовать перезагрузки модулей. + """ + schema = _SCHEMAS.get(settings.identity_store) + if schema is None: + # Недостижимо через настройки (Literal валидируется pydantic), но + # молчаливый fallback здесь означал бы поход не в ту БД. + raise ValueError(f"неизвестный identity_store={settings.identity_store!r}") + return schema + + +@contextmanager +def identity_session() -> Iterator[Session]: + """Сессия БД, в которой сейчас живёт identity. + + "tradein" → `app.core.db.SessionLocal` (та же БД и тот же пул, что у всего + остального приложения — сегодняшнее поведение прода без изменений). + "auth" → ленивый engine `app.core.auth_db`; пустой `AUTH_DATABASE_URL` + здесь поднимет `AuthDatabaseNotConfiguredError`, а не отдаст пустой + результат. + """ + if settings.identity_store == "auth": + with auth_db.auth_session() as db: + yield db + else: + with SessionLocal() as db: + yield db + + +def get_identity_db( + db: Annotated[Session, Depends(get_db)], +) -> Generator[Session, None, None]: + """FastAPI-зависимость: `db: Annotated[Session, Depends(get_identity_db)]`. + + Аналог `app.core.db.get_db`, но для реестра людей. Роуты, работающие с + identity, обязаны брать сессию отсюда — иначе при `identity_store="auth"` + они уйдут запросом в БД tradein, где нужных таблиц уже не будет. + + ⚠️ При `identity_store="tradein"` отдаётся РОВНО ТОТ ЖЕ объект `Session`, + что и у `Depends(get_db)` — не новая сессия к той же БД. Это не экономия + коннекта, а требование «прод обязан работать точно как сейчас»: роуты + «Команды» пишут в ОДНОЙ транзакции строку сотрудника (реестр) и его квоту + (`account_quota_overrides`, продуктовая таблица). Две сессии = две + транзакции = состояние «сотрудник создан, квота нет» на ровном месте. + FastAPI кеширует результат `Depends(get_db)` в пределах запроса, поэтому + роут, объявивший ОБЕ зависимости, в этом режиме получает один и тот же + объект, и `db is identity_db` — честный рантайм-признак «одна БД». + + При `identity_store="auth"` это разные БД физически, и одной транзакции + быть не может (двухфазный коммит здесь не заводим): вызывающий код обязан + коммитить обе сессии и понимать порядок — см. `app.api.v1.team`. + Зависимость `get_db` при этом всё равно резолвится, но `Session` ленив — + без единого запроса он коннект не открывает, так что лишнего соединения с + БД tradein не появляется. + """ + if settings.identity_store != "auth": + yield db + return + with auth_db.auth_session() as identity_db: + yield identity_db + + +def to_access_state(value: object) -> AccessState: + """Приводит значение колонки состояния доступа к `AccessState`. + + ЕДИНСТВЕННОЕ место, где булев `tradein_users.is_active` превращается в + трёхзначное состояние: True → `active`, False → `disabled` (жёсткая + блокировка, generic 401 — ровно то, что булева схема и означала). + `trial_expired` в булевой схеме выразить нечем: состояния там не + существовало, и на tradein-пути оно не появится. + + Fail-closed: неизвестная строка, NULL и любой неожиданный тип → `disabled` + + WARNING. Обратный выбор (пускать всё, что не `disabled`) означал бы, что + новое состояние, добавленное миграцией раньше кода, молча раздаёт доступ. + """ + if isinstance(value, bool): + return AccessState.ACTIVE if value else AccessState.DISABLED + if isinstance(value, str): + try: + return AccessState(value) + except ValueError: + logger.warning( + "identity_store: неизвестное состояние доступа %r → трактую как disabled", value + ) + return AccessState.DISABLED + logger.warning( + "identity_store: состояние доступа %r неожиданного типа %s → трактую как disabled", + value, + type(value).__name__, + ) + return AccessState.DISABLED + + +def access_state_param(state: AccessState) -> bool | str: + """Значение для ЗАПИСИ в `schema.access_state_column` — обратная к `to_access_state()`. + + Тип колонки разный (boolean против text), поэтому конверсию нельзя оставить + вызывающему: он бы неизбежно писал `True`/`'active'` по месту, и это ровно + то второе представление состояния, которого в коде быть не должно. + + Для булевой схемы `trial_expired` невыразим — там существуют только «пустят» + и «не пустят», и попытка записать промежуточное состояние молча стала бы + жёсткой блокировкой (клиент увидел бы «неверный пароль» вместо экрана + пробного периода). Поэтому это ошибка вызывающего, а не тихое приведение: + писать `trial_expired` можно только при `identity_store="auth"`. + """ + schema = identity_schema() + if schema.access_state_sql_type == "boolean": + if state is AccessState.TRIAL_EXPIRED: + raise ValueError( + f"состояние {state.value!r} невыразимо в схеме {schema.store!r} " + f"(колонка {schema.access_state_column} — boolean): доступны только " + f"{AccessState.ACTIVE.value!r} и {AccessState.DISABLED.value!r}" + ) + return state.can_sign_in + return state.value diff --git a/tradein-mvp/backend/tests/support/identity_modes.py b/tradein-mvp/backend/tests/support/identity_modes.py new file mode 100644 index 00000000..73c5672a --- /dev/null +++ b/tradein-mvp/backend/tests/support/identity_modes.py @@ -0,0 +1,200 @@ +"""Помощники для тестов, зависящих от того, В КАКОМ РЕЕСТРЕ живут люди. + +Эпик «единый вход»: `settings.identity_store` переключает код «Меры» между +`tradein_users`/`tradein_sessions` (БД tradein — ДЕФОЛТ, сегодняшнее поведение +прода) и `users`/`sessions` (БД `auth`). Различаются имена таблиц И тип колонки +состояния доступа (`is_active boolean` против `access_state text`). + +⚠️ ЗАЧЕМ ЭТОТ МОДУЛЬ (главная ловушка этих тестов). Интеграционные тесты +`test_auth_api.py` / `test_team_api.py` используют fake-DB, который диспатчит по +ТЕКСТУ SQL. Если ветка такого fake'а сравнивает с литералом «tradein_users», то +при `identity_store="auth"` она просто перестаёт матчиться — fake вернёт пустой +результат вместо строки, а тест останется ЗЕЛЁНЫМ на сломанном коде. Поэтому: + + * имена для матчинга берутся из `identity_schema()` (`sql_names()` ниже) — + ровно оттуда же, откуда их берёт продакшн-код; + * непонятый SQL в fake'ах ОБЯЗАН падать `AssertionError`, а не возвращать + пустоту (см. `raise AssertionError(f"unhandled fake SQL ...")` в обоих + файлах) — это то, что превращает «ветка отвалилась» в красный тест. + +`column_value()` — намеренно ЛИТЕРАЛЬНАЯ таблица «состояние → значение +колонки», а НЕ вызов `identity_store.access_state_param()`. Fake обязан хранить +то, что реально лежало бы в Postgres; если бы он звал ту же production-функцию, +что и проверяемый код, её инверсия (`active` ↔ `disabled`) прошла бы round-trip +через fake незамеченной, и тест бы не покраснел. +""" + +from __future__ import annotations + +import re +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from dataclasses import dataclass +from typing import Any + +import pytest + +from app.core import auth_db, config +from app.services import identity_store +from app.services.identity_store import AccessState, identity_schema + +# Оба допустимых значения `IDENTITY_STORE` (Literal в pydantic-настройках). +# "tradein" ПЕРВЫЙ — это дефолт и путь прода; при чтении вывода pytest'а первый +# параметр всегда «как сейчас», второй — «после переезда». +IDENTITY_MODES = ("tradein", "auth") + +# Состояние доступа → значение, которое реально лежит в колонке реестра. +# Литералы, независимые от production-кода (см. модульный docstring). +# `trial_expired` в булевой схеме ОТСУТСТВУЕТ: состояния «пробный период истёк» +# там не существовало, выразить его нечем — тесты про него имеют смысл только в +# режиме `auth`, поэтому здесь явная ошибка вместо тихого приведения к False. +_COLUMN_VALUE: dict[tuple[str, AccessState], bool | str] = { + ("tradein", AccessState.ACTIVE): True, + ("tradein", AccessState.DISABLED): False, + ("auth", AccessState.ACTIVE): "active", + ("auth", AccessState.TRIAL_EXPIRED): "trial_expired", + ("auth", AccessState.DISABLED): "disabled", +} + + +def column_value(state: AccessState) -> bool | str: + """Значение состояния *state* в колонке реестра для ТЕКУЩЕГО режима.""" + store = config.settings.identity_store + try: + return _COLUMN_VALUE[(store, state)] + except KeyError: + raise AssertionError( + f"состояние {state.value!r} не существует в схеме {store!r} — " + f"такой тест имеет смысл только при identity_store='auth'" + ) from None + + +@dataclass(frozen=True, slots=True) +class SqlNames: + """Имена, по которым fake-DB узнаёт запрос в ТЕКУЩЕМ режиме.""" + + users: str + sessions: str + access_state_column: str + access_state_sql_type: str + + +def sql_names() -> SqlNames: + """Имена таблиц/колонки из `identity_schema()` — источник тот же, что у кода.""" + schema = identity_schema() + return SqlNames( + users=schema.users_table, + sessions=schema.sessions_table, + access_state_column=schema.access_state_column, + access_state_sql_type=schema.access_state_sql_type, + ) + + +def assert_reads_access_state(sql: str, names: SqlNames) -> None: + """Запрос, читающий состояние доступа, ОБЯЗАН брать колонку ТЕКУЩЕГО режима. + + Ставится в те ветки fake-DB, которые отдают строку человека. Без неё fake + остаётся ЗЕЛЁНЫМ на захардкоженном `is_active AS access_state`: строку он + собирает из `_Store`, где ключ УЖЕ называется `access_state`, и про имя + колонки в SELECT'е ничего не знает — то есть запрос, невозможный на реальном + Postgres (`column "is_active" does not exist` в БД `auth`), проехал бы молча. + + Измерено мутацией: захардкодить колонку в `team._employee_columns` — без + этой проверки все 48 тестов «Команды» остаются зелёными; с ней ветка + перестаёт матчиться, SQL доезжает до `raise AssertionError` в конце + `execute` и тесты краснеют. + + Алиас проверяется отдельно от имени колонки: без `AS access_state` + вызывающий код читал бы то `is_active`, то `access_state`, то есть завёл бы + второе представление состояния — ровно то, чего эпик не допускает. + """ + expected = f"{names.access_state_column} AS access_state" + if expected not in sql: + raise AssertionError( + f"запрос к реестру не читает колонку состояния текущего режима " + f"({expected!r}): {sql!r}" + ) + + +def assert_insert_writes_access_state(sql: str, names: SqlNames) -> None: + """INSERT в реестр обязан перечислять колонку состояния ТЕКУЩЕГО режима. + + Проверяется именно СПИСОК КОЛОНОК, а не наличие подстроки: bind-параметр + называется `:access_state` в обоих режимах, поэтому `... , :access_state)` + в VALUES матчился бы всегда — и `INSERT INTO users (..., is_active)` + (невозможный в БД `auth`) проехал бы молча. Измерено мутацией. + """ + match = re.search(rf"INSERT INTO\s+{re.escape(names.users)}\s*\(([^)]*)\)", sql) + if match is None: + raise AssertionError(f"не разобрал список колонок INSERT'а в реестр: {sql!r}") + columns = {c.strip() for c in match.group(1).split(",")} + if names.access_state_column not in columns: + raise AssertionError( + f"INSERT в реестр не пишет колонку состояния текущего режима " + f"({names.access_state_column!r}); в списке: {sorted(columns)}" + ) + + +def assert_update_writes_access_state(sql: str, names: SqlNames) -> None: + """UPDATE реестра обязан присваивать колонку состояния ТЕКУЩЕГО режима — и + кастовать параметр в ЕЁ тип. + + CAST здесь несущий: параметр может быть NULL («поле не пришло в PATCH» → + `COALESCE(CAST(:x AS T), col)`), и без явного типа Postgres тип NULL-параметра + не выведет. Захардкоженный `boolean` в текстовой схеме — ошибка уровня БД, + которую fake иначе не увидел бы. + """ + assignment = f"{names.access_state_column} = COALESCE(" + if assignment not in sql: + raise AssertionError( + f"UPDATE реестра не присваивает колонку состояния текущего режима " + f"({assignment!r}): {sql!r}" + ) + cast = f"CAST(:access_state AS {names.access_state_sql_type})" + if cast not in sql: + raise AssertionError( + f"UPDATE реестра кастует состояние не в тип текущей схемы ({cast!r}): {sql!r}" + ) + + +def use_identity_mode(monkeypatch: pytest.MonkeyPatch, mode: str) -> str: + """Переключает реестр на *mode* на время теста. + + `reset_auth_db()` — на случай, если предыдущий тест успел построить engine + БД `auth`: закешированный engine пережил бы monkeypatch настроек (он живёт в + module-global, а не в `settings`) и утёк бы сюда. + """ + auth_db.reset_auth_db() + monkeypatch.setattr(config.settings, "identity_store", mode) + return mode + + +def patch_identity_sessions(monkeypatch: pytest.MonkeyPatch, make_db: Callable[[], Any]) -> None: + """Подменяет ОБА источника сессии реестра так, чтобы работал РЕАЛЬНЫЙ + `identity_store.identity_session()` / `get_identity_db()`, а не их копия + в тесте. + + Точки подмены выбраны настолько «низко», насколько возможно: + * `identity_store.SessionLocal` — то, что открывает `identity_session()` + в режиме "tradein" (импортирован по имени, поэтому патчим в + `identity_store`, а не в `app.core.db`); + * `auth_db.auth_session` — то, что открывают `identity_session()` и + `get_identity_db()` в режиме "auth" (`identity_store` держит ссылку на + МОДУЛЬ `auth_db`, поэтому подмена атрибута модуля видна ему сразу). + + Благодаря этому ветвление по режиму остаётся на production-коде: тест не + повторяет его у себя, и регрессия в `get_identity_db` (например, если он + перестанет отдавать в режиме "tradein" тот же объект `Session`, что и + `get_db`) не сможет спрятаться за тестовым дублёром. + + *make_db* вызывается БЕЗ аргументов и обязан отдавать новый fake-Session, + поддерживающий `with ... as db` (как настоящая `Session`). + """ + + @contextmanager + def _fake_auth_session() -> Iterator[Any]: + with make_db() as db: + yield db + + monkeypatch.setattr(identity_store, "SessionLocal", make_db) + monkeypatch.setattr(auth_db, "auth_session", _fake_auth_session) diff --git a/tradein-mvp/backend/tests/test_auth_api.py b/tradein-mvp/backend/tests/test_auth_api.py index 23997f98..6f3d3da1 100644 --- a/tradein-mvp/backend/tests/test_auth_api.py +++ b/tradein-mvp/backend/tests/test_auth_api.py @@ -3,19 +3,36 @@ 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). +in-memory fake DB standing in for the identity registry: + - сессия РЕЕСТРА подменяется на самом низком уровне — `identity_store.SessionLocal` + и `auth_db.auth_session` (см. `tests.support.identity_modes.patch_identity_sessions`), + так что и `identity_session()` (rbac_guard — middleware, FastAPI-DI там нет), и + `Depends(get_identity_db)` (auth.py / me.py) выполняются РЕАЛЬНЫЕ, вместе со своим + ветвлением по `settings.identity_store`; + - `app.core.db.get_db` переопределён через `app.dependency_overrides` — это + продуктовая БД (в дефолтном режиме она же и реестр). -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. +Все они смотрят в ОДИН `_Store` на тест, поэтому сессия, созданная POST /login, +сразу видна собственному DB-раунд-трипу rbac_guard'а на следующем запросе. + +⚠️ ДВА РЕЖИМА РЕЕСТРА И ЛОВУШКА FAKE-DB. `_FakeDB` диспатчит по ТЕКСТУ SQL, а +эпик «единый вход» переименовывает таблицы (`tradein_users`/`tradein_sessions` → +`users`/`sessions`) и меняет тип колонки состояния доступа. Литерал +«tradein_users» в диспатчере означал бы, что при `IDENTITY_STORE=auth` ветка +молча перестаёт матчиться, fake отдаёт пустоту, а тест остаётся ЗЕЛЁНЫМ на +сломанном коде. Поэтому имена берутся из `sql_names()` (= `identity_schema()`, +тот же словарь, что у продакшн-кода), а непонятый SQL падает `AssertionError`, +а не возвращает пустой результат. + +Дефолт (`identity_store="tradein"`) — сегодняшний прод; тесты без фикстуры +`auth_store` идут именно в нём. Тесты про режим `auth` (в т.ч. про состояние +`trial_expired`, невыразимое булевым `is_active`) — в конце файла. """ from __future__ import annotations import os +import re from datetime import UTC, datetime, timedelta from types import SimpleNamespace from typing import Annotated, Any @@ -29,13 +46,21 @@ 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 import auth_db, config from app.core.db import get_db from app.core.password import hash_password from app.core.rbac import rbac_guard +from app.services.identity_store import AccessState +from tests.support.identity_modes import ( + assert_reads_access_state, + column_value, + patch_identity_sessions, + sql_names, + use_identity_mode, +) # --------------------------------------------------------------------------- -# Fake DB backing tradein_users / tradein_sessions +# Fake DB backing the identity registry (users/sessions таблицы текущего режима) # --------------------------------------------------------------------------- @@ -43,6 +68,7 @@ class _Store: def __init__(self) -> None: self.users: dict[str, dict[str, Any]] = {} self.sessions: dict[str, dict[str, Any]] = {} + self.sql_log: list[str] = [] # весь SQL, доехавший до «БД» — см. тесты режимов self._next_id = 1 def add_user( @@ -51,7 +77,7 @@ class _Store: password_hash: str | None, *, role: str = "employee", - is_active: bool = True, + access_state: AccessState = AccessState.ACTIVE, display_name: str | None = "Alice A.", org_name: str | None = "Org LLC", email: str | None = "alice@example.com", @@ -63,13 +89,19 @@ class _Store: "username": username, "password_hash": password_hash, "role": role, - "is_active": is_active, + # СЫРОЕ значение колонки текущего режима (boolean либо text) — ровно + # то, что вернул бы драйвер; в AccessState его превращает код. + "access_state": column_value(access_state), "display_name": display_name, "org_name": org_name, "email": email, } return uid + def set_access_state(self, username: str, state: AccessState) -> None: + """Меняет состояние доступа уже заведённого юзера (как сделал бы админ/миграция).""" + self.users[username]["access_state"] = column_value(state) + def user_by_id(self, uid: int) -> dict[str, Any] | None: for u in self.users.values(): if u["id"] == uid: @@ -109,8 +141,12 @@ class _FakeDB: def execute(self, stmt: object, params: dict[str, Any] | None = None) -> SimpleNamespace: sql = str(stmt) p = params or {} + # Имена таблиц берутся ИЗ КОДА (identity_schema), а не из литералов — + # см. «ЛОВУШКА FAKE-DB» в модульном docstring. + names = sql_names() + self.store.sql_log.append(sql) - if "INSERT INTO tradein_sessions" in sql: + if f"INSERT INTO {names.sessions}" in sql: now = datetime.now(UTC) self.store.sessions[p["token"]] = { "user_id": p["user_id"], @@ -119,7 +155,7 @@ class _FakeDB: } return SimpleNamespace(fetchone=lambda: None) - if "UPDATE tradein_sessions" in sql and "SET last_seen_at" in sql: + if f"UPDATE {names.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) @@ -127,23 +163,27 @@ class _FakeDB: sess["expires_at"] = now + timedelta(hours=p["ttl_hours"]) return SimpleNamespace(fetchone=lambda: None) - if "DELETE FROM tradein_sessions WHERE token" in sql: + if f"DELETE FROM {names.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: + if f"DELETE FROM {names.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: + if f"FROM {names.sessions} s" in sql and f"JOIN {names.users} u" in sql: + assert_reads_access_state(sql, names) 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) + # Колонка состояния приезжает под алиасом `access_state` в ОБОИХ + # режимах (`u.<колонка> AS access_state` в реальном SELECT'е); + # значение — сырое, типа своей схемы. row = SimpleNamespace( user_id=sess["user_id"], expires_at=sess["expires_at"], @@ -153,11 +193,12 @@ class _FakeDB: display_name=user["display_name"], org_name=user["org_name"], email=user["email"], - is_active=user["is_active"], + access_state=user["access_state"], ) return SimpleNamespace(fetchone=lambda: row) - if "FROM tradein_users" in sql: + if f"FROM {names.users}" in sql and "WHERE username = :username" in sql: + assert_reads_access_state(sql, names) user = self.store.users.get(p["username"]) if user is None: return SimpleNamespace(fetchone=lambda: None) @@ -214,6 +255,9 @@ 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") + # Каждый тест стартует в ДЕФОЛТНОМ режиме реестра (сегодняшний прод), даже + # если предыдущий переключался на `auth`. + use_identity_mode(monkeypatch, "tradein") @pytest.fixture @@ -221,9 +265,23 @@ def store() -> _Store: return _Store() +@pytest.fixture +def auth_store(store: _Store, monkeypatch: pytest.MonkeyPatch) -> _Store: + """Тот же `store`, но реестр — БД `auth` (`users`/`sessions`, text-состояние). + + Запрашивай ПЕРЕД `client` в списке аргументов теста: `client` строится уже с + учётом режима (`_build_test_app` читает его лениво, но `store.add_user` + сохраняет значение колонки по режиму НА МОМЕНТ ВЫЗОВА). + """ + use_identity_mode(monkeypatch, "auth") + return store + + @pytest.fixture def client(store: _Store, monkeypatch: pytest.MonkeyPatch) -> TestClient: - monkeypatch.setattr("app.core.rbac.SessionLocal", lambda: _FakeDB(store)) + # Подменяем сессию РЕЕСТРА на обоих её источниках сразу, а не ветвление по + # режиму: `identity_session()` / `get_identity_db()` остаются настоящими. + patch_identity_sessions(monkeypatch, 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 @@ -280,7 +338,9 @@ def test_login_unknown_username_401_generic_message(client: TestClient) -> None: def test_login_inactive_user_401(client: TestClient, store: _Store) -> None: - store.add_user("bob", hash_password("Secret123!"), role="employee", is_active=False) + store.add_user( + "bob", hash_password("Secret123!"), role="employee", access_state=AccessState.DISABLED + ) resp = client.post("/api/v1/auth/login", json={"username": "bob", "password": "Secret123!"}) assert resp.status_code == 401 @@ -611,3 +671,193 @@ def test_cyrillic_username_session_propagation_does_not_500( # latin-1 "replace" гарантированно не крашит — точное значение (что именно # получится из non-latin1 байт) не является контрактом, важно отсутствие 500. assert resp.json()["user"] is not None + + +# --------------------------------------------------------------------------- +# Эпик «единый вход»: режим IDENTITY_STORE=auth (общий реестр в БД `auth`). +# +# Всё выше идёт в ДЕФОЛТНОМ режиме — он же прод — и служит регрессионным +# доказательством «после мержа работает точно как сейчас». Ниже — поведение, +# которое появляется ТОЛЬКО после переезда: трёхзначное состояние доступа +# (`active` / `trial_expired` / `disabled`) вместо булева `is_active`. +# --------------------------------------------------------------------------- + + +def test_default_mode_talks_to_tradein_tables_only(client: TestClient, store: _Store) -> None: + """Дефолт трогает РОВНО сегодняшние таблицы — и ни одной таблицы реестра `auth`. + + Пин на случай, если флаг когда-нибудь начнёт «протекать» (например, дефолт + поменяют или ветвление уедет не туда): расхождение здесь означало бы, что + прод после мержа пошёл в другую БД. + """ + store.add_user("alice", hash_password("Secret123!"), role="employee") + client.post("/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"}) + assert client.get("/api/v1/me").status_code == 200 + + joined = "\n".join(store.sql_log) + assert "tradein_users" in joined + assert "tradein_sessions" in joined + # Ни один запрос не адресован таблицам общего реестра. + assert not re.search(r"\b(FROM|INTO|UPDATE|JOIN)\s+users\b", joined) + assert not re.search(r"\b(FROM|INTO|UPDATE|JOIN)\s+sessions\b", joined) + # И engine БД `auth` даже не создавался (AUTH_DATABASE_URL на проде пуст — + # ленивое построение обязано не случиться, иначе запрос упал бы). + assert auth_db._engine is None + + +def test_auth_mode_talks_to_shared_registry_tables(auth_store: _Store, client: TestClient) -> None: + """Зеркало предыдущего: при IDENTITY_STORE=auth запросы уходят в users/sessions.""" + auth_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 client.get("/api/v1/me").status_code == 200 + + joined = "\n".join(auth_store.sql_log) + assert "tradein_users" not in joined + assert "tradein_sessions" not in joined + assert re.search(r"FROM\s+users\b", joined) + assert re.search(r"INSERT INTO\s+sessions\b", joined) + + +def test_login_trial_expired_403_with_code_and_no_session( + auth_store: _Store, client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """ВЕРНЫЙ пароль + `trial_expired` → 403 с машиночитаемым кодом, сессии НЕТ. + + Единственный не-generic ответ логина: аккаунт существует и владелец это уже + доказал паролем, так что осмысленный текст постороннему ничего не выдаёт. + """ + auth_store.add_user( + "trialguy", + hash_password("Secret123!"), + role="employee", + access_state=AccessState.TRIAL_EXPIRED, + ) + 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": "trialguy", "password": "Secret123!"} + ) + + assert resp.status_code == 403, resp.text + detail = resp.json()["detail"] + # Контракт для фронта — `code`, а не текст сообщения. + assert detail["code"] == "access_expired" + assert detail["message"] + # Сессия не выдана: ни куки, ни строки в реестре. + assert config.settings.session_cookie_name not in resp.cookies + assert auth_store.sessions == {} + assert [e["event_type"] for e in events] == ["login_blocked_expired"] + + +def test_login_wrong_password_on_trial_expired_is_generic_401( + auth_store: _Store, client: TestClient +) -> None: + """НЕверный пароль на `trial_expired` → тот же generic 401, что у чужого логина. + + Иначе отдельный 403 превращается в оракул существования аккаунта: перебором + можно было бы перечислить логины, не зная ни одного пароля. + """ + auth_store.add_user( + "trialguy", + hash_password("Secret123!"), + role="employee", + access_state=AccessState.TRIAL_EXPIRED, + ) + + wrong_pw = client.post("/api/v1/auth/login", json={"username": "trialguy", "password": "nope"}) + ghost = client.post("/api/v1/auth/login", json={"username": "ghost", "password": "nope"}) + + assert wrong_pw.status_code == 401 + # Побайтово тот же ответ, что и на несуществующий логин. + assert wrong_pw.json() == ghost.json() + assert auth_store.sessions == {} + + +def test_login_disabled_is_generic_401_not_403(auth_store: _Store, client: TestClient) -> None: + """`disabled` + верный пароль → generic 401, НЕ 403: заблокированный аккаунт + для пользователя неотличим от несуществующего (в отличие от `trial_expired`, + у которого есть свой экран).""" + auth_store.add_user( + "blocked", + hash_password("Secret123!"), + role="employee", + access_state=AccessState.DISABLED, + ) + + blocked = client.post( + "/api/v1/auth/login", json={"username": "blocked", "password": "Secret123!"} + ) + ghost = client.post("/api/v1/auth/login", json={"username": "ghost", "password": "x"}) + + assert blocked.status_code == 401 + assert blocked.json() == ghost.json() + assert auth_store.sessions == {} + + +def test_unknown_access_state_is_fail_closed_401(auth_store: _Store, client: TestClient) -> None: + """Состояние, которого код не знает (миграция уехала вперёд кода), НЕ пускает.""" + auth_store.add_user("newbie", hash_password("Secret123!"), role="employee") + auth_store.users["newbie"]["access_state"] = "pending_review" + + resp = client.post("/api/v1/auth/login", json={"username": "newbie", "password": "Secret123!"}) + + assert resp.status_code == 401 + assert auth_store.sessions == {} + + +@pytest.mark.parametrize("state", [AccessState.TRIAL_EXPIRED, AccessState.DISABLED]) +def test_live_session_dies_when_access_state_leaves_active( + auth_store: _Store, client: TestClient, state: AccessState +) -> None: + """Уже выданная сессия перестаёт работать СРАЗУ, как только состояние != active. + + Без этого sliding-refresh (`get_session_user` продлевает expires_at на каждом + запросе) держал бы сессию истёкшего/заблокированного бесконечно долго. + """ + auth_store.add_user("alice", hash_password("Secret123!"), role="employee") + login = client.post("/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"}) + assert login.status_code == 200 + assert client.get("/api/v1/trade-in/dummy").status_code == 200 + + auth_store.set_access_state("alice", state) + + # auth_mode=dual, но legacy-заголовка нет → сессия больше не резолвится → 401. + assert client.get("/api/v1/trade-in/dummy").status_code == 401 + assert client.get("/api/v1/me").status_code == 401 + + +def test_session_identity_wins_over_spoofed_header_auth_store( + auth_store: _Store, client: TestClient +) -> None: + """Перезапись X-Authenticated-User в ASGI-scope работает и на общем реестре. + + Тот же CRITICAL, что и в дефолтном режиме (см. выше): подделанный клиентом + заголовок не должен выигрывать у резолвленной сессии ни в одном режиме — эти + ~15 downstream-хендлеров читают сырой заголовок и про режим ничего не знают. + """ + auth_store.add_user("alice", hash_password("Secret123!"), role="employee") + auth_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_auth_mode_role_scope_and_logout(auth_store: _Store, client: TestClient) -> None: + """Роль/скоуп и logout на общем реестре ведут себя как в дефолтном режиме.""" + auth_store.add_user("mgr", hash_password("Secret123!"), role="manager") + login = client.post("/api/v1/auth/login", json={"username": "mgr", "password": "Secret123!"}) + token = login.cookies[config.settings.session_cookie_name] + assert token in auth_store.sessions + + body = client.get("/api/v1/me").json() + assert body["role"] == "manager" + assert "/api/v1/team/**" in body["allowed_paths"] + assert "/trade-in/sale-share/**" in body["deny_paths"] + + assert client.post("/api/v1/auth/logout").status_code == 200 + assert token not in auth_store.sessions diff --git a/tradein-mvp/backend/tests/test_auth_session.py b/tradein-mvp/backend/tests/test_auth_session.py index b45ef98a..4595b6b9 100644 --- a/tradein-mvp/backend/tests/test_auth_session.py +++ b/tradein-mvp/backend/tests/test_auth_session.py @@ -2,15 +2,28 @@ Coverage: - create_session: INSERT with CAST(...) (never `:x::type`), commit, unique tokens. - - get_session_user: valid/expired/inactive/missing-row + sliding refresh (only when + - get_session_user: valid/expired/не-active/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. + - get_user_by_username: found/not-found + состояние доступа как `AccessState`. - 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. + +⚠️ ОБА РЕЖИМА РЕЕСТРА. Эпик «единый вход» вынес имена таблиц и имя/тип колонки +состояния доступа в `identity_store.identity_schema()`. Тесты, которые вообще +трогают SQL, прогоняются в ОБОИХ режимах (фикстура `identity_mode`): "tradein" +(дефолт, сегодняшний прод — `tradein_users`/`tradein_sessions`, boolean +`is_active`) и "auth" (`users`/`sessions`, text `access_state`). Ожидаемые имена +в ассертах берутся из `identity_schema()` — из того же словаря, что и у кода, +поэтому переименование таблиц не «разъезжает» тест с реальностью тихо; +поломка запроса ловится тем, что fake отдаёт строку ТОЛЬКО на ожидаемый SQL, +а сам SQL проверяется явными ассертами ниже. + +Тесты БЕЗ фикстуры `identity_mode` намеренно идут в дефолтном режиме +(`_default_identity_mode` autouse) — это чистая логика без SQL. """ from __future__ import annotations @@ -23,7 +36,34 @@ from typing import Any os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") +import pytest + from app.services import auth_session as svc +from app.services.identity_store import AccessState, identity_schema +from tests.support.identity_modes import IDENTITY_MODES, column_value, use_identity_mode + +# --------------------------------------------------------------------------- +# Режим реестра +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _default_identity_mode(monkeypatch: pytest.MonkeyPatch) -> None: + """Каждый тест стартует в ДЕФОЛТНОМ режиме, даже если предыдущий его менял.""" + use_identity_mode(monkeypatch, "tradein") + + +@pytest.fixture(params=IDENTITY_MODES) +def identity_mode(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch) -> str: + """Тест прогоняется дважды: "tradein" (прод) и "auth" (после переезда).""" + return use_identity_mode(monkeypatch, request.param) + + +@pytest.fixture +def auth_mode(monkeypatch: pytest.MonkeyPatch) -> str: + """Только режим "auth" — для состояний, невыразимых булевой колонкой.""" + return use_identity_mode(monkeypatch, "auth") + # --------------------------------------------------------------------------- # Fake DB session @@ -58,6 +98,11 @@ class _FakeDB: self.rolled_back += 1 +# Часовой «аргумент не передан» — None здесь занят (это валидное сырое значение +# колонки: NULL, который to_access_state обязан трактовать как disabled). +_MISSING = object() + + def _session_row( *, user_id: int = 1, @@ -65,8 +110,17 @@ def _session_row( last_seen_at: datetime | None = None, username: str = "alice", role: str = "employee", - is_active: bool = True, + access_state: AccessState = AccessState.ACTIVE, + raw_access_state: object = _MISSING, ) -> SimpleNamespace: + """Строка JOIN'а sessions×users, как её отдал бы драйвер. + + Колонка состояния всегда приезжает под алиасом `access_state` (`AS access_state` + в реальном SELECT'е), а ЗНАЧЕНИЕ в ней — то, что лежит в БД текущего режима: + boolean для `tradein_users.is_active`, text для `auth.users.access_state`. + *raw_access_state* — обход таблицы состояний для проверки fail-closed на + значении, которого код не знает. + """ now = datetime.now(UTC) return SimpleNamespace( user_id=user_id, @@ -77,7 +131,9 @@ def _session_row( display_name="Alice A.", org_name="Org LLC", email="alice@example.com", - is_active=is_active, + access_state=( + column_value(access_state) if raw_access_state is _MISSING else raw_access_state + ), ) @@ -87,14 +143,14 @@ def _user_row( username: str = "alice", password_hash: str | None = "hash", role: str = "employee", - is_active: bool = True, + access_state: AccessState = AccessState.ACTIVE, ) -> SimpleNamespace: return SimpleNamespace( id=user_id, username=username, password_hash=password_hash, role=role, - is_active=is_active, + access_state=column_value(access_state), display_name="Alice A.", org_name="Org LLC", email="alice@example.com", @@ -106,14 +162,14 @@ def _user_row( # --------------------------------------------------------------------------- -def test_create_session_inserts_and_commits() -> None: +def test_create_session_inserts_and_commits(identity_mode: str) -> 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 f"INSERT INTO {identity_schema().sessions_table}" in sql assert params is not None assert params["user_id"] == 42 assert params["ip"] == "1.2.3.4" @@ -123,7 +179,7 @@ def test_create_session_inserts_and_commits() -> None: assert len(token) >= 32 -def test_create_session_cast_not_doublecolon() -> None: +def test_create_session_cast_not_doublecolon(identity_mode: str) -> None: db = _FakeDB() svc.create_session(db, user_id=1) sql, _ = db.executed[0] @@ -150,16 +206,20 @@ def test_get_session_user_no_token_returns_none() -> None: assert db.executed == [] -def test_get_session_user_missing_row_returns_none() -> None: +def test_get_session_user_missing_row_returns_none(identity_mode: str) -> None: + schema = identity_schema() 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 f"FROM {schema.sessions_table} s" in sql + assert f"JOIN {schema.users_table} u" in sql + # Колонка состояния — под именем текущей схемы и обязательно с алиасом: + # без него вызывающий код читал бы то `is_active`, то `access_state`. + assert f"u.{schema.access_state_column} AS access_state" in sql assert params == {"token": "tok"} -def test_get_session_user_expired_returns_none() -> None: +def test_get_session_user_expired_returns_none(identity_mode: str) -> 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 @@ -167,13 +227,36 @@ def test_get_session_user_expired_returns_none() -> None: assert len(db.executed) == 1 -def test_get_session_user_inactive_returns_none() -> None: - db = _FakeDB(rows=[_session_row(is_active=False)]) +def test_get_session_user_disabled_returns_none(identity_mode: str) -> None: + """Жёстко заблокированный аккаунт — сессия недействительна в обеих схемах.""" + db = _FakeDB(rows=[_session_row(access_state=AccessState.DISABLED)]) assert svc.get_session_user(db, "tok") is None assert len(db.executed) == 1 -def test_get_session_user_valid_recent_no_refresh() -> None: +def test_get_session_user_trial_expired_returns_none(auth_mode: str) -> None: + """Пробный период истёк — УЖЕ ВЫДАННАЯ сессия гасится немедленно. + + Иначе сотрудник, залогиненный до истечения пробного доступа, продолжал бы + работать, а sliding-refresh продлевал бы ему `expires_at` бесконечно — + состояние `trial_expired` не наступило бы для него никогда. + """ + db = _FakeDB(rows=[_session_row(access_state=AccessState.TRIAL_EXPIRED)]) + assert svc.get_session_user(db, "tok") is None + # Ни UPDATE (sliding refresh), ни commit — сессия не продлевается. + assert len(db.executed) == 1 + assert db.committed == 0 + + +def test_get_session_user_unknown_state_returns_none(auth_mode: str) -> None: + """Fail-closed: состояние, которого код не знает (миграция впереди кода), + НЕ пускает. Обратный выбор молча раздавал бы доступ по новому значению.""" + db = _FakeDB(rows=[_session_row(raw_access_state="pending_review")]) + assert svc.get_session_user(db, "tok") is None + assert len(db.executed) == 1 + + +def test_get_session_user_valid_recent_no_refresh(identity_mode: str) -> None: """last_seen_at свежий (<5 мин) — sliding refresh НЕ триггерится.""" now = datetime.now(UTC) db = _FakeDB(rows=[_session_row(last_seen_at=now - timedelta(minutes=1))]) @@ -186,12 +269,15 @@ def test_get_session_user_valid_recent_no_refresh() -> None: assert result["org_name"] == "Org LLC" assert result["email"] == "alice@example.com" assert result["user_id"] == 1 + # Состояние доступа приезжает ЕДИНЫМ понятием, а не boolean/str по режимам; + # сюда доходит только ACTIVE (не-active отсеян выше). + assert result["access_state"] is AccessState.ACTIVE # Только 1 execute (SELECT) — никакого UPDATE. assert len(db.executed) == 1 assert db.committed == 0 -def test_get_session_user_stale_last_seen_triggers_refresh() -> None: +def test_get_session_user_stale_last_seen_triggers_refresh(identity_mode: str) -> 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))]) @@ -200,7 +286,7 @@ def test_get_session_user_stale_last_seen_triggers_refresh() -> None: 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 f"UPDATE {identity_schema().sessions_table}" 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 @@ -208,7 +294,7 @@ def test_get_session_user_stale_last_seen_triggers_refresh() -> None: assert db.committed == 1 -def test_get_session_user_refresh_failure_is_swallowed() -> None: +def test_get_session_user_refresh_failure_is_swallowed(identity_mode: str) -> None: """Sliding-refresh UPDATE падает — всё равно возвращаем валидного юзера (best-effort refresh, не часть решения "валидна ли сессия").""" now = datetime.now(UTC) @@ -228,7 +314,8 @@ def test_get_session_user_refresh_failure_is_swallowed() -> None: # --------------------------------------------------------------------------- -def test_get_user_by_username_found() -> None: +def test_get_user_by_username_found(identity_mode: str) -> None: + schema = identity_schema() db = _FakeDB(rows=[_user_row()]) user = svc.get_user_by_username(db, "alice") @@ -236,13 +323,38 @@ def test_get_user_by_username_found() -> None: assert user["username"] == "alice" assert user["password_hash"] == "hash" assert user["role"] == "employee" - assert user["is_active"] is True + assert user["access_state"] is AccessState.ACTIVE sql, params = db.executed[0] - assert "FROM tradein_users" in sql + assert f"FROM {schema.users_table}" in sql + assert f"{schema.access_state_column} AS access_state" in sql assert params == {"username": "alice"} -def test_get_user_by_username_not_found() -> None: +def test_get_user_by_username_disabled_state_is_reported_not_hidden(identity_mode: str) -> None: + """Строка отдаётся ВСЕГДА, состояние — отдельным полем. + + Login обязан отличать «нет такого логина» (None) от «есть, но доступ закрыт» + (строка + не-ACTIVE): от этого зависит выбор события аудита, а прятать + заблокированного за None означало бы потерять эту разницу. + """ + db = _FakeDB(rows=[_user_row(access_state=AccessState.DISABLED)]) + user = svc.get_user_by_username(db, "alice") + + assert user is not None + assert user["access_state"] is AccessState.DISABLED + assert user["access_state"].can_sign_in is False + + +def test_get_user_by_username_trial_expired_state(auth_mode: str) -> None: + db = _FakeDB(rows=[_user_row(access_state=AccessState.TRIAL_EXPIRED)]) + user = svc.get_user_by_username(db, "alice") + + assert user is not None + assert user["access_state"] is AccessState.TRIAL_EXPIRED + assert user["access_state"].can_sign_in is False + + +def test_get_user_by_username_not_found(identity_mode: str) -> None: db = _FakeDB(rows=[None]) assert svc.get_user_by_username(db, "ghost") is None @@ -252,24 +364,24 @@ def test_get_user_by_username_not_found() -> None: # --------------------------------------------------------------------------- -def test_revoke_session_deletes_and_commits() -> None: +def test_revoke_session_deletes_and_commits(identity_mode: str) -> 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 f"DELETE FROM {identity_schema().sessions_table}" in sql assert "token" in sql assert params == {"token": "tok"} -def test_revoke_user_sessions_deletes_and_commits() -> None: +def test_revoke_user_sessions_deletes_and_commits(identity_mode: str) -> 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 f"DELETE FROM {identity_schema().sessions_table}" in sql assert "user_id" in sql assert params == {"user_id": 7} diff --git a/tradein-mvp/backend/tests/test_identity_store.py b/tradein-mvp/backend/tests/test_identity_store.py new file mode 100644 index 00000000..59eba9f5 --- /dev/null +++ b/tradein-mvp/backend/tests/test_identity_store.py @@ -0,0 +1,481 @@ +"""Tests for app.services.identity_store + app.core.auth_db — эпик «единый вход». + +`identity_store` — единственное место, знающее, В КАКОЙ БД и В КАКИХ ТАБЛИЦАХ +живёт identity. Всё остальное (auth_session, rbac, роуты) спрашивает у него, и +поэтому ошибка ЗДЕСЬ — это ошибка сразу везде. + +Главное, что пинят эти тесты (⚠️ ограничение PR: после мержа прод обязан +работать ТОЧНО как сейчас): + + 1. ДЕФОЛТ = старое поведение. `IDENTITY_STORE` не задан → `tradein_users` / + `tradein_sessions`, boolean-колонка, сессия из `app.core.db.SessionLocal`. + 2. При дефолте код НЕ ТРОГАЕТ БД `auth` вообще: engine не строится, пустой + `AUTH_DATABASE_URL` не ошибка. На проде роль `auth_app` ещё без пароля и + DSN не заведён — любое обращение туда было бы отказом входа. + 3. `IDENTITY_STORE=auth` + пустой DSN → ЯВНАЯ `AuthDatabaseNotConfiguredError`, + а не тихий фолбэк на tradein-таблицы и не пустой результат. Молчаливая + деградация auth-пути читалась бы как «неверный пароль» у всех сразу. + 4. `get_identity_db` в дефолтном режиме отдаёт ТОТ ЖЕ объект `Session`, что и + `get_db` — «Команда» пишет строку сотрудника и его квоту одной транзакцией. + Регрессия здесь дала бы состояние «сотрудник создан, квота нет». + 5. Литералы значений состояния (`True`/`'active'`/...) — пин по таблице + значений, а не round-trip через `to_access_state`: инверсия + `access_state_param` обязана быть видна. +""" + +from __future__ import annotations + +import os +from typing import Annotated, Any + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import pytest +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient +from sqlalchemy import Engine + +from app.core import auth_db, config +from app.core.db import get_db +from app.core.rbac import rbac_guard +from app.services import identity_store +from app.services.identity_store import ( + AccessState, + access_state_param, + get_identity_db, + identity_schema, + identity_session, + to_access_state, +) +from tests.support.identity_modes import IDENTITY_MODES, use_identity_mode + +_FAKE_AUTH_DSN = "postgresql+psycopg://auth_app:secret@localhost:5432/auth" + + +@pytest.fixture(autouse=True) +def _clean_identity_state(monkeypatch: pytest.MonkeyPatch): + """Дефолтный режим + пустой DSN + сброшенный engine до И после теста. + + Engine БД `auth` живёт в module-global, а не в `settings`, поэтому + monkeypatch его не откатывает — держим сброс явно с обеих сторон, иначе + построенный здесь engine утёк бы в любой следующий тест сьюта. + """ + auth_db.reset_auth_db() + monkeypatch.setattr(config.settings, "identity_store", "tradein") + monkeypatch.setattr(config.settings, "auth_database_url", "") + yield + auth_db.reset_auth_db() + + +class _FakeSession: + """Session-заглушка: тестам здесь важна ИДЕНТИЧНОСТЬ объекта, не поведение.""" + + def __enter__(self) -> _FakeSession: + return self + + def __exit__(self, *exc: object) -> bool: + return False + + def close(self) -> None: + pass + + +# --------------------------------------------------------------------------- +# identity_schema — имена, попадающие прямо в SQL +# --------------------------------------------------------------------------- + + +def test_settings_defaults_are_legacy_mode(monkeypatch: pytest.MonkeyPatch) -> None: + """⚠️ ГЛАВНЫЙ ИНВАРИАНТ PR, пин НАПРЯМУЮ по классу настроек. + + Все остальные identity-тесты работают под autouse-фикстурой, которая + ПРИНУДИТЕЛЬНО выставляет `identity_store="tradein"` — то есть проверяют + поведение при уже выбранном режиме, а не сам дефолт. Перевернись + `Field(default=...)` в config.py — они бы этого не заметили, и прод молча + ушёл бы в БД `auth`, где ещё нет ни пароля роли `auth_app`, ни данных. + + Поэтому здесь настройки конструируются заново, минуя `config.settings`: + * `_env_file=None` — не читать локальный `.env` (дев-машина или CI могут + держать там свои значения; пиним ДЕФОЛТ КОДА, а не окружение); + * `delenv` обеих переменных — то же самое для переменных процесса. + Останется ровно то, что записано литералом в `Settings`. + """ + monkeypatch.delenv("IDENTITY_STORE", raising=False) + monkeypatch.delenv("AUTH_DATABASE_URL", raising=False) + + fresh = config.Settings(_env_file=None) # type: ignore[call-arg] + + assert fresh.identity_store == "tradein", ( + "дефолт IDENTITY_STORE обязан остаться 'tradein': прод после мержа должен " + "работать ТОЧНО как сейчас, на tradein_users/tradein_sessions" + ) + assert fresh.auth_database_url == "", ( + "AUTH_DATABASE_URL обязан быть пуст по умолчанию: на проде DSN роли " + "auth_app ещё не заведён, и пустое значение не должно ронять старт" + ) + + +def test_default_schema_is_todays_production(monkeypatch: pytest.MonkeyPatch) -> None: + """Без переменной окружения — ровно сегодняшние таблицы «Меры».""" + schema = identity_schema() + assert schema.store == "tradein" + assert schema.users_table == "tradein_users" + assert schema.sessions_table == "tradein_sessions" + assert schema.access_state_column == "is_active" + assert schema.access_state_sql_type == "boolean" + + +def test_auth_schema_points_at_shared_registry(monkeypatch: pytest.MonkeyPatch) -> None: + """В БД `auth` таблицы без префикса продукта — реестр общий на «Меру» и «Птицу».""" + use_identity_mode(monkeypatch, "auth") + schema = identity_schema() + assert schema.store == "auth" + assert schema.users_table == "users" + assert schema.sessions_table == "sessions" + assert schema.access_state_column == "access_state" + assert schema.access_state_sql_type == "text" + + +def test_schema_is_read_per_call_not_cached_at_import(monkeypatch: pytest.MonkeyPatch) -> None: + """Флаг читается на КАЖДОМ вызове: переключение не требует перезагрузки модулей.""" + assert identity_schema().users_table == "tradein_users" + use_identity_mode(monkeypatch, "auth") + assert identity_schema().users_table == "users" + + +def test_unknown_store_raises_instead_of_silent_fallback(monkeypatch: pytest.MonkeyPatch) -> None: + """Значение вне словаря — ошибка, а не «ну возьмём tradein». + + Недостижимо через настройки (`Literal` валидируется pydantic), но молчаливый + фолбэк здесь означал бы поход не в ту БД. + """ + monkeypatch.setattr(config.settings, "identity_store", "elsewhere") + with pytest.raises(ValueError, match="elsewhere"): + identity_schema() + + +@pytest.mark.parametrize("mode", IDENTITY_MODES) +def test_table_names_never_come_from_outside(monkeypatch: pytest.MonkeyPatch, mode: str) -> None: + """Имена таблиц — только из фиксированного словаря (защита от SQL-инъекции по имени). + + Имя таблицы нельзя передать bind-параметром, оно склеивается в строку запроса, + поэтому единственный допустимый источник — `_SCHEMAS`. Тест пинит, что весь + набор значений конечен и не содержит ничего, кроме идентификаторов. + """ + use_identity_mode(monkeypatch, mode) + schema = identity_schema() + for name in (schema.users_table, schema.sessions_table, schema.access_state_column): + assert name.replace("_", "").isalnum(), name + assert schema.access_state_sql_type in ("boolean", "text") + + +# --------------------------------------------------------------------------- +# AccessState / to_access_state — ОДНО понятие состояния на обе схемы +# --------------------------------------------------------------------------- + + +def test_only_active_can_sign_in() -> None: + assert AccessState.ACTIVE.can_sign_in is True + assert AccessState.TRIAL_EXPIRED.can_sign_in is False + assert AccessState.DISABLED.can_sign_in is False + + +def test_boolean_column_maps_to_active_disabled() -> None: + """Булев `tradein_users.is_active` — ровно два состояния, `trial_expired` там нет.""" + assert to_access_state(True) is AccessState.ACTIVE + assert to_access_state(False) is AccessState.DISABLED + + +def test_text_column_maps_by_value() -> None: + assert to_access_state("active") is AccessState.ACTIVE + assert to_access_state("trial_expired") is AccessState.TRIAL_EXPIRED + assert to_access_state("disabled") is AccessState.DISABLED + + +@pytest.mark.parametrize("value", ["", "ACTIVE", "pending_review", None, 1, 0, object()]) +def test_unrecognized_state_is_fail_closed(value: object) -> None: + """Неизвестное значение / NULL / неожиданный тип → `disabled`. + + Обратный выбор («пускать всё, что не disabled») означал бы, что состояние, + добавленное миграцией РАНЬШЕ кода, молча раздаёт доступ. NB: `1`/`0` — это + int, а не bool, и в булевой схеме они не появляются; сюда они попадают как + «неожиданный тип» и тоже блокируются. + """ + assert to_access_state(value) is AccessState.DISABLED + + +# --------------------------------------------------------------------------- +# access_state_param — обратное направление (ЗАПИСЬ) +# --------------------------------------------------------------------------- + + +def test_write_value_in_boolean_schema() -> None: + """Литералы, а не round-trip: инверсия функции обязана быть видна прямо здесь.""" + assert access_state_param(AccessState.ACTIVE) is True + assert access_state_param(AccessState.DISABLED) is False + + +def test_write_value_in_text_schema(monkeypatch: pytest.MonkeyPatch) -> None: + use_identity_mode(monkeypatch, "auth") + assert access_state_param(AccessState.ACTIVE) == "active" + assert access_state_param(AccessState.TRIAL_EXPIRED) == "trial_expired" + assert access_state_param(AccessState.DISABLED) == "disabled" + + +def test_trial_expired_is_not_silently_downgraded_in_boolean_schema() -> None: + """`trial_expired` в булевой схеме — ошибка вызывающего, НЕ тихий `False`. + + Тихое приведение превратило бы «пробный период истёк» в жёсткую блокировку: + клиент увидел бы «неверный логин или пароль» вместо экрана пробного периода. + """ + with pytest.raises(ValueError, match="trial_expired"): + access_state_param(AccessState.TRIAL_EXPIRED) + + +# --------------------------------------------------------------------------- +# Где физически берётся сессия реестра +# --------------------------------------------------------------------------- + + +def test_default_mode_uses_product_session_and_never_builds_auth_engine( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Дефолт: та же `SessionLocal`, что у всего приложения; БД `auth` не трогается. + + Это буквально «прод после мержа работает как сейчас»: `AUTH_DATABASE_URL` на + проде пуст, и его отсутствие не должно ни ронять старт, ни всплывать в + рантайме. + """ + opened: list[_FakeSession] = [] + + def _session_local() -> _FakeSession: + s = _FakeSession() + opened.append(s) + return s + + monkeypatch.setattr(identity_store, "SessionLocal", _session_local) + + with identity_session() as db: + assert db is opened[0] + + assert len(opened) == 1 + assert auth_db._engine is None + assert auth_db._session_factory is None + + +def test_auth_mode_without_dsn_raises_instead_of_silent_fallback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """`IDENTITY_STORE=auth` + пустой DSN → явная ошибка, и НИ ОДНОГО запроса в tradein. + + Тихий фолбэк на `tradein_users` был бы худшим исходом: вход бы «работал», но + в реестре, который к тому моменту считается неактуальным. + """ + use_identity_mode(monkeypatch, "auth") + + def _must_not_be_called() -> _FakeSession: + raise AssertionError("режим auth не имеет права открывать сессию БД tradein") + + monkeypatch.setattr(identity_store, "SessionLocal", _must_not_be_called) + + with pytest.raises(auth_db.AuthDatabaseNotConfiguredError, match="AUTH_DATABASE_URL"): + with identity_session(): + pass + + +def test_auth_engine_is_lazy_cached_and_resettable(monkeypatch: pytest.MonkeyPatch) -> None: + """Engine строится при ПЕРВОМ обращении, кешируется, сбрасывается `reset_auth_db`. + + `create_engine` к серверу не ходит (пул ленивый), поэтому тест не требует + живой БД — проверяется именно кеширование, из-за которого два одновременных + первых запроса иначе создали бы два независимых пула. + """ + use_identity_mode(monkeypatch, "auth") + monkeypatch.setattr(config.settings, "auth_database_url", _FAKE_AUTH_DSN) + + assert auth_db._engine is None # ленивость: до первого обращения ничего нет + engine = auth_db.get_auth_engine() + assert isinstance(engine, Engine) + assert auth_db.get_auth_engine() is engine + assert auth_db.get_auth_session_factory() is auth_db.get_auth_session_factory() + + auth_db.reset_auth_db() + assert auth_db._engine is None + assert auth_db.get_auth_engine() is not engine + + +def test_blank_dsn_is_not_configured(monkeypatch: pytest.MonkeyPatch) -> None: + """DSN из одних пробелов = не задан (иначе `create_engine('')` дал бы мутную ошибку).""" + use_identity_mode(monkeypatch, "auth") + monkeypatch.setattr(config.settings, "auth_database_url", " ") + with pytest.raises(auth_db.AuthDatabaseNotConfiguredError): + auth_db.get_auth_engine() + + +# --------------------------------------------------------------------------- +# get_identity_db — FastAPI-зависимость: ОДНА транзакция в дефолте, две в auth +# --------------------------------------------------------------------------- + + +def _probe_app() -> FastAPI: + """Мини-приложение с обеими зависимостями сразу — как у роутов «Команды».""" + app = FastAPI() + + @app.get("/probe") + async def probe( + db: Annotated[Any, Depends(get_db)], + identity_db: Annotated[Any, Depends(get_identity_db)], + ) -> dict[str, bool]: + return {"same_session": db is identity_db} + + return app + + +def test_default_mode_shares_one_session_with_get_db(monkeypatch: pytest.MonkeyPatch) -> None: + """`db is identity_db` в дефолте — не экономия коннекта, а требование прода. + + «Команда» пишет строку сотрудника (реестр) и его квоту (`account_quota_overrides`, + продуктовая таблица) В ОДНОЙ транзакции. Две сессии = две транзакции = + состояние «сотрудник создан, квота нет» на ровном месте. + """ + app = _probe_app() + app.dependency_overrides[get_db] = lambda: iter([_FakeSession()]) + + resp = TestClient(app).get("/probe") + + assert resp.status_code == 200, resp.text + assert resp.json() == {"same_session": True} + + +def test_auth_mode_yields_separate_registry_session(monkeypatch: pytest.MonkeyPatch) -> None: + """В режиме `auth` БД физически разные → и сессии обязаны быть разными объектами. + + `db is not identity_db` — рантайм-признак «БД разные», по которому `team.py` + решает, коммитить ли вторую транзакцию. + """ + use_identity_mode(monkeypatch, "auth") + registry_session = _FakeSession() + + from contextlib import contextmanager + + @contextmanager + def _fake_auth_session(): + yield registry_session + + monkeypatch.setattr(auth_db, "auth_session", _fake_auth_session) + + app = _probe_app() + app.dependency_overrides[get_db] = lambda: iter([_FakeSession()]) + + resp = TestClient(app).get("/probe") + + assert resp.status_code == 200, resp.text + assert resp.json() == {"same_session": False} + + +# --------------------------------------------------------------------------- +# Сломанная конфигурация не роняет запрос (rbac_guard) +# --------------------------------------------------------------------------- + + +def _guarded_app() -> FastAPI: + app = FastAPI() + app.middleware("http")(rbac_guard) + + @app.get("/api/v1/trade-in/dummy") + async def dummy() -> dict[str, bool]: + return {"ok": True} + + return app + + +def test_misconfigured_auth_store_degrades_to_401_not_500( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """`IDENTITY_STORE=auth` без DSN + запрос С КУКОЙ → 401, а не 500. + + `AuthDatabaseNotConfiguredError` обрабатывается тем же путём, что и любой + сбой БД: резолв сессии не состоялся, дальше решает `auth_mode`. Сознательно + не отличается от «БД недоступна» — обе ситуации это сломанная конфигурация + реестра, и ни одна не имеет права отдавать 500 (или, тем более, пускать). + """ + use_identity_mode(monkeypatch, "auth") + client = TestClient(_guarded_app(), base_url="https://testserver") + client.cookies.set(config.settings.session_cookie_name, "some-token") + + resp = client.get("/api/v1/trade-in/dummy") + + assert resp.status_code == 401 + # Legacy trusted-header путь (dual-mode) при этом продолжает работать — + # сломанный реестр не отрезает существующих пользователей Caddy. + # + # ⚠️ Это поведение УЖЕ НЕДОСТИЖИМО в реальном процессе: до такого состояния + # приложение не доживает, потому что lifespan падает на старте (см. + # `test_lifespan_fails_fast_when_auth_store_has_no_dsn` ниже). Тест держит + # guard'а от 500-ки/анонимного доступа как второй рубеж — на случай, если + # DSN сломается уже ПОСЛЕ успешного старта. + fallback = client.get("/api/v1/trade-in/dummy", headers={"X-Authenticated-User": "kopylov"}) + assert fallback.status_code == 200, fallback.text + + +# --------------------------------------------------------------------------- +# Boot-time guard: сломанный реестр не должен ЖИТЬ на legacy-пути +# --------------------------------------------------------------------------- + + +def _run_lifespan(monkeypatch: pytest.MonkeyPatch) -> None: + """Прогоняет lifespan приложения до `yield` и обратно. + + FDW-bootstrap выключен: он ходит в продуктовую БД, которой в юнит-тестах + нет. К проверяемому здесь он отношения не имеет (и в самом lifespan обёрнут + в try/except), а без заглушки тест ждал бы таймаута коннекта. + """ + import asyncio + + from app import main as app_main + + monkeypatch.setattr(app_main, "ensure_fdw_user_mapping", lambda db: None) + + async def _cycle() -> None: + async with app_main.lifespan(app_main.app): + pass + + asyncio.run(_cycle()) + + +def test_lifespan_fails_fast_when_auth_store_has_no_dsn( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """`IDENTITY_STORE=auth` + пустой DSN → контейнер НЕ поднимается. + + Почему не «работает как-нибудь»: `rbac_guard` ловит + `AuthDatabaseNotConfiguredError` вместе с любым другим сбоем резолва сессии + и уходит в legacy trusted-header ветку. Продуктовая БД при этом жива, и + такой деплой способен работать сутками, раздавая права из roles.yaml всем, + кого пропустил Caddy basic_auth, — включая аккаунты, у которых в реестре + `access_state='disabled'`/`'trial_expired'`. Ошибка КОНФИГУРАЦИИ обязана + убивать старт, а не деградировать в тихий обход реестра. + """ + use_identity_mode(monkeypatch, "auth") + monkeypatch.setattr(config.settings, "auth_database_url", "") + + with pytest.raises(auth_db.AuthDatabaseNotConfiguredError): + _run_lifespan(monkeypatch) + + +def test_lifespan_does_not_touch_auth_db_in_default_mode( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Дефолтный режим: старт НЕ обращается к БД `auth` и пустой DSN не мешает. + + Ровно ограничение PR — сегодняшний прод (`IDENTITY_STORE` не задан, + `AUTH_DATABASE_URL` нет вовсе) обязан подниматься как раньше. + """ + from app import main as app_main + + calls: list[str] = [] + monkeypatch.setattr(app_main, "get_auth_engine", lambda: calls.append("built")) + + _run_lifespan(monkeypatch) + + assert calls == [], "в дефолтном режиме engine БД `auth` не должен строиться на старте" diff --git a/tradein-mvp/backend/tests/test_team_api.py b/tradein-mvp/backend/tests/test_team_api.py index 3487e2c3..0a06cb60 100644 --- a/tradein-mvp/backend/tests/test_team_api.py +++ b/tradein-mvp/backend/tests/test_team_api.py @@ -2,19 +2,44 @@ 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`. +(`_Store`/`_FakeDB`) dispatching on SQL text standing in for реестра людей / +`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`. +Сессия РЕЕСТРА подменяется на самом низком уровне (`identity_store.SessionLocal` ++ `auth_db.auth_session`, см. `tests.support.identity_modes.patch_identity_sessions`), +а `app.core.db.get_db` — через `app.dependency_overrides`. Поэтому и +`identity_session()` (rbac_guard — middleware, FastAPI-DI там нет), и +`Depends(get_identity_db)` (`current_team_actor`, все team-роуты) выполняются +НАСТОЯЩИЕ, вместе со своим ветвлением по `settings.identity_store`. Все они +смотрят в ОДИН `_Store` на тест — сессия из POST /login сразу видна и +rbac_guard'у, и `current_team_actor`. + +ДВЕ СЕССИИ. В дефолтном режиме `get_identity_db` отдаёт ТОТ ЖЕ объект, что +`get_db` (одна БД, одна транзакция — сегодняшний прод). В режиме `auth` это +физически разные сессии, и `team.py` коммитит их отдельно (`if db is not +identity_db`). Здесь это воспроизводится честно: в режиме `auth` реестр и +продуктовые таблицы получают РАЗНЫЕ `_FakeDB` (общий `_Store` — как общий +«кластер», но разные соединения). + +⚠️ ЛОВУШКА FAKE-DB. `_FakeDB` диспатчит по ТЕКСТУ SQL, а эпик «единый вход» +переименовывает таблицы (`tradein_users`/`tradein_sessions` → `users`/`sessions`) +и меняет тип колонки состояния доступа (`is_active boolean` → `access_state +text`). Литерал «tradein_users» в диспатчере означал бы, что при +`IDENTITY_STORE=auth` ветка молча перестаёт матчиться, fake отдаёт пустоту, а +тест остаётся ЗЕЛЁНЫМ на сломанном коде. Поэтому имена берутся из `sql_names()` +(= `identity_schema()`, тот же словарь, что у продакшн-кода), а непонятый SQL +падает `AssertionError`, а не возвращает пустой результат. + +Значение состояния доступа fake хранит СЫРЫМ (то, что реально лежало бы в +колонке) и НЕ прогоняет через `identity_store.access_state_param()` — иначе +инверсия этой функции прошла бы round-trip через fake незамеченной. """ from __future__ import annotations import os +import re from datetime import UTC, datetime, timedelta from types import SimpleNamespace from typing import Any @@ -33,9 +58,19 @@ 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 +from app.services.identity_store import AccessState +from tests.support.identity_modes import ( + assert_insert_writes_access_state, + assert_reads_access_state, + assert_update_writes_access_state, + column_value, + patch_identity_sessions, + sql_names, + use_identity_mode, +) # --------------------------------------------------------------------------- -# Fake DB backing tradein_users / tradein_sessions / quota / user_events +# Fake DB backing реестр людей / sessions / quota / user_events # --------------------------------------------------------------------------- @@ -47,6 +82,8 @@ class _Store: 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.sql_log: list[str] = [] # весь SQL, доехавший до «БД» — см. тесты режимов + self.commits: list[int] = [] # id() сессий, на которых вызывали commit() self._next_id = 1 self.query_count = 0 # db.execute() calls — N+1 regression guard (review PR #2563) @@ -57,7 +94,7 @@ class _Store: *, role: str = "employee", manager_id: int | None = None, - is_active: bool = True, + access_state: AccessState = AccessState.ACTIVE, display_name: str | None = None, org_name: str | None = None, email: str | None = None, @@ -74,7 +111,8 @@ class _Store: "display_name": display_name, "org_name": org_name, "email": email, - "is_active": is_active, + # СЫРОЕ значение колонки текущего режима (boolean либо text). + "access_state": column_value(access_state), "created_at": created_at or datetime.now(UTC), } return uid @@ -162,7 +200,7 @@ class _FakeDB: pass def commit(self) -> None: - pass + self.store.commits.append(id(self)) def rollback(self) -> None: pass @@ -172,9 +210,13 @@ class _FakeDB: p = params or {} s = self.store s.query_count += 1 + s.sql_log.append(sql) + # Имена таблиц/колонки берутся ИЗ КОДА (identity_schema), а не из + # литералов — см. «ЛОВУШКА FAKE-DB» в модульном docstring. + names = sql_names() - # ---- tradein_sessions ---- - if "INSERT INTO tradein_sessions" in sql: + # ---- сессии реестра ---- + if f"INSERT INTO {names.sessions}" in sql: now = datetime.now(UTC) s.sessions[p["token"]] = { "user_id": p["user_id"], @@ -183,7 +225,7 @@ class _FakeDB: } return _Result([]) - if "UPDATE tradein_sessions" in sql and "SET last_seen_at" in sql: + if f"UPDATE {names.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) @@ -191,23 +233,25 @@ class _FakeDB: sess["expires_at"] = now + timedelta(hours=p["ttl_hours"]) return _Result([]) - if "DELETE FROM tradein_sessions WHERE token" in sql: + if f"DELETE FROM {names.sessions} WHERE token" in sql: s.sessions.pop(p["token"], None) return _Result([]) - if "DELETE FROM tradein_sessions WHERE user_id" in sql: + if f"DELETE FROM {names.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: + if f"FROM {names.sessions} s" in sql and f"JOIN {names.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([]) + # Колонка состояния приезжает под алиасом `access_state` в обоих + # режимах (`u.<колонка> AS access_state`), значение — сырое. return _Result( [ { @@ -219,18 +263,23 @@ class _FakeDB: "display_name": user["display_name"], "org_name": user["org_name"], "email": user["email"], - "is_active": user["is_active"], + "access_state": user["access_state"], } ] ) - # ---- tradein_users: login lookup (get_user_by_username) ---- - if "password_hash, role, is_active" in sql and "FROM tradein_users" in sql: + # ---- реестр: login lookup (get_user_by_username) ---- + # Дискриминатор — bind-параметр `:username` (у pre-check'а уникальности + # ниже он называется `:u`), поэтому ветки не пересекаются ни в одном режиме. + if f"FROM {names.users}" in sql and "WHERE username = :username" in sql: + assert_reads_access_state(sql, names) 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: + # ---- реестр: create ---- + if f"INSERT INTO {names.users}" in sql: + assert_insert_writes_access_state(sql, names) + assert_reads_access_state(sql, names) # RETURNING отдаёт её же uid = s._next_id s._next_id += 1 created_at = datetime.now(UTC) @@ -243,25 +292,31 @@ class _FakeDB: "display_name": p["display_name"], "org_name": p["org_name"], "email": p["email"], - "is_active": True, + # Ровно то, что код прислал параметром — БЕЗ нормализации. + # Инверсия `access_state_param()` обязана доехать до ответа API + # (`is_active`), а не раствориться в дублёре. + "access_state": p["access_state"], "created_at": created_at, } s.users[p["username"]] = row return _Result([dict(row)]) - # ---- tradein_users: manager_id validation ---- - if "role = 'manager'" in sql: + # ---- реестр: manager_id validation ---- + if f"FROM {names.users}" in sql and "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 managed rows (has explicit ORDER BY) ---- + # ---- реестр: 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 + if ( + f"FROM {names.users}" in sql + and ("role = 'employee'" in sql or "role IN ('employee', 'manager')" in sql) + and "ORDER BY created_at DESC" in sql ): + assert_reads_access_state(sql, names) managed = ( ("employee", "manager") if "role IN ('employee', 'manager')" in sql @@ -285,7 +340,7 @@ class _FakeDB: "display_name": u["display_name"], "org_name": u["org_name"], "email": u["email"], - "is_active": u["is_active"], + "access_state": u["access_state"], "manager_id": u["manager_id"], "created_at": u["created_at"], } @@ -293,8 +348,11 @@ class _FakeDB: ] ) - # ---- tradein_users: fetch single managed row by id ---- - if "role = 'employee'" in sql or "role IN ('employee', 'manager')" in sql: + # ---- реестр: fetch single managed row by id ---- + if f"FROM {names.users}" in sql and ( + "role = 'employee'" in sql or "role IN ('employee', 'manager')" in sql + ): + assert_reads_access_state(sql, names) managed = ( ("employee", "manager") if "role IN ('employee', 'manager')" in sql @@ -312,20 +370,21 @@ class _FakeDB: "display_name": user["display_name"], "org_name": user["org_name"], "email": user["email"], - "is_active": user["is_active"], + "access_state": user["access_state"], "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"): + # ---- реестр: uniqueness pre-check ---- + if sql.strip().startswith(f"SELECT id FROM {names.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: + # ---- реестр: update (PATCH) ---- + if f"UPDATE {names.users}" in sql and "SET display_name = COALESCE" in sql: + assert_update_writes_access_state(sql, names) user = s.user_by_id(p["id"]) assert user is not None if p.get("display_name") is not None: @@ -334,8 +393,11 @@ class _FakeDB: 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"] + # COALESCE(CAST(:access_state AS <тип>), <колонка>) — None означает + # «поле не пришло в PATCH», значение записывается КАК ЕСТЬ (см. + # комментарий про round-trip в INSERT выше). + if p.get("access_state") is not None: + user["access_state"] = p["access_state"] if p.get("password_hash") is not None: user["password_hash"] = p["password_hash"] return _Result([]) @@ -437,6 +499,8 @@ 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") + # Каждый тест стартует в ДЕФОЛТНОМ режиме реестра (сегодняшний прод). + use_identity_mode(monkeypatch, "tradein") # 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)) @@ -452,9 +516,23 @@ def store() -> _Store: return _Store() +@pytest.fixture +def auth_store(store: _Store, monkeypatch: pytest.MonkeyPatch) -> _Store: + """Тот же `store`, но реестр — БД `auth` (`users`/`sessions`, text-состояние). + + Запрашивать ПЕРЕД `client`: `store.add_user` фиксирует значение колонки по + режиму на момент вызова. + """ + use_identity_mode(monkeypatch, "auth") + return store + + @pytest.fixture def client(store: _Store, monkeypatch: pytest.MonkeyPatch) -> TestClient: - monkeypatch.setattr("app.core.rbac.SessionLocal", lambda: _FakeDB(store)) + # Подменяем сессию РЕЕСТРА на обоих её источниках сразу, а не ветвление по + # режиму: `identity_session()` / `get_identity_db()` остаются настоящими, + # включая инвариант «в дефолтном режиме это тот же объект, что у get_db». + patch_identity_sessions(monkeypatch, 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") @@ -818,7 +896,11 @@ def test_reset_password_revokes_old_sessions(client: TestClient, store: _Store) 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 + "emp_a", + hash_password("Secret123!"), + role="employee", + manager_id=mgr_id, + access_state=AccessState.DISABLED, ) _login(client, "mgr_a", "Secret123!") @@ -1157,3 +1239,178 @@ def test_employee_history_limit_max_200(client: TestClient, store: _Store) -> No resp = client.get(f"/api/v1/team/employees/{emp_id}/history", params={"limit": 500}) assert resp.status_code == 422 + + +# --------------------------------------------------------------------------- +# Эпик «единый вход»: режим IDENTITY_STORE=auth (общий реестр в БД `auth`). +# +# Всё выше идёт в ДЕФОЛТНОМ режиме — он же прод. Ниже — то, что появляется +# только после переезда: другая БД под реестром (две сессии вместо одной) и +# текстовое трёхзначное состояние доступа вместо булева `is_active`. +# --------------------------------------------------------------------------- + + +def test_default_mode_single_session_and_tradein_tables(client: TestClient, store: _Store) -> None: + """Дефолт: реестр и продуктовые таблицы — ОДНА сессия, один commit, старые имена. + + Это и есть «после мержа прод работает точно как сейчас» на уровне + транзакции: «сотрудник создан, квота нет» невозможно, потому что писать + обоих некуда, кроме одной транзакции. + """ + store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + _login(client, "mgr_a", "Secret123!") + store.commits.clear() + + resp = client.post( + "/api/v1/team/employees", + json={"username": "emp_x", "password": "Secret123!", "monthly_limit": 7}, + ) + assert resp.status_code == 201, resp.text + + # Ровно один commit и ровно на одной сессии — `db is identity_db`. + assert len(set(store.commits)) == 1, store.commits + joined = "\n".join(store.sql_log) + assert "tradein_users" in joined + assert "tradein_sessions" in joined + assert not re.search(r"\b(FROM|INTO|UPDATE|JOIN)\s+users\b", joined) + assert not re.search(r"\b(FROM|INTO|UPDATE|JOIN)\s+sessions\b", joined) + # Новый сотрудник заводится открытым — булевым литералом, как и раньше. + assert store.users["emp_x"]["access_state"] is True + assert resp.json()["is_active"] is True + + +def test_auth_mode_commits_registry_and_product_db_separately( + auth_store: _Store, client: TestClient +) -> None: + """Режим `auth`: БД физически две → две сессии и два отдельных коммита. + + Порядок несущий (реестр первым): не доехавшая квота — это сотрудник с + глобальным лимитом (чинится повторным PATCH), а обратный порядок оставил бы + висящий override на несуществующего человека. + """ + auth_store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + _login(client, "mgr_a", "Secret123!") + auth_store.commits.clear() + + resp = client.post( + "/api/v1/team/employees", + json={"username": "emp_x", "password": "Secret123!", "monthly_limit": 7}, + ) + assert resp.status_code == 201, resp.text + + assert len(set(auth_store.commits)) == 2, auth_store.commits + joined = "\n".join(auth_store.sql_log) + assert "tradein_users" not in joined + assert "tradein_sessions" not in joined + assert re.search(r"INSERT INTO\s+users\b", joined) + # Квота осталась в ПРОДУКТОВОЙ таблице — она в общий реестр не переезжает. + assert "INSERT INTO account_quota_overrides" in joined + assert auth_store.quota_overrides["emp_x"]["monthly_limit"] == 7 + + +def test_auth_mode_create_writes_text_active_literal( + auth_store: _Store, client: TestClient +) -> None: + """INSERT кладёт в колонку 'active' (text), а не булев true. + + Значение fake хранит как есть — если бы `access_state_param()` инвертировался + или отдавал не тот тип, это доехало бы прямо сюда и до `is_active` в ответе. + """ + auth_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_x", "password": "Secret123!"} + ) + + assert resp.status_code == 201, resp.text + assert auth_store.users["emp_x"]["access_state"] == "active" + assert resp.json()["is_active"] is True + + +def test_auth_mode_block_writes_disabled_and_revokes_sessions( + auth_store: _Store, client: TestClient +) -> None: + """PATCH is_active=false → колонка 'disabled' + все сессии сотрудника порваны. + + Сессии живут в БД РЕЕСТРА, поэтому рвать их надо через `identity_db`: с + продуктовой сессией DELETE ушёл бы не в ту БД, и блокировка не действовала бы + до истечения TTL (а sliding-refresh продлевал бы её бесконечно). + """ + mgr_id = auth_store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + emp_id = auth_store.add_user( + "emp_a", hash_password("Secret123!"), role="employee", manager_id=mgr_id + ) + auth_store.sessions["emp-token"] = { + "user_id": emp_id, + "expires_at": datetime.now(UTC) + timedelta(hours=1), + "last_seen_at": datetime.now(UTC), + } + _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 + assert auth_store.users["emp_a"]["access_state"] == "disabled" + assert "emp-token" not in auth_store.sessions + + +def test_auth_mode_trial_expired_shows_as_blocked_and_unblock_activates( + auth_store: _Store, client: TestClient +) -> None: + """`trial_expired` в «Команде» выглядит заблокированным, а is_active=true снимает + пробное ограничение (переводит в `active`). + + Форма ответа API не меняется этим PR: `is_active` остаётся булевым и считается + как «пустят ли входить». Отдельное отображение пробного периода — вопрос UI-PR'а. + """ + mgr_id = auth_store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + emp_id = auth_store.add_user( + "emp_a", + hash_password("Secret123!"), + role="employee", + manager_id=mgr_id, + access_state=AccessState.TRIAL_EXPIRED, + ) + _login(client, "mgr_a", "Secret123!") + + listed = client.get("/api/v1/team/employees") + assert listed.status_code == 200, listed.text + assert [e["is_active"] for e in listed.json()] == [False] + + resp = client.patch(f"/api/v1/team/employees/{emp_id}", json={"is_active": True}) + assert resp.status_code == 200, resp.text + assert resp.json()["is_active"] is True + assert auth_store.users["emp_a"]["access_state"] == "active" + + +def test_auth_mode_org_isolation_still_404s_foreign_employee( + auth_store: _Store, client: TestClient +) -> None: + """Главный инвариант «Команды» (чужой сотрудник → 404, не 403) переезд переживает.""" + auth_store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + mgr_b_id = auth_store.add_user("mgr_b", hash_password("Secret123!"), role="manager") + foreign_id = auth_store.add_user( + "emp_b", hash_password("Secret123!"), role="employee", manager_id=mgr_b_id + ) + _login(client, "mgr_a", "Secret123!") + + assert client.get("/api/v1/team/employees").json() == [] + patched = client.patch(f"/api/v1/team/employees/{foreign_id}", json={"is_active": False}) + assert patched.status_code == 404 + assert client.get(f"/api/v1/team/employees/{foreign_id}/history").status_code == 404 + # Чужая строка не тронута. + assert auth_store.users["emp_b"]["access_state"] == "active" + + +def test_auth_mode_employee_role_still_403_on_team_routes( + auth_store: _Store, client: TestClient +) -> None: + """Роль резолвится из общего реестра — employee по-прежнему не админ «Команды».""" + auth_store.add_user("emp_only", hash_password("Secret123!"), role="employee") + _login(client, "emp_only", "Secret123!") + + resp = client.get("/api/v1/team/employees") + assert resp.status_code == 403 + assert "admin or manager" in resp.json()["detail"].lower() diff --git a/tradein-mvp/frontend/src/app/login/page.tsx b/tradein-mvp/frontend/src/app/login/page.tsx index 8bc61556..ad74d876 100644 --- a/tradein-mvp/frontend/src/app/login/page.tsx +++ b/tradein-mvp/frontend/src/app/login/page.tsx @@ -71,12 +71,32 @@ function sanitizeNext(next: string | null): string { return cleaned; } +/** + * Единственный 403 логина — «пробный доступ закончился» (пароль ВЕРНЫЙ, + * access_state='trial_expired' в реестре людей). Ветвимся по машиночитаемому + * `detail.code`, а не по тексту: текст сообщения бэк вправе менять, код — нет + * (app/api/v1/auth.py, _ACCESS_EXPIRED_CODE). + * + * Достижимо только при IDENTITY_STORE=auth: в дефолтном режиме состояние + * доступа булево (active/disabled), и trial_expired там не существует. + */ +function accessExpiredCode(body: unknown): string | undefined { + if (typeof body !== "object" || body === null) return undefined; + const detail = (body as { detail?: unknown }).detail; + if (typeof detail !== "object" || detail === null) return undefined; + const code = (detail as { code?: unknown }).code; + return typeof code === "string" ? code : undefined; +} + function loginErrorMessage(error: unknown): string { if (error instanceof HTTPError) { if (error.status === 401) return "Неверный логин или пароль"; if (error.status === 429) { return "Слишком много попыток. Попробуйте через несколько минут"; } + if (error.status === 403 && accessExpiredCode(error.body) === "access_expired") { + return "Пробный доступ закончился — обратитесь к менеджеру"; + } } return "Не удалось войти. Проверьте подключение и попробуйте ещё раз"; } From ad753c6a873c656f065a0bfc20b69668d3ab928f Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 1 Aug 2026 08:22:43 +0300 Subject: [PATCH 049/141] =?UTF-8?q?fix(tradein/proxy):=20=D1=81=D0=B0?= =?UTF-8?q?=D0=BC=D0=BE=D0=B2=D0=BE=D1=81=D1=81=D1=82=D0=B0=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=83=D0=BB=D0=B0?= =?UTF-8?q?=20=D0=B8=20=D0=B7=D0=B0=D0=BF=D0=B0=D1=81=D0=BD=D0=BE=D0=B9=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=BA=D1=81=D0=B8=20=D1=87=D1=83=D0=B6=D0=BE?= =?UTF-8?q?=D0=B9=20affinity=20(#2600)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Прод-замер: disabled-узлы никогда не перепроверялись (WHERE enabled в run_proxy_healthcheck) — auto-disable по DISABLE_THRESHOLD необратим, транзиентный сбой = вечный приговор (id 11 сгорел за ночь, будучи физически исправным). acquire() при пустой выборке по provider_affinity падал в None, морив источник голодом при живых свободных узлах чужой affinity. - run_proxy_healthcheck: disabled-узлы проверяются реже (DISABLED_RECHECK_MINUTES=60 либо last_check_at IS NULL); успешная проба реанимирует узел (enabled=true через mark_health) и инкрементит новый счётчик revived. - mark_health(ok=True) теперь безусловно ставит enabled=true (реанимация). - acquire: вторым заходом при пустой выборке своей affinity берёт любой свободный здоровый узел любой affinity (WARNING-лог), приоритет своих сохранён. - _probe_proxy классифицирует неуспех (timeout/connect_error/http_error/other) в fail_kind — прокидывается в mark_health только для логирования; полноценное разделение порогов транзиент/бан отложено (см. docstring mark_health). --- .../backend/app/services/proxy_pool.py | 175 ++++++++++++++--- .../backend/tests/services/test_proxy_pool.py | 179 ++++++++++++++++-- 2 files changed, 309 insertions(+), 45 deletions(-) diff --git a/tradein-mvp/backend/app/services/proxy_pool.py b/tradein-mvp/backend/app/services/proxy_pool.py index 44b666ee..3d4d4c0c 100644 --- a/tradein-mvp/backend/app/services/proxy_pool.py +++ b/tradein-mvp/backend/app/services/proxy_pool.py @@ -15,11 +15,23 @@ ipify-пробу через каждый прокси и обновляет heal за одну строку — второй параллельный вызов пропустит залоченную и возьмёт следующую). Health: - - mark_health(ok=True) → consecutive_fails=0, last_ok_at/last_check_at, exit_ip, latency. + - mark_health(ok=True) → consecutive_fails=0, enabled=true, last_ok_at/last_check_at, + exit_ip, latency. enabled=true — реанимация: узел, выключенный + ранее авто-disable'ом, возвращается в строй первой же успешной + пробой (см. run_proxy_healthcheck). - mark_health(ok=False) → consecutive_fails += 1; при достижении DISABLE_THRESHOLD прокси авто-disable (enabled=false), чтобы битый узел выпал из пула. - acquire отфильтровывает enabled=false И consecutive_fails >= MAX_FAILS. +Self-healing (#2600): + - run_proxy_healthcheck проверяет не только enabled-узлы, но и disabled — реже, раз в + DISABLED_RECHECK_MINUTES (или если ни разу не проверялся). Успешная проба выключенного + узла реанимирует его (enabled=true), инкрементит счётчик `revived` и пишет INFO-лог. + Без этого auto-disable необратим: транзиентный сбой = вечный приговор узлу. + - acquire, не найдя свободного здорового узла нужной provider_affinity, вторым заходом + берёт любой свободный здоровый узел ЛЮБОЙ affinity (WARNING-лог) — иначе источник + голодает при живых свободных узлах чужой affinity. + psycopg v3 / SQLAlchemy text(): все параметры через CAST(:x AS type), НЕ :x::type. """ @@ -36,6 +48,7 @@ from sqlalchemy.orm import Session logger = logging.getLogger(__name__) __all__ = [ + "DISABLED_RECHECK_MINUTES", "DISABLE_THRESHOLD", "MAX_CONSECUTIVE_FAILS", "NON_RUN_LEASE_MARKER", @@ -62,6 +75,12 @@ DISABLE_THRESHOLD = 5 # освобождается reap_stale_leases — иначе прокси навсегда «занят» мёртвым run'ом. STALE_LEASE_MINUTES = 30 +# Disabled-узлы перепроверяются не каждый прогон (это долбёж по мёртвому/дорогому +# провайдеру), а раз в это число минут — либо если ни разу не проверялся. Успешная +# проба реанимирует узел (см. run_proxy_healthcheck). Без recheck'а auto-disable +# необратим: транзиентный сбой = вечный приговор (#2600). +DISABLED_RECHECK_MINUTES = 60 + # Маркер lease для не-run вызовов (leased_by NOT NULL = занят, но это не id из scrape_runs). NON_RUN_LEASE_MARKER = -1 @@ -90,10 +109,16 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe (last_ok_at NULLS LAST). Затем помечает строку leased_by=run_id (или NON_RUN_LEASE_MARKER если run_id не задан) и коммитит. + Если свободных здоровых узлов нужной affinity (provider/'any') нет — вторым заходом + берётся любой свободный здоровый узел ЛЮБОЙ affinity (тот же ORDER BY/FOR UPDATE SKIP + LOCKED), с WARNING-логом. Приоритет не меняется: своя affinity всегда предпочтительнее, + чужая — только запасной вариант, чтобы источник не голодал при живых свободных узлах + чужой affinity (#2600). + Конкурентные acquire не дерутся за одну строку: SKIP LOCKED пропускает залоченную другим вызовом строку, второй параллельный acquire берёт следующую свободную. - Returns ProxyLease или None если свободных здоровых прокси нет. + Returns ProxyLease или None если свободных здоровых прокси нет вообще. """ lease_marker = run_id if run_id is not None else NON_RUN_LEASE_MARKER @@ -117,6 +142,33 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe .mappings() .fetchone() ) + + fallback_used = False + if row is None: + # Нет своих (provider/'any') — запасной заход: любой свободный здоровый узел, + # affinity не важна. Лучше выдать источнику чужой прокси, чем оставить его без + # прокси при живых свободных узлах. + row = ( + db.execute( + text( + """ + SELECT id, url, kind, rotate_url + FROM scrape_proxies + WHERE enabled + AND consecutive_fails < CAST(:max_fails AS integer) + AND leased_by IS NULL + ORDER BY last_ok_at NULLS LAST, id + FOR UPDATE SKIP LOCKED + LIMIT 1 + """ + ), + {"max_fails": MAX_CONSECUTIVE_FAILS}, + ) + .mappings() + .fetchone() + ) + fallback_used = row is not None + if row is None: db.rollback() # снять FOR UPDATE-транзакцию (ничего не залочено, но чисто) return None @@ -133,9 +185,18 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe {"run_id": lease_marker, "id": proxy_id}, ) db.commit() - logger.info( - "proxy_pool: leased proxy id=%d provider=%s by=%s", proxy_id, provider, lease_marker - ) + if fallback_used: + logger.warning( + "proxy_pool: leased proxy id=%d provider=%s by=%s — FALLBACK affinity " + "(no free healthy proxy of matching affinity, issuing proxy of other affinity)", + proxy_id, + provider, + lease_marker, + ) + else: + logger.info( + "proxy_pool: leased proxy id=%d provider=%s by=%s", proxy_id, provider, lease_marker + ) return ProxyLease( id=proxy_id, url=str(row["url"]), @@ -167,13 +228,24 @@ def mark_health( *, exit_ip: str | None = None, latency_ms: int | None = None, + fail_kind: str | None = None, ) -> None: """Записать результат health-check'а прокси. - ok=True → consecutive_fails обнуляется, обновляются last_ok_at/last_check_at/ - exit_ip/latency_ms. + ok=True → consecutive_fails обнуляется, enabled=true, обновляются last_ok_at/ + last_check_at/exit_ip/latency_ms. enabled=true безусловно — это реанимация: + узел, ранее выключенный auto-disable'ом, возвращается в строй первой же + успешной пробой (см. run_proxy_healthcheck, #2600 п.1). ok=False → consecutive_fails += 1; при достижении DISABLE_THRESHOLD прокси авто-disable (enabled=false). last_check_at обновляется в любом случае. + + fail_kind — необязательная классификация неуспеха ("timeout" / "connect_error" / + "http_error" / "other", см. _probe_proxy), используется ТОЛЬКО для логирования. + Счётчик consecutive_fails/порог disable инкрементится одинаково для любого fail_kind — + аккуратное разделение "транзиентный сбой vs перманентный бан" (разные пороги/скорость + инкремента по типу ошибки) требует более глубокой переработки модуля (отдельный + трекинг по типам ошибок, вероятно per-fail_kind счётчики) и намеренно НЕ сделано в + рамках #2600 п.2 — см. обоснование в PR. fail_kind — задел под это на будущее. """ if ok: db.execute( @@ -185,6 +257,7 @@ def mark_health( last_check_at = now(), exit_ip = CAST(:exit_ip AS text), latency_ms = CAST(:latency_ms AS integer), + enabled = true, updated_at = now() WHERE id = CAST(:id AS bigint) """ @@ -210,7 +283,13 @@ def mark_health( {"disable_threshold": DISABLE_THRESHOLD, "id": proxy_id}, ) db.commit() - logger.info("proxy_pool: mark_health id=%d ok=%s exit_ip=%s", proxy_id, ok, exit_ip) + logger.info( + "proxy_pool: mark_health id=%d ok=%s exit_ip=%s fail_kind=%s", + proxy_id, + ok, + exit_ip, + fail_kind, + ) def reap_stale_leases(db: Session, older_than_minutes: int = STALE_LEASE_MINUTES) -> int: @@ -237,10 +316,17 @@ def reap_stale_leases(db: Session, older_than_minutes: int = STALE_LEASE_MINUTES return len(rows) -async def _probe_proxy(url: str) -> tuple[bool, str | None, int | None]: +async def _probe_proxy(url: str) -> tuple[bool, str | None, int | None, str | None]: """GET ipify через прокси (timeout _HEALTH_PROBE_TIMEOUT_S). - Returns (ok, exit_ip, latency_ms). ok=False + (None, None) при любой ошибке. + Returns (ok, exit_ip, latency_ms, fail_kind). При успехе fail_kind=None. При неуспехе + exit_ip/latency_ms=None, а fail_kind классифицирует что случилось (#2600 п.2 — + транзиентный сбой узла ≠ перманентный бан, используется пока только для логов): + - "timeout" — сеть недоступна/медленная (httpx.TimeoutException) + - "connect_error" — прокси не поднят/не слушает/DNS (httpx.ConnectError) + - "http_error" — ipify ответил ошибкой через прокси (auth/upstream) + - "other" — прочее + url несёт схему (http:// / socks5://) — httpx[socks] обрабатывает оба. """ started = time.monotonic() @@ -250,10 +336,23 @@ async def _probe_proxy(url: str) -> tuple[bool, str | None, int | None]: resp.raise_for_status() ip = resp.json().get("ip") latency_ms = int((time.monotonic() - started) * 1000) - return True, (str(ip) if ip else None), latency_ms + return True, (str(ip) if ip else None), latency_ms, None + except httpx.TimeoutException: + logger.warning("proxy_pool: health probe timeout proxy=%s", _mask(url)) + return False, None, None, "timeout" + except httpx.ConnectError: + logger.warning("proxy_pool: health probe connect_error proxy=%s", _mask(url)) + return False, None, None, "connect_error" + except httpx.HTTPStatusError as exc: + logger.warning( + "proxy_pool: health probe http_error proxy=%s status=%s", + _mask(url), + exc.response.status_code, + ) + return False, None, None, "http_error" except Exception: logger.warning("proxy_pool: health probe failed proxy=%s", _mask(url), exc_info=True) - return False, None, None + return False, None, None, "other" def _mask(url: str) -> str: @@ -269,16 +368,23 @@ def _mask(url: str) -> str: async def run_proxy_healthcheck(db: Session) -> dict[str, int]: - """Периодический health-check всех enabled-прокси пула (#2162). + """Периодический health-check прокси пула — enabled каждый прогон, disabled реже (#2162, #2600). - Сначала reap_stale_leases (освобождает протухшие lease'ы), затем для каждого - enabled-прокси гоняет ipify-пробу через сам прокси и пишет результат через - mark_health (успех → сброс fails + свежий exit_ip/latency; фейл → инкремент, - авто-disable при DISABLE_THRESHOLD). + Сначала reap_stale_leases (освобождает протухшие lease'ы), затем гоняет ipify-пробу + через каждый кандидат и пишет результат через mark_health (успех → сброс fails + + enabled=true + свежий exit_ip/latency; фейл → инкремент, авто-disable при + DISABLE_THRESHOLD). + + Кандидаты: ВСЕ enabled-узлы (как раньше) + disabled-узлы, которые ни разу не + проверялись (last_check_at IS NULL) или проверялись давнее DISABLED_RECHECK_MINUTES + назад. Без этого auto-disable необратим — узел, ушедший в disable из-за транзиентного + сбоя, никогда больше не проверяется и не может вернуться (#2600 п.1). Успешная проба + disabled-узла реанимирует его (enabled=true через mark_health) — инкрементит `revived` + и пишет отдельный INFO-лог. Пробы идут последовательно — пул небольшой (десятки узлов), а параллельный залп на один и тот же upstream-endpoint (ipify) не нужен. Returns counters - {reaped, checked, ok, failed}. + {reaped, checked, ok, failed, revived}. """ reaped = reap_stale_leases(db) @@ -286,12 +392,17 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]: db.execute( text( """ - SELECT id, url, kind + SELECT id, url, kind, enabled FROM scrape_proxies WHERE enabled + OR last_check_at IS NULL + OR last_check_at < now() - make_interval( + mins => CAST(:disabled_recheck_minutes AS integer) + ) ORDER BY id """ - ) + ), + {"disabled_recheck_minutes": DISABLED_RECHECK_MINUTES}, ) .mappings() .all() @@ -300,22 +411,38 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]: checked = 0 ok_count = 0 failed = 0 + revived = 0 for row in proxies: proxy_id = int(row["id"]) url = str(row["url"]) - ok, exit_ip, latency_ms = await _probe_proxy(url) - mark_health(db, proxy_id, ok, exit_ip=exit_ip, latency_ms=latency_ms) + was_disabled = not bool(row["enabled"]) + ok, exit_ip, latency_ms, fail_kind = await _probe_proxy(url) + mark_health(db, proxy_id, ok, exit_ip=exit_ip, latency_ms=latency_ms, fail_kind=fail_kind) checked += 1 if ok: ok_count += 1 + if was_disabled: + revived += 1 + logger.info( + "proxy_pool: REVIVED proxy id=%d — successful probe of a disabled node, " + "returned to service (enabled=true, consecutive_fails=0)", + proxy_id, + ) else: failed += 1 logger.info( - "proxy_pool: healthcheck done — reaped=%d checked=%d ok=%d failed=%d", + "proxy_pool: healthcheck done — reaped=%d checked=%d ok=%d failed=%d revived=%d", reaped, checked, ok_count, failed, + revived, ) - return {"reaped": reaped, "checked": checked, "ok": ok_count, "failed": failed} + return { + "reaped": reaped, + "checked": checked, + "ok": ok_count, + "failed": failed, + "revived": revived, + } diff --git a/tradein-mvp/backend/tests/services/test_proxy_pool.py b/tradein-mvp/backend/tests/services/test_proxy_pool.py index 5a39acb2..67634c62 100644 --- a/tradein-mvp/backend/tests/services/test_proxy_pool.py +++ b/tradein-mvp/backend/tests/services/test_proxy_pool.py @@ -1,4 +1,4 @@ -"""Offline-тесты пула прокси (#2162). +"""Offline-тесты пула прокси (#2162, #2600). Покрытие БЕЗ live-сети/БД: stateful FakeSession эмулирует таблицу scrape_proxies и интерпретирует SQL по ключевым фрагментам, так что acquire/release/mark_health/ @@ -8,11 +8,15 @@ reap_stale_leases проверяются по фактическому изме - два acquire подряд → РАЗНЫЕ прокси (первый лизнут → выпал из выборки второго). - release освобождает (leased_by → NULL), прокси снова acquire-абелен. - mark_health fail → инкремент consecutive_fails, авто-disable при DISABLE_THRESHOLD. - - mark_health ok → сброс fails + exit_ip/latency. + - mark_health ok → сброс fails + exit_ip/latency + enabled=true (реанимация). - reap_stale_leases освобождает старый lease, свежий не трогает. - affinity-фильтр: acquire('avito') не берёт cian-only прокси. - acquire пропускает disabled и «нездоровые» (fails >= MAX_CONSECUTIVE_FAILS). + - acquire без своих/any свободных → берёт свободный чужой affinity (fallback, #2600 п.3). - run_proxy_healthcheck: reap + проба каждого enabled + mark_health (проба замокана). + - run_proxy_healthcheck: disabled-узлы — самовосстановление (#2600 п.1): + * успешная проба выключенного узла возвращает его в строй + revived++; + * недавно проверенный выключенный узел повторно не проверяется (не долбим провайдера). """ from __future__ import annotations @@ -29,6 +33,7 @@ import pytest from app.services import proxy_pool from app.services.proxy_pool import ( DISABLE_THRESHOLD, + DISABLED_RECHECK_MINUTES, MAX_CONSECUTIVE_FAILS, acquire, mark_health, @@ -71,17 +76,26 @@ class FakeSession: sql = str(stmt) p = params or {} - if "FOR UPDATE SKIP LOCKED" in sql: # acquire SELECT - provider = p["provider"] + if "FOR UPDATE SKIP LOCKED" in sql: # acquire SELECT (primary affinity-scoped or fallback) max_fails = p["max_fails"] - cands = [ - r - for r in self.rows - if r["enabled"] - and r["consecutive_fails"] < max_fails - and r["provider_affinity"] in (provider, "any") - and r["leased_by"] is None - ] + if "provider_affinity IN" in sql: # primary: своя affinity ИЛИ 'any' + provider = p["provider"] + cands = [ + r + for r in self.rows + if r["enabled"] + and r["consecutive_fails"] < max_fails + and r["provider_affinity"] in (provider, "any") + and r["leased_by"] is None + ] + else: # fallback: любая affinity (#2600 п.3) + cands = [ + r + for r in self.rows + if r["enabled"] + and r["consecutive_fails"] < max_fails + and r["leased_by"] is None + ] # ORDER BY last_ok_at NULLS LAST, id cands.sort( key=lambda r: ( @@ -127,18 +141,28 @@ class FakeSession: row["exit_ip"] = p["exit_ip"] row["latency_ms"] = p["latency_ms"] row["last_ok_at"] = datetime.now(UTC) + row["last_check_at"] = datetime.now(UTC) + row["enabled"] = True # реанимация выключенного узла (#2600 п.1) return _FakeResult([]) if "consecutive_fails = consecutive_fails + 1" in sql: # mark_health fail row = self._by_id(p["id"]) if row is not None: row["consecutive_fails"] += 1 + row["last_check_at"] = datetime.now(UTC) if row["consecutive_fails"] >= p["disable_threshold"]: row["enabled"] = False return _FakeResult([]) - if "WHERE enabled" in sql and "ORDER BY id" in sql: # healthcheck SELECT - rows = sorted((r for r in self.rows if r["enabled"]), key=lambda r: r["id"]) + if "WHERE enabled" in sql and "ORDER BY id" in sql: # healthcheck SELECT (#2600 п.1) + recheck_minutes = p["disabled_recheck_minutes"] + cutoff = datetime.now(UTC) - timedelta(minutes=recheck_minutes) + cands = [ + r + for r in self.rows + if r["enabled"] or r.get("last_check_at") is None or r["last_check_at"] < cutoff + ] + rows = sorted(cands, key=lambda r: r["id"]) return _FakeResult([dict(r) for r in rows]) raise AssertionError(f"unhandled SQL: {sql}") @@ -159,6 +183,7 @@ def _proxy( leased_by: int | None = None, leased_at: datetime | None = None, last_ok_at: datetime | None = None, + last_check_at: datetime | None = None, kind: str = "http", rotate_url: str | None = None, ) -> dict[str, Any]: @@ -173,6 +198,7 @@ def _proxy( "leased_by": leased_by, "leased_at": leased_at, "last_ok_at": last_ok_at, + "last_check_at": last_check_at, "exit_ip": None, "latency_ms": None, } @@ -209,9 +235,17 @@ def test_acquire_empty_pool_returns_none() -> None: assert acquire(db, "avito", run_id=1) is None # type: ignore[arg-type] -def test_acquire_affinity_filter_excludes_other_provider() -> None: +def test_acquire_affinity_filter_falls_back_instead_of_none() -> None: + """До #2600 такой сетап возвращал None (голодный источник); теперь — fallback-выдача. + + Поведение намеренно изменено п.3 issue #2600: чужой прокси лучше, чем никакого при + живом свободном узле. Дублирующее покрытие того же сценария — + test_acquire_falls_back_to_other_affinity_when_no_own_free. + """ db = FakeSession([_proxy(1, affinity="cian")]) - assert acquire(db, "avito", run_id=1) is None # type: ignore[arg-type] + lease = acquire(db, "avito", run_id=1) # type: ignore[arg-type] + assert lease is not None + assert lease.id == 1 def test_acquire_skips_disabled() -> None: @@ -231,6 +265,32 @@ def test_acquire_without_run_id_uses_marker() -> None: assert db._by_id(1)["leased_by"] == proxy_pool.NON_RUN_LEASE_MARKER +# ── acquire: fallback affinity (#2600 п.3 — не морить источник голодом) ──────── + + +def test_acquire_prefers_own_affinity_when_available() -> None: + """Своих (affinity=avito) хватает — приоритет не сломан, чужой (cian) не берём.""" + db = FakeSession([_proxy(1, affinity="avito"), _proxy(2, affinity="cian")]) + lease = acquire(db, "avito", run_id=1) # type: ignore[arg-type] + assert lease is not None + assert lease.id == 1 + + +def test_acquire_falls_back_to_other_affinity_when_no_own_free() -> None: + """Свободных avito/any нет, но есть свободный здоровый cian → fallback, а не None.""" + db = FakeSession([_proxy(1, affinity="cian")]) + lease = acquire(db, "avito", run_id=1) # type: ignore[arg-type] + assert lease is not None + assert lease.id == 1 + assert db._by_id(1)["leased_by"] == 1 + + +def test_acquire_no_fallback_when_nothing_free_at_all() -> None: + """Fallback не выдумывает прокси из воздуха — если свободных нет вообще, None.""" + db = FakeSession([_proxy(1, affinity="cian", leased_by=99)]) # занят + assert acquire(db, "avito", run_id=1) is None # type: ignore[arg-type] + + # ── release ────────────────────────────────────────────────────────────────── @@ -271,6 +331,15 @@ def test_mark_health_ok_resets_and_records() -> None: assert row["last_ok_at"] is not None +def test_mark_health_ok_revives_disabled_proxy() -> None: + """Успешная проба реанимирует выключенный узел (#2600 п.1) — enabled=true, fails=0.""" + db = FakeSession([_proxy(1, enabled=False, fails=DISABLE_THRESHOLD)]) + mark_health(db, 1, ok=True) # type: ignore[arg-type] + row = db._by_id(1) + assert row["enabled"] is True + assert row["consecutive_fails"] == 0 + + # ── reap_stale_leases ──────────────────────────────────────────────────────── @@ -295,27 +364,95 @@ def test_reap_frees_stale_lease_keeps_fresh() -> None: async def test_healthcheck_probes_enabled_and_marks_health( monkeypatch: pytest.MonkeyPatch, ) -> None: + recently_checked = datetime.now(UTC) - timedelta(minutes=5) # < DISABLED_RECHECK_MINUTES db = FakeSession( [ _proxy(1, fails=2), - _proxy(2, enabled=False), # disabled — не проверяется + # disabled, recheck ещё не наступил (недавно проверен) — не проверяется в этот прогон + _proxy(2, enabled=False, last_check_at=recently_checked), _proxy(3, fails=0), ] ) - async def _fake_probe(url: str) -> tuple[bool, str | None, int | None]: + async def _fake_probe(url: str) -> tuple[bool, str | None, int | None, str | None]: # прокси 1 «жив», прокси 3 «мёртв» if "h1:" in url: - return True, "9.9.9.9", 42 - return False, None, None + return True, "9.9.9.9", 42, None + return False, None, None, "other" monkeypatch.setattr(proxy_pool, "_probe_proxy", _fake_probe) counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] - assert counters["checked"] == 2 # только enabled (1 и 3) + assert counters["checked"] == 2 # только enabled (1 и 3), disabled recheck не наступил assert counters["ok"] == 1 assert counters["failed"] == 1 + assert counters["revived"] == 0 assert db._by_id(1)["consecutive_fails"] == 0 # ok → сброс assert db._by_id(1)["exit_ip"] == "9.9.9.9" assert db._by_id(3)["consecutive_fails"] == 1 # fail → инкремент + + +# ── run_proxy_healthcheck: self-healing disabled-узлов (#2600 п.1) ───────────── + + +async def test_healthcheck_revives_disabled_proxy_on_success( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Выключенный узел с успешной пробой возвращается в строй, revived++.""" + stale_check = datetime.now(UTC) - timedelta(minutes=DISABLED_RECHECK_MINUTES + 5) + db = FakeSession([_proxy(1, enabled=False, fails=DISABLE_THRESHOLD, last_check_at=stale_check)]) + + async def _fake_probe(url: str) -> tuple[bool, str | None, int | None, str | None]: + return True, "5.5.5.5", 30, None + + monkeypatch.setattr(proxy_pool, "_probe_proxy", _fake_probe) + + counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + assert counters["checked"] == 1 + assert counters["ok"] == 1 + assert counters["revived"] == 1 + row = db._by_id(1) + assert row["enabled"] is True + assert row["consecutive_fails"] == 0 + + +async def test_healthcheck_skips_recently_checked_disabled_proxy( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Выключенный узел, проверенный недавно, повторно не проверяется в этот прогон.""" + fresh_check = datetime.now(UTC) - timedelta(minutes=5) # < DISABLED_RECHECK_MINUTES + db = FakeSession([_proxy(1, enabled=False, fails=DISABLE_THRESHOLD, last_check_at=fresh_check)]) + probed: list[str] = [] + + async def _fake_probe(url: str) -> tuple[bool, str | None, int | None, str | None]: + probed.append(url) # не должно вызваться + return True, "5.5.5.5", 30, None + + monkeypatch.setattr(proxy_pool, "_probe_proxy", _fake_probe) + + counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + assert counters["checked"] == 0 + assert counters["revived"] == 0 + assert probed == [] # провайдер не долбим каждый тик + assert db._by_id(1)["enabled"] is False # остался выключенным + + +async def test_healthcheck_checks_disabled_proxy_never_checked_before( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Выключенный узел без last_check_at (никогда не проверялся) — проверяется сразу.""" + db = FakeSession([_proxy(1, enabled=False, fails=DISABLE_THRESHOLD, last_check_at=None)]) + + async def _fake_probe(url: str) -> tuple[bool, str | None, int | None, str | None]: + return False, None, None, "timeout" + + monkeypatch.setattr(proxy_pool, "_probe_proxy", _fake_probe) + + counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + assert counters["checked"] == 1 + assert counters["revived"] == 0 # неуспех — не реанимируем + assert db._by_id(1)["enabled"] is False From 876b6664242616dfdcbeb254f352524a16615cde Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 1 Aug 2026 21:36:02 +0300 Subject: [PATCH 050/141] =?UTF-8?q?fix(tradein/proxy):=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D0=BE=D1=82=D0=B4=D0=B0=D0=B2=D0=B0=D1=82=D1=8C=20=D0=B2=20fal?= =?UTF-8?q?lback=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=D0=B4=D0=BD=D0=B8=D0=B9?= =?UTF-8?q?=20=D1=83=D0=B7=D0=B5=D0=BB=20=D0=B2=D1=8B=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=BD=D0=BE=D0=B9=20affinity=20(#2600)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ревью PR #2609: domclick — ровно один узел (прод scrape_proxies.id=1), намеренно вырезанный из общего пула через provider_affinity='domclick' (см. 173_scrape_proxies_add_domclick_affinity.sql) — QRATOR банит всё, кроме этого одного чистого residential-адреса. Fallback-запрос из предыдущего коммита мог законно забрать его под avito/cian/yandex, оставив domclick (сейчас исправно собирает: 6501 активных объявлений, 368/сутки) без прокси вообще — чинили бы один источник ценой полной поломки другого. - acquire(): fallback-SELECT дополнен условием "affinity='any' ИЛИ есть ДРУГОЙ enabled-узел той же affinity" через коррелированный EXISTS- подзапрос (WHERE + FOR UPDATE SKIP LOCKED + ORDER BY last_ok_at NULLS LAST, id — сохранены). Кандидат с единственным enabled-узлом своей выделенной affinity в fallback не участвует. - Тесты: единственный domclick-узел → acquire('avito') возвращает None; второй enabled domclick-узел появляется — fallback снова срабатывает. - Починен мок FakeSession (tests/services/test_proxy_pool.py): ветка "mark_health ok" раньше ставила enabled=True безусловно по совпадению общей подстроки "SET consecutive_fails = 0" (одинаковой в старом и новом SQL) — test_mark_health_ok_revives_disabled_proxy проходил бы и против кода без реанимации. Теперь ставит enabled=True только если в тексте SQL реально есть "enabled". Та же проблема была и в fallback-ветке (protects_last_node переопределял логику в Python независимо от SQL) — исправлено аналогично: применяется, только если в SQL реально есть EXISTS-подзапрос. --- .../backend/app/services/proxy_pool.py | 42 ++++++++--- .../backend/tests/services/test_proxy_pool.py | 74 ++++++++++++++----- 2 files changed, 89 insertions(+), 27 deletions(-) diff --git a/tradein-mvp/backend/app/services/proxy_pool.py b/tradein-mvp/backend/app/services/proxy_pool.py index 3d4d4c0c..0406d6b2 100644 --- a/tradein-mvp/backend/app/services/proxy_pool.py +++ b/tradein-mvp/backend/app/services/proxy_pool.py @@ -30,7 +30,9 @@ Self-healing (#2600): Без этого auto-disable необратим: транзиентный сбой = вечный приговор узлу. - acquire, не найдя свободного здорового узла нужной provider_affinity, вторым заходом берёт любой свободный здоровый узел ЛЮБОЙ affinity (WARNING-лог) — иначе источник - голодает при живых свободных узлах чужой affinity. + голодает при живых свободных узлах чужой affinity. Fallback НЕ забирает последний + enabled-узел выделенной affinity (пример — domclick, один узел на всё, см. acquire + docstring) — иначе чинили бы один источник ценой полной поломки другого. psycopg v3 / SQLAlchemy text(): все параметры через CAST(:x AS type), НЕ :x::type. """ @@ -115,6 +117,15 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe чужая — только запасной вариант, чтобы источник не голодал при живых свободных узлах чужой affinity (#2600). + Fallback НЕ трогает последний enabled-узел выделенной (не-'any') affinity — см. + 173_scrape_proxies_add_domclick_affinity.sql: у domclick ровно один узел (id=1), + намеренно вырезанный из общего пула, потому что QRATOR банит все прокси кроме этого + одного чистого residential-адреса. Если fallback заберёт его под avito/cian/yandex, + domclick останется без прокси вообще — хуже, чем голодание исходного источника, + которое фикс призван устранить. Кандидат участвует в fallback, только если его + affinity='any' ИЛИ у этой affinity есть ДРУГОЙ enabled-узел (EXISTS-подзапрос) — + т.е. выдача не обнулит доступность выделенной affinity целиком. + Конкурентные acquire не дерутся за одну строку: SKIP LOCKED пропускает залоченную другим вызовом строку, второй параллельный acquire берёт следующую свободную. @@ -145,19 +156,30 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe fallback_used = False if row is None: - # Нет своих (provider/'any') — запасной заход: любой свободный здоровый узел, - # affinity не важна. Лучше выдать источнику чужой прокси, чем оставить его без - # прокси при живых свободных узлах. + # Нет своих (provider/'any') — запасной заход: любой свободный здоровый узел + # ЛЮБОЙ affinity, кроме последнего enabled-узла выделенной affinity (domclick и + # т.п.) — EXISTS-подзапрос требует хотя бы ОДИН ДРУГОЙ enabled-узел той же + # affinity, иначе affinity='any' достаточно. row = ( db.execute( text( """ - SELECT id, url, kind, rotate_url - FROM scrape_proxies - WHERE enabled - AND consecutive_fails < CAST(:max_fails AS integer) - AND leased_by IS NULL - ORDER BY last_ok_at NULLS LAST, id + SELECT sp.id, sp.url, sp.kind, sp.rotate_url + FROM scrape_proxies AS sp + WHERE sp.enabled + AND sp.consecutive_fails < CAST(:max_fails AS integer) + AND sp.leased_by IS NULL + AND ( + sp.provider_affinity = 'any' + OR EXISTS ( + SELECT 1 + FROM scrape_proxies AS other + WHERE other.provider_affinity = sp.provider_affinity + AND other.enabled + AND other.id <> sp.id + ) + ) + ORDER BY sp.last_ok_at NULLS LAST, sp.id FOR UPDATE SKIP LOCKED LIMIT 1 """ diff --git a/tradein-mvp/backend/tests/services/test_proxy_pool.py b/tradein-mvp/backend/tests/services/test_proxy_pool.py index 67634c62..d7631088 100644 --- a/tradein-mvp/backend/tests/services/test_proxy_pool.py +++ b/tradein-mvp/backend/tests/services/test_proxy_pool.py @@ -88,13 +88,31 @@ class FakeSession: and r["provider_affinity"] in (provider, "any") and r["leased_by"] is None ] - else: # fallback: любая affinity (#2600 п.3) + else: # fallback: любая affinity, но не последний узел выделенной affinity + # (domclick и т.п. — #2600 review). ВАЖНО: применяем эту фильтрацию, + # только если сама SQL реально содержит защиту (EXISTS-подзапрос) — + # иначе мок реализовывал бы бизнес-логику независимо от проверяемого + # кода и не смог бы отличить старый (незащищённый) fallback-запрос от + # нового. Тот же класс бага, что был с "enabled" в mark_health-моке. + protects_last_node = "EXISTS" in sql + + def _has_backup(row: dict[str, Any]) -> bool: + if row["provider_affinity"] == "any": + return True + return any( + other["provider_affinity"] == row["provider_affinity"] + and other["enabled"] + and other["id"] != row["id"] + for other in self.rows + ) + cands = [ r for r in self.rows if r["enabled"] and r["consecutive_fails"] < max_fails and r["leased_by"] is None + and (not protects_last_node or _has_backup(r)) ] # ORDER BY last_ok_at NULLS LAST, id cands.sort( @@ -142,7 +160,12 @@ class FakeSession: row["latency_ms"] = p["latency_ms"] row["last_ok_at"] = datetime.now(UTC) row["last_check_at"] = datetime.now(UTC) - row["enabled"] = True # реанимация выключенного узла (#2600 п.1) + # "SET consecutive_fails = 0" — общая подстрока старого И нового SQL, + # НЕ различает их сама по себе. Реанимация (enabled=true) — только если + # в тексте запроса реально есть присвоение enabled (#2600 review: старый + # мок ставил enabled=True безусловно и не ловил регресс). + if "enabled" in sql: + row["enabled"] = True return _FakeResult([]) if "consecutive_fails = consecutive_fails + 1" in sql: # mark_health fail @@ -235,19 +258,6 @@ def test_acquire_empty_pool_returns_none() -> None: assert acquire(db, "avito", run_id=1) is None # type: ignore[arg-type] -def test_acquire_affinity_filter_falls_back_instead_of_none() -> None: - """До #2600 такой сетап возвращал None (голодный источник); теперь — fallback-выдача. - - Поведение намеренно изменено п.3 issue #2600: чужой прокси лучше, чем никакого при - живом свободном узле. Дублирующее покрытие того же сценария — - test_acquire_falls_back_to_other_affinity_when_no_own_free. - """ - db = FakeSession([_proxy(1, affinity="cian")]) - lease = acquire(db, "avito", run_id=1) # type: ignore[arg-type] - assert lease is not None - assert lease.id == 1 - - def test_acquire_skips_disabled() -> None: db = FakeSession([_proxy(1, affinity="avito", enabled=False)]) assert acquire(db, "avito", run_id=1) is None # type: ignore[arg-type] @@ -277,8 +287,12 @@ def test_acquire_prefers_own_affinity_when_available() -> None: def test_acquire_falls_back_to_other_affinity_when_no_own_free() -> None: - """Свободных avito/any нет, но есть свободный здоровый cian → fallback, а не None.""" - db = FakeSession([_proxy(1, affinity="cian")]) + """Свободных avito/any нет, но есть свободный здоровый cian с бэкапом → fallback, а не None. + + Два cian-узла — забрать один через fallback безопасно: у cian остаётся другой + enabled-узел (protection на "последний узел affinity" не срабатывает). + """ + db = FakeSession([_proxy(1, affinity="cian"), _proxy(2, affinity="cian")]) lease = acquire(db, "avito", run_id=1) # type: ignore[arg-type] assert lease is not None assert lease.id == 1 @@ -291,6 +305,32 @@ def test_acquire_no_fallback_when_nothing_free_at_all() -> None: assert acquire(db, "avito", run_id=1) is None # type: ignore[arg-type] +# ── acquire: fallback НЕ забирает последний узел выделенной affinity (review #2609) ── +# +# domclick — ровно один узел (прод scrape_proxies.id=1), намеренно вырезанный из общего +# пула через provider_affinity='domclick': QRATOR банит всё, кроме этого одного чистого +# residential-адреса (см. 173_scrape_proxies_add_domclick_affinity.sql). Если fallback +# заберёт его под avito/cian/yandex — domclick (сейчас исправно собирает: 6501 активных +# объявлений, 368/сутки) останется без прокси вообще. Починка одного источника ценой +# полной поломки другого недопустима. + + +def test_acquire_fallback_protects_last_node_of_dedicated_affinity() -> None: + """Единственный enabled-узел domclick НЕ отдаётся avito через fallback — None.""" + db = FakeSession([_proxy(1, affinity="domclick")]) + assert acquire(db, "avito", run_id=1) is None # type: ignore[arg-type] + assert db._by_id(1)["leased_by"] is None # узел не тронут + + +def test_acquire_fallback_allows_when_dedicated_affinity_has_backup() -> None: + """Второй enabled-узел domclick есть → fallback как и раньше отдаёт свободный.""" + db = FakeSession([_proxy(1, affinity="domclick"), _proxy(2, affinity="domclick")]) + lease = acquire(db, "avito", run_id=1) # type: ignore[arg-type] + assert lease is not None + assert lease.id == 1 + assert db._by_id(2)["leased_by"] is None # у domclick остался живой запасной узел + + # ── release ────────────────────────────────────────────────────────────────── From b1563b86cbb14b364fce894511e2938778aed2c2 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 1 Aug 2026 21:41:12 +0300 Subject: [PATCH 051/141] =?UTF-8?q?feat(tradein/proxy):=20=D1=80=D0=BE?= =?UTF-8?q?=D1=82=D0=B0=D1=86=D0=B8=D1=8F=20exit-IP=20ASocks=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D0=B1=D0=B0=D0=BD=D1=83=20=D1=81=D0=BE=20=D1=81=D1=87?= =?UTF-8?q?=D1=91=D1=82=D1=87=D0=B8=D0=BA=D0=BE=D0=BC=20=D0=B8=20=D0=B3?= =?UTF-8?q?=D1=80=D0=BE=D0=BC=D0=BA=D0=B8=D0=BC=20=D0=BE=D1=82=D0=BA=D0=B0?= =?UTF-8?q?=D0=B7=D0=BE=D0=BC=20(#2600)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/backend/app/api/v1/admin.py | 42 ++ tradein-mvp/backend/app/core/config.py | 12 + .../backend/app/services/proxy_rotation.py | 311 ++++++++++++++ .../data/sql/198_scrape_proxy_rotations.sql | 49 +++ .../199_scrape_proxies_asocks_rotate_url.sql | 58 +++ .../tests/services/test_proxy_rotation.py | 388 ++++++++++++++++++ 6 files changed, 860 insertions(+) create mode 100644 tradein-mvp/backend/app/services/proxy_rotation.py create mode 100644 tradein-mvp/backend/data/sql/198_scrape_proxy_rotations.sql create mode 100644 tradein-mvp/backend/data/sql/199_scrape_proxies_asocks_rotate_url.sql create mode 100644 tradein-mvp/backend/tests/services/test_proxy_rotation.py diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py index 5ab89505..01dc273d 100644 --- a/tradein-mvp/backend/app/api/v1/admin.py +++ b/tradein-mvp/backend/app/api/v1/admin.py @@ -70,6 +70,7 @@ from app.core.db import SessionLocal, get_db from app.schemas.trade_in import ScheduleConfig, ScheduleConfigUpdate from app.services import cian_session as cian_session_svc from app.services import domclick_session as domclick_session_svc +from app.services import proxy_rotation as proxy_rotation_svc from app.services import scrape_runs as runs_mod from app.services.geocoder import geocode from app.services.scheduler import has_running_run @@ -2889,3 +2890,44 @@ def patch_proxy( created_at=_iso(row["created_at"]), updated_at=_iso(row["updated_at"]), ) + + +# ── Proxy pool: ручная ротация exit-IP по proxy_id (#2600 п.5) ─────────────── +# +# ОТДЕЛЬНО от /scraper/{source}/rotate-ip (выше) — тот работает по env-прокси +# mobileproxy для avito/cian/yandex (changeip-ссылка, ротация "на лету" без +# лимитов), не трогается. Этот эндпоинт — по proxy_id из пула scrape_proxies +# (сейчас это ASocks-порты с суточным лимитом 3/сутки), см. +# app.services.proxy_rotation.rotate_proxy. + + +class ProxyRotateResponse(BaseModel): + ok: bool + reason: str | None = None + new_ip: str | None = None + rotations_remaining_today: int + + +@router.post("/proxies/{proxy_id}/rotate", response_model=ProxyRotateResponse) +async def rotate_pool_proxy( + proxy_id: int, + db: Annotated[Session, Depends(get_db)], +) -> ProxyRotateResponse: + """Ручная ротация exit-IP одного прокси пула (#2600 п.5). + + Делегирует в app.services.proxy_rotation.rotate_proxy — читает rotate_url + прокси из scrape_proxies, требует ASOCKS_API_TOKEN (settings.asocks_api_token), + проверяет суточный лимит (3/сутки, scrape_proxy_rotations) ДО обращения к API. + ok=False — ожидаемая бизнес-ситуация (нет rotate_url / нет токена / лимит / + провайдер отказал), НЕ HTTPException; reason ВСЕГДА нейтральный, без токена. + + ПОКА без автотриггера по бану (issue #2600 п.2: сигнал бана до пула не + доходит — страница-заглушка отдаёт 200) — только этот ручной вызов. + """ + result = await proxy_rotation_svc.rotate_proxy(db, proxy_id) + return ProxyRotateResponse( + ok=result.ok, + reason=result.reason, + new_ip=result.new_ip, + rotations_remaining_today=result.rotations_remaining_today, + ) diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 7ee6f9e5..491c4600 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -548,6 +548,18 @@ class Settings(BaseSettings): proxy_rotate_attempt_timeout_s: float = 8.0 proxy_rotate_attempts: int = 3 + # ── ASocks pool-proxy rotation (#2600) ─────────────────────────────────── + # Bearer-токен веб-кабинета ASocks для POST .../unlimited-proxy/{portId}/refresh-ip + # (app.services.proxy_rotation). Документированный публичный API (GET + # /v2/proxy/refresh/{portId}?apiKey=) для безлимитных портов не работает — + # подтверждено владельцем аккаунта; единственный рабочий путь — эта ручка + # веб-кабинета с сессионным токеном. Токен разово протухнет (осознанное + # решение владельца) — тогда provider вернёт 401, proxy_rotation.rotate_proxy + # логирует error + шлёт Sentry/GlitchTip alert. Пусто = ротация для всех + # прокси недоступна (rotate_proxy возвращает внятный отказ, не падает). + # ENV: ASOCKS_API_TOKEN. НИКОГДА не логировать / не возвращать в HTTP-ответе. + asocks_api_token: str = Field(default="", validation_alias="ASOCKS_API_TOKEN") + # #1950: если SERP уже сохранил лоты (ins+upd > 0) и упали только detail/houses, # ставим 'done' а не 'banned' — partial intake сохранён, 'banned' лишний. # False = старое поведение. ENV: AVITO_SERP_OK_NOT_BANNED. diff --git a/tradein-mvp/backend/app/services/proxy_rotation.py b/tradein-mvp/backend/app/services/proxy_rotation.py new file mode 100644 index 00000000..9ff922c8 --- /dev/null +++ b/tradein-mvp/backend/app/services/proxy_rotation.py @@ -0,0 +1,311 @@ +"""Ротация exit-IP прокси ASocks по требованию, со счётчиком и громким отказом (#2600 п.5). + +АДДИТИВНО. НЕ трогает app.services.proxy_pool (pick/lease/health — параллельный +PR #2609, конфликт исключён: вся новая логика тут, в новом модуле). + +Контекст (эмпирика, issue #2600 п.5 — проверено владельцем аккаунта/пробой): + - Документированный публичный API ASocks (GET /v2/proxy/refresh/{portId}?apiKey=) + для безлимитных портов НЕ работает. + - Ротация сменой session-суффикса логина (-session-N) НЕ работает — exit-IP + не меняется (три варианта дали один и тот же IP). + - Единственный рабочий путь — ручка веб-кабинета: + POST https://api.asocks.com/unlimited-proxy/{portId}/refresh-ip + Authorization: Bearer <токен> + Без заголовка провайдер отдаёт 401 {"success": false, "message": "Unauthenticated"}. + scrape_proxies.rotate_url уже несёт этот URL (миграция 199) — токен НЕ в URL, + он только в ASOCKS_API_TOKEN (env, app.core.config.settings.asocks_api_token). + - Лимит провайдера: 3 ротации в сутки на порт. + - Токен — сессионный, однажды протухнет (осознанное решение владельца аккаунта). + Когда это случится, провайдер ответит 401 — это ГРОМКИЙ отказ ниже + (logger.error + Sentry/GlitchTip capture_message), а не молчаливая остановка. + +Суточный лимит и таблица истории (scrape_proxy_rotations, миграция 198): + Против лимита 3/сутки считаются ТОЛЬКО попытки, реально дошедшие до провайдера + и обработанные им — т.е. любой HTTP-ответ провайдера, КРОМЕ 401. Обоснование: + 401 — это буквально описание провайдера "Unauthenticated": запрос отсеян на + уровне аутентификации ДО обращения к самой логике ротации порта, провайдер не + мог засчитать использование ротации тому, кого даже не подтвердил. Сетевые + ошибки (таймаут / разрыв соединения — ответа вообще нет) по той же логике не + считаются: нет подтверждения, что запрос вообще дошёл до провайдера. Локальные + отказы (нет rotate_url / нет токена / лимит уже исчерпан) до HTTP-вызова не + доходят вовсе — в таблицу не пишутся и лимит не трогают. + + quota-consuming := http_status IS NOT NULL AND http_status != 401 + (успех 200 И любой не-401 ответ провайдера, включая его собственные 4xx/5xx — + если провайдер прошёл auth и ответил бизнес-ошибкой, запрос точно дошёл до + реальной rotate-логики и мог быть учтён в лимите на его стороне). + +⛔ Токен никогда не должен появиться в возвращаемом клиенту reason, в тексте +исключения, ни в одной записи scrape_proxy_rotations. Прецедент утечки через +str(exc) — см. комментарий в app.api.v1.admin.rotate_proxy_ip (~line 2400): +httpx-исключения несут полный request URL/детали, поэтому наружу — только +нейтральный reason, полные детали — в лог с exc_info=True. + +psycopg v3 / SQLAlchemy text(): все параметры через CAST(:x AS type), НЕ :x::type. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Any + +import httpx +from sqlalchemy import text +from sqlalchemy.orm import Session + +from app.core.config import settings + +logger = logging.getLogger(__name__) + +__all__ = [ + "DAILY_ROTATION_LIMIT", + "RotationResult", + "rotate_proxy", +] + +# Лимит провайдера (ASocks, безлимитные порты): 3 ротации в сутки на порт (эмпирика). +DAILY_ROTATION_LIMIT = 3 + +# Таймаут POST refresh-ip. Пункт задачи требует "~30с". +_ROTATE_TIMEOUT_S = 30.0 + + +@dataclass +class RotationResult: + """Результат попытки ротации exit-IP одного прокси. reason — ВСЕГДА нейтральный + (безопасен для HTTP-ответа клиенту), никогда не несёт токен/секреты.""" + + ok: bool + reason: str | None + new_ip: str | None = None + # Сколько quota-consuming попыток остаётся сегодня ПОСЛЕ этой попытки (см. модуль + # docstring за определением quota-consuming). Для локально отклонённых попыток + # (no rotate_url/no token) не относится к текущему прокси — просто текущий остаток. + rotations_remaining_today: int = DAILY_ROTATION_LIMIT + + +def _quota_used_today(db: Session, proxy_id: int) -> int: + """Число quota-consuming попыток за последние 24ч (см. docstring модуля). + + http_status IS NOT NULL AND != 401 — успех И любой не-401 ответ провайдера. + 401 (auth-отсев) и сетевые ошибки (http_status IS NULL) не считаются. + """ + row = ( + db.execute( + text( + """ + SELECT count(*) AS n + FROM scrape_proxy_rotations + WHERE proxy_id = CAST(:proxy_id AS bigint) + AND rotated_at > now() - interval '24 hours' + AND http_status IS NOT NULL + AND http_status != 401 + """ + ), + {"proxy_id": proxy_id}, + ) + .mappings() + .fetchone() + ) + return int(row["n"]) if row is not None else 0 + + +def _record_attempt( + db: Session, + proxy_id: int, + *, + success: bool, + http_status: int | None, + note: str | None, +) -> None: + """Записать попытку ротации в аудит-таблицу. Вызывается ТОЛЬКО когда HTTP-запрос + к провайдеру реально был сделан (локально отклонённые попытки не пишутся — + см. модуль docstring).""" + db.execute( + text( + """ + INSERT INTO scrape_proxy_rotations (proxy_id, success, http_status, note) + VALUES ( + CAST(:proxy_id AS bigint), + CAST(:success AS boolean), + CAST(:http_status AS integer), + CAST(:note AS text) + ) + """ + ), + {"proxy_id": proxy_id, "success": success, "http_status": http_status, "note": note}, + ) + db.commit() + + +def _alert_stale_token(proxy_id: int) -> None: + """Громкий отказ на 401: logger.error + событие в Sentry/GlitchTip (best-effort). + + 401 значит, что провайдер отверг Authorization-заголовок — токен протух (issue + #2600 п.5: "Токен — сессионный, однажды протухнет. Это осознанное решение + владельца"). Молчаливая остановка ротации недопустима — операторы должны узнать + об этом сразу, а не когда прокси уже забанены неделю. + """ + logger.error( + "proxy_rotation: ASocks REJECTED Authorization (401) for proxy_id=%d — " + "ASOCKS_API_TOKEN likely EXPIRED, IP rotation is now BLOCKED for this proxy " + "until the token is refreshed in web-cabinet + env", + proxy_id, + ) + try: + import sentry_sdk + + sentry_sdk.capture_message( + f"ASocks rotation token rejected (401) for proxy_id={proxy_id} — " + "ASOCKS_API_TOKEN expired, IP rotation blocked until refreshed", + level="error", + ) + except Exception: + pass # sentry_sdk not initialised in dev — best-effort only + + +def _extract_new_ip(resp: httpx.Response) -> str | None: + """Best-effort вытащить новый exit-IP из ответа провайдера. Формат ответа + refresh-ip для безлимитных портов ASocks не документирован (issue #2600 п.5) — + парсинг заведомо defensive, неудача не является ошибкой ротации.""" + try: + data: Any = resp.json() + except Exception: + return None + if not isinstance(data, dict): + return None + for key in ("new_ip", "ip", "exit_ip"): + val = data.get(key) + if val: + return str(val) + nested = data.get("data") + if isinstance(nested, dict): + for key in ("new_ip", "ip", "exit_ip"): + val = nested.get(key) + if val: + return str(val) + return None + + +async def rotate_proxy(db: Session, proxy_id: int) -> RotationResult: + """Сменить exit-IP одного прокси пула через ASocks refresh-ip (#2600 п.5). + + Порядок: + 1. proxy_id не найден в scrape_proxies → ok=False, reason нейтральный. + 2. rotate_url пусто → ok=False, "ротация не поддерживается" (НЕ ошибка). + 3. ASOCKS_API_TOKEN не задан (settings.asocks_api_token) → ok=False, + внятный отказ, ничего не ломается. + 4. Суточный лимит (см. _quota_used_today) исчерпан → ok=False, отказ БЕЗ + обращения к API. + 5. POST rotate_url с Authorization: Bearer , timeout ~30с. + - Сетевая ошибка (нет ответа) → ok=False, аудит-запись http_status=NULL + (НЕ считается в лимите), нейтральный reason, детали в лог exc_info=True. + - 401 → громкий отказ (_alert_stale_token) + аудит-запись (НЕ считается + в лимите), нейтральный reason. + - Другой 4xx/5xx → аудит-запись (считается в лимите — провайдер прошёл + auth и ответил своей бизнес-логикой), нейтральный reason. + - 2xx → аудит-запись success=True (считается в лимите), new_ip best-effort. + + Ни в одном из reason/логов НЕ появляется токен. + """ + row = ( + db.execute( + text("SELECT id, rotate_url FROM scrape_proxies WHERE id = CAST(:id AS bigint)"), + {"id": proxy_id}, + ) + .mappings() + .fetchone() + ) + if row is None: + return RotationResult(ok=False, reason="proxy not found") + + rotate_url = row["rotate_url"] + if not rotate_url: + logger.info( + "proxy_rotation: proxy_id=%d has no rotate_url — rotation not supported", proxy_id + ) + return RotationResult( + ok=False, reason="rotation not supported for this proxy (no rotate_url configured)" + ) + + token = settings.asocks_api_token + if not token: + logger.warning( + "proxy_rotation: ASOCKS_API_TOKEN not configured — proxy_id=%d rotation skipped", + proxy_id, + ) + return RotationResult(ok=False, reason="rotation not configured (missing API token)") + + used = _quota_used_today(db, proxy_id) + if used >= DAILY_ROTATION_LIMIT: + logger.warning( + "proxy_rotation: daily limit reached proxy_id=%d used=%d/%d — skipping API call", + proxy_id, + used, + DAILY_ROTATION_LIMIT, + ) + return RotationResult( + ok=False, + reason=f"daily rotation limit reached ({DAILY_ROTATION_LIMIT}/day)", + rotations_remaining_today=0, + ) + + try: + async with httpx.AsyncClient(timeout=_ROTATE_TIMEOUT_S) as client: + resp = await client.post(rotate_url, headers={"Authorization": f"Bearer {token}"}) + except Exception: + # Ответа не было вообще — не подтверждено, что запрос дошёл до провайдера, + # значит квота НЕ тратится. str(exc) НИКОГДА не идёт наружу (может нести + # служебные детали соединения) — только exc_info=True в лог. + logger.warning( + "proxy_rotation: request failed (no response) proxy_id=%d", proxy_id, exc_info=True + ) + _record_attempt( + db, proxy_id, success=False, http_status=None, note="request failed (no response)" + ) + return RotationResult( + ok=False, + reason="rotation request failed (network error)", + rotations_remaining_today=max(0, DAILY_ROTATION_LIMIT - used), + ) + + status = resp.status_code + + if status == 401: + _alert_stale_token(proxy_id) + _record_attempt( + db, + proxy_id, + success=False, + http_status=401, + note="unauthenticated — token expired/invalid (excluded from daily quota)", + ) + return RotationResult( + ok=False, + reason="rotation service rejected credentials — alerted, contact operator", + rotations_remaining_today=max(0, DAILY_ROTATION_LIMIT - used), + ) + + if status >= 400: + logger.warning( + "proxy_rotation: provider returned error proxy_id=%d status=%d", proxy_id, status + ) + _record_attempt( + db, proxy_id, success=False, http_status=status, note="provider returned error" + ) + return RotationResult( + ok=False, + reason=f"rotation request failed (provider status {status})", + rotations_remaining_today=max(0, DAILY_ROTATION_LIMIT - (used + 1)), + ) + + new_ip = _extract_new_ip(resp) + logger.info("proxy_rotation: rotated proxy_id=%d status=%d new_ip=%s", proxy_id, status, new_ip) + _record_attempt(db, proxy_id, success=True, http_status=status, note=None) + return RotationResult( + ok=True, + reason=None, + new_ip=new_ip, + rotations_remaining_today=max(0, DAILY_ROTATION_LIMIT - (used + 1)), + ) diff --git a/tradein-mvp/backend/data/sql/198_scrape_proxy_rotations.sql b/tradein-mvp/backend/data/sql/198_scrape_proxy_rotations.sql new file mode 100644 index 00000000..870d85bc --- /dev/null +++ b/tradein-mvp/backend/data/sql/198_scrape_proxy_rotations.sql @@ -0,0 +1,49 @@ +-- 198_scrape_proxy_rotations.sql +-- Issue #2600 п.5 — ротация exit-IP прокси ASocks по бану, со счётчиком и громким +-- отказом. АДДИТИВНО, не трогает scrape_proxies (157_scrape_proxies.sql) кроме +-- FK-ссылки; не трогает proxy_pool.py (параллельный PR #2609). +-- +-- WHY: +-- Провайдер (ASocks, безлимитные порты) ограничивает ручную ротацию exit-IP тремя +-- вызовами в сутки на порт (эмпирика, владелец аккаунта). app.services.proxy_rotation +-- должен и проверять этот лимит ПЕРЕД обращением к API, и вести аудит попыток — +-- без отдельной таблицы истории лимит негде считать (scrape_proxies хранит только +-- текущее состояние, не историю). +-- +-- Semantics: +-- Одна строка = одна попытка ротации (успешная ИЛИ неуспешная), но НЕ каждый +-- вызов rotate_proxy() пишет строку — локально отклонённые попытки (нет +-- rotate_url / нет ASOCKS_API_TOKEN / лимит уже исчерпан) вообще не доходят до +-- HTTP-вызова и в таблицу не пишутся (см. app.services.proxy_rotation docstring +-- за полным обоснованием "какие попытки считать против лимита"). +-- http_status NULL = сетевая ошибка (ответа от провайдера не было вообще). +-- +-- Idempotency: +-- CREATE TABLE IF NOT EXISTS + CREATE INDEX IF NOT EXISTS → повторный прогон +-- no-op (auto-apply strict на деплое это требует). Весь файл в BEGIN/COMMIT. +-- +-- Dependencies: +-- 157_scrape_proxies.sql (scrape_proxies.id — FK-таргет). + +BEGIN; + +CREATE TABLE IF NOT EXISTS scrape_proxy_rotations ( + id bigserial PRIMARY KEY, + proxy_id bigint NOT NULL REFERENCES scrape_proxies (id), + rotated_at timestamptz NOT NULL DEFAULT now(), + success boolean NOT NULL, + http_status integer, + note text +); + +COMMENT ON TABLE scrape_proxy_rotations IS + 'Аудит + суточный лимит (#2600 п.5) ручных ротаций exit-IP через ASocks ' + 'refresh-ip. Лимит провайдера — 3 попытки/сутки на порт; app.services.' + 'proxy_rotation._quota_used_today считает только строки с http_status ' + 'IS NOT NULL AND != 401 (реально дошедшие до провайдера) за последние 24ч.'; + +-- Проверка суточного лимита + выборка истории по прокси: (proxy_id, rotated_at). +CREATE INDEX IF NOT EXISTS idx_scrape_proxy_rotations_proxy_time + ON scrape_proxy_rotations (proxy_id, rotated_at); + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/199_scrape_proxies_asocks_rotate_url.sql b/tradein-mvp/backend/data/sql/199_scrape_proxies_asocks_rotate_url.sql new file mode 100644 index 00000000..15c8e0dd --- /dev/null +++ b/tradein-mvp/backend/data/sql/199_scrape_proxies_asocks_rotate_url.sql @@ -0,0 +1,58 @@ +-- 199_scrape_proxies_asocks_rotate_url.sql +-- Issue #2600 п.5 — проставить rotate_url для четырёх ASocks unlimited-портов пула, +-- чтобы app.services.proxy_rotation.rotate_proxy имел куда стучаться. +-- +-- WHY: +-- scrape_proxies.rotate_url для этих 4 строк сейчас NULL (загружены через +-- POST /proxies/bulk без rotate_url). Единственный рабочий способ ротации exit-IP +-- для ASocks-безлимитных портов — ручка веб-кабинета +-- POST https://api.asocks.com/unlimited-proxy/{portId}/refresh-ip с заголовком +-- Authorization: Bearer (env, НЕ в URL — секретов в миграции +-- нет). Документированный публичный GET /v2/proxy/refresh/{portId}?apiKey= для +-- безлимитных портов не работает (подтверждено владельцем аккаунта); ротация +-- session-суффиксом логина тоже не работает (проверено пробой, три варианта — +-- один и тот же exit-IP). +-- +-- Matching (важно — НЕ по id): +-- scrape_proxies.id может разъехаться между средами (dev/stage/prod грузятся +-- bulk-ручкой независимо) — сопоставляем по адресу host:port, зашитому в конец +-- url (scrape_proxies.url — всегда 'scheme://[user:pass@]host:port' БЕЗ пути, +-- см. admin.py _mask_proxy_url/urlparse-логику и 157_scrape_proxies.sql) через +-- right(url, length(hostport)) = hostport. portId → host:port (проверено +-- владельцем аккаунта, issue #2600 п.5): +-- 223610715 → 212.8.249.134:10423 +-- 225031312 → 190.2.145.131:10313 +-- 231878029 → 175.110.115.153:10492 +-- 231878030 → 109.236.82.42:11048 +-- +-- Idempotency: +-- Обычный UPDATE ... WHERE — повторный прогон пишет то же значение, no-op по +-- результату. Прокси, которых нет в пуле текущей среды (host:port не найден) — +-- 0 строк обновлено, не ошибка. Весь файл в BEGIN/COMMIT. +-- +-- Dependencies: +-- 157_scrape_proxies.sql (scrape_proxies.rotate_url). + +BEGIN; + +UPDATE scrape_proxies +SET rotate_url = 'https://api.asocks.com/unlimited-proxy/223610715/refresh-ip', + updated_at = now() +WHERE right(url, length(CAST('212.8.249.134:10423' AS text))) = '212.8.249.134:10423'; + +UPDATE scrape_proxies +SET rotate_url = 'https://api.asocks.com/unlimited-proxy/225031312/refresh-ip', + updated_at = now() +WHERE right(url, length(CAST('190.2.145.131:10313' AS text))) = '190.2.145.131:10313'; + +UPDATE scrape_proxies +SET rotate_url = 'https://api.asocks.com/unlimited-proxy/231878029/refresh-ip', + updated_at = now() +WHERE right(url, length(CAST('175.110.115.153:10492' AS text))) = '175.110.115.153:10492'; + +UPDATE scrape_proxies +SET rotate_url = 'https://api.asocks.com/unlimited-proxy/231878030/refresh-ip', + updated_at = now() +WHERE right(url, length(CAST('109.236.82.42:11048' AS text))) = '109.236.82.42:11048'; + +COMMIT; diff --git a/tradein-mvp/backend/tests/services/test_proxy_rotation.py b/tradein-mvp/backend/tests/services/test_proxy_rotation.py new file mode 100644 index 00000000..a51adbd4 --- /dev/null +++ b/tradein-mvp/backend/tests/services/test_proxy_rotation.py @@ -0,0 +1,388 @@ +"""Offline-тесты ротации exit-IP ASocks (#2600 п.5). + +Покрытие БЕЗ live-сети/БД: httpx.AsyncClient подменён предсказуемым фейком, +FakeSession эмулирует scrape_proxies (одна строка) + scrape_proxy_rotations +(append-only список), pytest-asyncio (asyncio_mode=auto, см. pyproject.toml). + + - rotate_url пуст → «не поддерживается», НЕ ошибка, HTTP не дёргается. + - ASOCKS_API_TOKEN не задан → внятный отказ, HTTP не дёргается. + - 4-я попытка за сутки отклоняется БЕЗ обращения к API (лимит 3/сутки). + - Успешная ротация пишет запись в scrape_proxy_rotations (success=True). + - 401 → logger.error (громкий отказ) + sentry_sdk.capture_message (мониторинг), + аудит-запись пишется, но НЕ считается против суточного лимита. + - Токен не появляется ни в RotationResult.reason, ни в note аудит-записи — + ни в одном из сценариев (сеть-ошибка, 401, provider 5xx, success). +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import logging +from datetime import UTC, datetime, timedelta +from typing import Any + +import httpx +import pytest + +from app.services import proxy_rotation + +SECRET_TOKEN = "asocks-super-secret-token-must-never-leak-1a2b3c" + +# ── stateful fakes ──────────────────────────────────────────────────────────── + + +class _FakeResult: + def __init__(self, rows: list[dict[str, Any]]): + self._rows = rows + + def mappings(self) -> _FakeResult: + return self + + def fetchone(self) -> dict[str, Any] | None: + return self._rows[0] if self._rows else None + + +class FakeSession: + """Эмуляция Session: одна строка scrape_proxies + append-only + scrape_proxy_rotations, интерпретирует SQL по ключевым фрагментам (тот же + паттерн, что tests/services/test_proxy_pool.py).""" + + def __init__( + self, + proxy_row: dict[str, Any] | None, + rotations: list[dict[str, Any]] | None = None, + ): + self.proxy_row = proxy_row + self.rotations: list[dict[str, Any]] = rotations or [] + self.commits = 0 + + def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult: + sql = str(stmt) + p = params or {} + + if "SELECT id, rotate_url FROM scrape_proxies" in sql: + if self.proxy_row is None or self.proxy_row["id"] != p["id"]: + return _FakeResult([]) + return _FakeResult([dict(self.proxy_row)]) + + if "SELECT count(*) AS n" in sql and "scrape_proxy_rotations" in sql: + cutoff = datetime.now(UTC) - timedelta(hours=24) + n = sum( + 1 + for r in self.rotations + if r["proxy_id"] == p["proxy_id"] + and r["rotated_at"] > cutoff + and r["http_status"] is not None + and r["http_status"] != 401 + ) + return _FakeResult([{"n": n}]) + + if "INSERT INTO scrape_proxy_rotations" in sql: + self.rotations.append( + { + "proxy_id": p["proxy_id"], + "success": p["success"], + "http_status": p["http_status"], + "note": p["note"], + "rotated_at": datetime.now(UTC), + } + ) + return _FakeResult([]) + + raise AssertionError(f"unhandled SQL: {sql}") + + def commit(self) -> None: + self.commits += 1 + + def rollback(self) -> None: + pass + + +class _FakeResponse: + def __init__(self, status_code: int, json_data: dict[str, Any] | None): + self.status_code = status_code + self._json_data = json_data + + def json(self) -> dict[str, Any]: + if self._json_data is None: + raise ValueError("no json body") + return self._json_data + + +def _fake_async_client( + *, + response: tuple[int, dict[str, Any] | None] | None, + exception: Exception | None, +): + """Строит замену httpx.AsyncClient, никогда не бьющую в реальную сеть. + + Ровно один из (response, exception) задан. calls накапливает (url, headers) + каждого post() — тест проверяет по ним, был ли вообще HTTP-вызов. + """ + calls: list[dict[str, Any]] = [] + + class _FakeClientImpl: + def __init__(self, timeout: float | None = None) -> None: + self.timeout = timeout + + async def __aenter__(self) -> _FakeClientImpl: + return self + + async def __aexit__(self, *exc: object) -> bool: + return False + + async def post(self, url: str, headers: dict[str, str] | None = None) -> _FakeResponse: + calls.append({"url": url, "headers": headers or {}}) + if exception is not None: + raise exception + assert response is not None + status, body = response + return _FakeResponse(status, body) + + return _FakeClientImpl, calls + + +def _no_http_allowed(): + """httpx.AsyncClient-заглушка, падающая AssertionError при любом post() — + для сценариев, где HTTP до провайдера дойти НЕ должно.""" + + class _ForbiddenClient: + def __init__(self, timeout: float | None = None) -> None: + pass + + async def __aenter__(self) -> _ForbiddenClient: + return self + + async def __aexit__(self, *exc: object) -> bool: + return False + + async def post(self, *a: object, **kw: object) -> None: + raise AssertionError("HTTP call must NOT happen for this scenario") + + return _ForbiddenClient + + +_DEFAULT_ROTATE_URL = "https://api.asocks.com/unlimited-proxy/1/refresh-ip" + + +def _proxy_row(rotate_url: str | None = _DEFAULT_ROTATE_URL) -> dict[str, Any]: + return {"id": 1, "rotate_url": rotate_url} + + +def _quota_rows(proxy_id: int, n: int, *, http_status: int = 200) -> list[dict[str, Any]]: + now = datetime.now(UTC) + return [ + { + "proxy_id": proxy_id, + "success": http_status < 400, + "http_status": http_status, + "note": None, + "rotated_at": now - timedelta(minutes=i), + } + for i in range(n) + ] + + +# ── no rotate_url → not an error ──────────────────────────────────────────── + + +async def test_no_rotate_url_is_not_an_error(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN) + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed()) + + db = FakeSession(_proxy_row(rotate_url=None)) + result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type] + + assert result.ok is False + assert result.reason is not None + assert "rotat" in result.reason.lower() # human-readable, not a crash + assert db.rotations == [] # ничего не писалось — попытки не было + + +# ── missing token → neutral refusal, no crash ─────────────────────────────── + + +async def test_missing_token_is_neutral_refusal(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", "") + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed()) + + db = FakeSession(_proxy_row()) + result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type] + + assert result.ok is False + assert result.reason is not None + assert db.rotations == [] + + +# ── daily limit ────────────────────────────────────────────────────────────── + + +async def test_fourth_attempt_today_rejected_without_api_call( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN) + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed()) + + # 3 quota-consuming попытки уже сегодня (успешные 200 — засчитываются). + db = FakeSession(_proxy_row(), _quota_rows(1, proxy_rotation.DAILY_ROTATION_LIMIT)) + result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type] + + assert result.ok is False + assert "limit" in (result.reason or "").lower() or "лимит" in (result.reason or "").lower() + assert result.rotations_remaining_today == 0 + # _no_http_allowed() would have raised AssertionError from within rotate_proxy + # if the code had tried an HTTP call — reaching here means it didn't. + assert len(db.rotations) == proxy_rotation.DAILY_ROTATION_LIMIT # ничего нового не дописано + + +# ── success writes history ────────────────────────────────────────────────── + + +async def test_successful_rotation_writes_history_row(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN) + fake_client, calls = _fake_async_client(response=(200, {"ip": "9.9.9.9"}), exception=None) + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client) + + db = FakeSession(_proxy_row()) + result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type] + + assert result.ok is True + assert result.new_ip == "9.9.9.9" + assert result.rotations_remaining_today == proxy_rotation.DAILY_ROTATION_LIMIT - 1 + assert len(calls) == 1 + assert calls[0]["headers"]["Authorization"] == f"Bearer {SECRET_TOKEN}" + + assert len(db.rotations) == 1 + row = db.rotations[0] + assert row["success"] is True + assert row["http_status"] == 200 + assert db.commits >= 1 + + +# ── 401 → loud failure ─────────────────────────────────────────────────────── + + +async def test_401_logs_error_and_alerts_monitoring_excluded_from_quota( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN) + fake_client, calls = _fake_async_client( + response=(401, {"success": False, "message": "Unauthenticated"}), exception=None + ) + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client) + + sentry_calls: list[tuple[str, str | None]] = [] + monkeypatch.setattr( + "sentry_sdk.capture_message", + lambda msg, level=None: sentry_calls.append((msg, level)), + ) + + db = FakeSession(_proxy_row()) + with caplog.at_level(logging.ERROR): + result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type] + + assert result.ok is False + assert len(calls) == 1 # запрос реально ушёл + + # громкий отказ: и лог, и мониторинг — не молчаливая остановка + error_records = [r for r in caplog.records if r.levelno == logging.ERROR] + assert any("401" in r.getMessage() for r in error_records) + assert len(sentry_calls) == 1 + assert sentry_calls[0][1] == "error" + + # аудит записан, но 401 НЕ считается против суточного лимита (см. модуль + # docstring: auth-отсев до провайдера, лимит на его стороне не тратится). + assert len(db.rotations) == 1 + assert db.rotations[0]["http_status"] == 401 + assert db.rotations[0]["success"] is False + assert proxy_rotation._quota_used_today(db, 1) == 0 # type: ignore[arg-type] + + second = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type] + # 401 не съел лимит — снова полный DAILY_ROTATION_LIMIT доступен + assert second.rotations_remaining_today == proxy_rotation.DAILY_ROTATION_LIMIT + + +# ── token never leaks ──────────────────────────────────────────────────────── + + +async def test_token_never_appears_in_reason_success(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN) + fake_client, _ = _fake_async_client(response=(200, {"ip": "1.1.1.1"}), exception=None) + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client) + + db = FakeSession(_proxy_row()) + result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type] + assert SECRET_TOKEN not in (result.reason or "") + assert SECRET_TOKEN not in (result.new_ip or "") + + +async def test_token_never_appears_in_reason_on_401(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN) + monkeypatch.setattr("sentry_sdk.capture_message", lambda *a, **kw: None) + fake_client, _ = _fake_async_client( + response=(401, {"message": "Unauthenticated"}), exception=None + ) + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client) + + db = FakeSession(_proxy_row()) + result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type] + assert SECRET_TOKEN not in (result.reason or "") + assert all(SECRET_TOKEN not in (r["note"] or "") for r in db.rotations) + + +async def test_token_never_appears_in_reason_on_network_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """httpx-исключения могут нести полный request-контекст (URL/детали) — + прецедент утечки: app.api.v1.admin.rotate_proxy_ip (~line 2400). Здесь токен + живёт только в headers (не в URL), но проверяем end-to-end: даже если + exception-текст содержит секрет (симулируем это явно), наружу он не идёт.""" + monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN) + boom = httpx.ConnectError(f"connection failed while POSTing token={SECRET_TOKEN}") + fake_client, _ = _fake_async_client(response=None, exception=boom) + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client) + + db = FakeSession(_proxy_row()) + result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type] + + assert result.ok is False + assert SECRET_TOKEN not in (result.reason or "") + assert all(SECRET_TOKEN not in (r["note"] or "") for r in db.rotations) + # сетевая ошибка не подтверждает, что провайдер обработал попытку → квота не тратится + assert db.rotations[0]["http_status"] is None + assert proxy_rotation._quota_used_today(db, 1) == 0 # type: ignore[arg-type] + + +async def test_token_never_appears_on_provider_error_status( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN) + fake_client, _ = _fake_async_client( + response=(500, {"message": "internal error"}), exception=None + ) + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client) + + db = FakeSession(_proxy_row()) + result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type] + + assert result.ok is False + assert SECRET_TOKEN not in (result.reason or "") + # провайдер прошёл auth и ответил своей ошибкой (500) — засчитывается в квоту + assert db.rotations[0]["http_status"] == 500 + assert proxy_rotation._quota_used_today(db, 1) == 1 # type: ignore[arg-type] + + +# ── proxy not found ────────────────────────────────────────────────────────── + + +async def test_unknown_proxy_id_returns_neutral_not_found(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN) + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed()) + + db = FakeSession(None) + result = await proxy_rotation.rotate_proxy(db, 999) # type: ignore[arg-type] + assert result.ok is False + assert result.reason is not None From f83ca44179a260ff691bb5d97ab3e49429b41c20 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 1 Aug 2026 21:52:00 +0300 Subject: [PATCH 052/141] =?UTF-8?q?fix(tradein/data):=20=D1=83=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D0=BB=D0=BE=D0=B6=D0=BD=D1=8B=D0=B9=20reg?= =?UTF-8?q?ion=5Fcode=3D66=20=D1=83=20=D0=BE=D0=B1=D1=8A=D1=8F=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B9=20=D1=87=D1=83=D0=B6=D0=B8=D1=85=20?= =?UTF-8?q?=D0=B3=D0=BE=D1=80=D0=BE=D0=B4=D0=BE=D0=B2=20(#2604)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Миграция 200 (номер 199 занят параллельным PR #2611, не смержен в main). UPDATE listings SET region_code=NULL WHERE source='avito' и slug города в source_url не входит в наши шесть (ekaterinburg/nizhniy_tagil/ kamensk-uralskiy/pervouralsk/verhnyaya_pyshma/serov). Строки — наследие массового заброса 18 июня до появления гео-фильтра карточек (f0264237, 20 июня), канал закрыт, все 16930 строк is_active=false. NULL вместо настоящего региона: колонку не читает ни одна живая выборка, восстанавливать регион по тексту не будем. Idempotent (region_code IS NOT NULL guard). Только UPDATE, без DDL. --- .../sql/200_region_code_foreign_cities.sql | 100 +++++++++ ...igration_200_region_code_foreign_cities.py | 192 ++++++++++++++++++ 2 files changed, 292 insertions(+) create mode 100644 tradein-mvp/backend/data/sql/200_region_code_foreign_cities.sql create mode 100644 tradein-mvp/backend/tests/test_migration_200_region_code_foreign_cities.py diff --git a/tradein-mvp/backend/data/sql/200_region_code_foreign_cities.sql b/tradein-mvp/backend/data/sql/200_region_code_foreign_cities.sql new file mode 100644 index 00000000..7aba4688 --- /dev/null +++ b/tradein-mvp/backend/data/sql/200_region_code_foreign_cities.sql @@ -0,0 +1,100 @@ +-- 200_region_code_foreign_cities.sql +-- Issue #2604 п.2 — убрать ложную метку региона у объявлений Avito из чужих +-- городов (Новосибирск, Казань, Челябинск, Тюмень и ещё ~1600 слагов). +-- +-- ПРОБЛЕМА: 16930 строк listings (source='avito') несут region_code = 66 +-- (Свердловская обл.), хотя source_url указывает на город ВНЕ наших шести — +-- это неправда. Строки — наследие массового заброса 18 июня (сплошной +-- multi-city SERP-краул до появления гео-фильтра карточек, коммит +-- f0264237, 20 июня), который с тех пор не проставлял target_city_slug на +-- SERP-запрос и не отсеивал карточки чужих городов на этапе сбора. Канал +-- давно закрыт (тот же класс проблемы, что чинили 196/197 для listings.city), +-- новых таких строк не поступает — все 16930 сейчас is_active = false. +-- +-- ПОЧЕМУ NULL, А НЕ НАСТОЯЩИЙ РЕГИОН: вывести реальный регион из текста +-- адреса/URL можно было бы (slug города в source_url), но это требовало бы +-- поддерживать растущий справочник ~1600 чужих региональных кодов ради +-- колонки, которую сегодня не читает НИ ОДНА живая выборка (проверено grep: +-- только исторические миграции 077_*/091_* и один комментарий). Честное +-- «неизвестно» (NULL) дешевле и не создаёт вторую ложь взамен первой. +-- +-- ПОЧЕМУ ТОЛЬКО AVITO: у cian/domklik/yandex region_code=66 определяется не +-- заброс-механизмом чужого города (там его и не было), а параметром region= +-- самого запроса (cian) / отсутствием городской привязки в URL вовсе +-- (domklik/yandex) — то есть в подавляющем большинстве region_code=66 у них +-- ВЕРНЫЙ. Среди них нашлось лишь 27 строк с адресом, похожим на чужой город +-- (текстовый разбор, ненадёжный сигнал) — сознательно НЕ трогаем, отдельная +-- задача при желании её довести. +-- +-- ИСТОЧНИК СЛАГА: первый сегмент пути после хоста — +-- https://www.avito.ru/nizhniy_tagil/kvartiry/... -> 'nizhniy_tagil' +-- извлекается regex `substring(source_url from 'avito\.ru/([^/]+)/')` — +-- тот же идиом, что и в 197 (проверено: 'www.' перед 'avito.ru' в общий +-- матч не проваливается, слаг 'www' ни разу не извлёкся — все 45472 +-- source_url на проде имеют форму 'https://www.avito.ru/...'). Точный +-- сегмент пути, НЕ `LIKE '%slug%'` — среди наших шести слагов нет +-- подстрочных коллизий друг с другом (ekaterinburg, nizhniy_tagil, +-- kamensk-uralskiy, pervouralsk, verhnyaya_pyshma, serov — все взаимно +-- не substring), поэтому точное сравнение через WHERE ... NOT IN (...) над +-- извлечённым сегментом безопасно. +-- +-- Наши шесть слагов — АВИТОВСКОЕ написание (см. CityLocation(...).avito_slug +-- в packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py, +-- CITY_LOCATIONS ~ строки 330-336 + EKB default для 'ekaterinburg'): +-- kamensk-uralskiy — ЧЕРЕЗ ДЕФИС (не 'kamensk_uralskiy', наш внутренний +-- city_slug/CITY_LOCATIONS-ключ — через подчёркивание) +-- verhnyaya_pyshma — БЕЗ 'k' (не 'verkhnyaya_pyshma', наш внутренний ключ) +-- Побайтно сверено с 197_backfill_listings_city_from_url.sql, который решает +-- ту же задачу маппинга avito_slug -> наши города. +-- +-- ЗАМЕРЫ (SELECT, read-only, прод, перед миграцией): +-- Наши шесть городов (НЕ должны попасть под UPDATE): 28542 строк +-- Кандидаты на UPDATE (source='avito', НЕ наши 6, region_code=66): +-- 16930 строк +-- из них is_active = false: 16930 (100%) +-- из них region_code = 66 (единственное текущее значение): 16930 (100%) +-- Avito-строк с region_code уже NULL среди кандидатов: 0 +-- (UPDATE их не задевает по построению — WHERE region_code IS NOT NULL) +-- Avito-строк с нераспознаваемым source_url (слаг не извлёкся): 0 +-- total avito = 45472 = 28542 (наши 6) + 16930 (кандидаты) — сходится. +-- +-- ПРОИЗВОДИТЕЛЬНОСТЬ: триггеры на listings — column-scoped +-- (`listings_price_change_trg` на UPDATE OF price_rub, +-- `listings_set_geom_trg` на UPDATE OF lat, lon) — UPDATE только по +-- region_code их не пробуждает. Но `tsv` (GENERATED ALWAYS ... STORED над +-- description+address) пересчитывается на КАЖДОМ UPDATE независимо от того, +-- какие колонки менялись. EXPLAIN (без ANALYZE, план не исполняется) на +-- проде показывает Bitmap Heap Scan по listings_source_idx (source='avito') +-- — тот же путь доступа, что и в 197. 197 обновила 27706 строк с тем же tsv +-- recalculation за 4.1с; здесь строк меньше (16930, ~61% от 27706) — +-- ожидаемая длительность ~2.5-3с. Никакого DDL, GIST/geom не затронуты. +-- +-- Idempotency: `AND region_code IS NOT NULL` — повторный прогон находит 0 +-- строк (все затронутые строки уже NULL после первого прогона), UPDATE +-- становится no-op. WHERE ограничен ровно source='avito' и slug вне наших +-- шести — наши города и другие источники никогда не попадают в scope. +-- +-- ГРАНИЦЫ: НЕ трогает region_code наших шести городов, НЕ трогает +-- cian/domklik/yandex/n1, НЕ трогает city/is_active/скраперы/ +-- DEFAULT_REGION_CODE. Ничего не удаляет, ничего не деактивирует. Только +-- UPDATE одной колонки одной таблицы. +-- +-- Dependencies: 002_core_tables.sql (listings.region_code — nullable int, +-- без DEFAULT на уровне таблицы). + +BEGIN; + +UPDATE listings +SET region_code = NULL +WHERE source = 'avito' + AND region_code IS NOT NULL + AND substring(source_url from 'avito\.ru/([^/]+)/') NOT IN ( + 'ekaterinburg', + 'nizhniy_tagil', + 'kamensk-uralskiy', + 'pervouralsk', + 'verhnyaya_pyshma', + 'serov' + ); + +COMMIT; diff --git a/tradein-mvp/backend/tests/test_migration_200_region_code_foreign_cities.py b/tradein-mvp/backend/tests/test_migration_200_region_code_foreign_cities.py new file mode 100644 index 00000000..ae35a9eb --- /dev/null +++ b/tradein-mvp/backend/tests/test_migration_200_region_code_foreign_cities.py @@ -0,0 +1,192 @@ +"""Static guards for migration 200 (issue #2604 п.2 — убрать ложный +region_code=66 у объявлений Avito из чужих городов). + +Прод применяет data/sql построчно строго (ON_ERROR_STOP). Полный DB-прогон +требует живой БД; здесь фиксируем структурные инварианты, которые ГАРАНТИРУЮТ +идемпотентность, скоуп (только Avito, только чужие города, не наши шесть) и +НЕдеструктивность к самим listings-строкам по построению. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql" +_MIGRATION_200 = _SQL_DIR / "200_region_code_foreign_cities.sql" + +_OUR_SIX_SLUGS = ( + "ekaterinburg", + "nizhniy_tagil", + "kamensk-uralskiy", + "pervouralsk", + "verhnyaya_pyshma", + "serov", +) + + +def _sql() -> str: + return _MIGRATION_200.read_text(encoding="utf-8") + + +def _executable_sql() -> str: + """SQL без построчных `--`-комментариев — только исполняемый код.""" + lines = [] + for raw in _sql().splitlines(): + code = raw.split("--", 1)[0] + if code.strip(): + lines.append(code) + return "\n".join(lines) + + +def _flat(text: str) -> str: + return re.sub(r"\s+", " ", text).strip().lower() + + +def test_migration_200_exists() -> None: + assert _MIGRATION_200.exists(), f"missing migration: {_MIGRATION_200}" + + +def test_migration_200_is_transactional() -> None: + sql = _sql() + assert "BEGIN;" in sql + assert "COMMIT;" in sql + + +def test_migration_200_only_avito() -> None: + """WHERE ограничен source='avito' — cian/domklik/yandex/n1 не трогаются + (у них region_code=66 в основном верен; 27 подозрительных строк там — + сознательно вне scope этой миграции, ненадёжный сигнал).""" + flat = _flat(_executable_sql()) + assert "where source = 'avito'" in flat + + +def test_migration_200_idempotent_guard_present() -> None: + """`AND region_code IS NOT NULL` — повторный прогон находит 0 строк + (уже NULL после первого прогона), UPDATE становится no-op.""" + flat = _flat(_executable_sql()) + assert "and region_code is not null" in flat + + +def test_migration_200_sets_null_not_a_guessed_region() -> None: + """SET region_code = NULL — честное «неизвестно», не подставной код + другого региона (мы не выводим регион из текста адреса).""" + flat = _flat(_executable_sql()) + assert "set region_code = null" in flat + + +def test_migration_200_excludes_exactly_our_six_cities() -> None: + """WHERE ... NOT IN покрывает ровно наши шесть слагов — не больше (не + расширяем защищённый список произвольно), не меньше (иначе один из наших + городов ложно попадёт под обнуление).""" + flat = _flat(_executable_sql()) + for slug in _OUR_SIX_SLUGS: + assert f"'{slug}'" in flat, f"missing protected avito slug: {slug}" + + +def test_migration_200_kamensk_slug_uses_dash_not_underscore() -> None: + """Avito отдаёт 'kamensk-uralskiy' (дефис) — НЕ наш внутренний city_slug + 'kamensk_uralskiy' (подчёркивание, CITY_LOCATIONS ключ в pipeline.py). + Регресс на подчёркивание означал бы, что реальный Каменск-Уральский + ложно обнуляется этой миграцией.""" + flat = _flat(_executable_sql()) + assert "'kamensk-uralskiy'" in flat + assert "'kamensk_uralskiy'" not in flat + + +def test_migration_200_pyshma_slug_matches_avito_not_internal_key() -> None: + """Avito слаг — 'verhnyaya_pyshma' (без 'k'), а не наш внутренний ключ + 'verkhnyaya_pyshma' (с 'k', CITY_LOCATIONS в pipeline.py).""" + flat = _flat(_executable_sql()) + assert "'verhnyaya_pyshma'" in flat + assert "'verkhnyaya_pyshma'" not in flat + + +def test_migration_200_slugs_match_pipeline_source_of_truth() -> None: + """Шесть защищённых слагов побайтно совпадают с CityLocation(...) + .avito_slug в scraper_kit.orchestration.pipeline (CITY_LOCATIONS + + 'ekaterinburg' EKB-дефолт) — иначе список разойдётся с источником + истины и миграция начнёт либо обнулять свои города, либо пропускать + чужие.""" + pipeline_path = ( + Path(__file__).resolve().parents[2] + / "packages" + / "scraper-kit" + / "src" + / "scraper_kit" + / "orchestration" + / "pipeline.py" + ) + pipeline_src = pipeline_path.read_text(encoding="utf-8") + + sql = _sql() + for slug in _OUR_SIX_SLUGS: + assert slug in sql, f"missing avito slug in migration: {slug}" + # 'ekaterinburg' — EKB-дефолт, в pipeline.py не встречается как + # avito_slug строкой (нет явного CityLocation для ЕКБ, city_slug=None + # -> _avito_slug fallback на city_slug), остальные пять — явные + # CityLocation(...).avito_slug значения в CITY_LOCATIONS. + if slug != "ekaterinburg": + assert slug in pipeline_src, ( + f"avito_slug {slug!r} в миграции 200 не найден в pipeline.py " + "CITY_LOCATIONS — риск расхождения защищённого списка с " + "источником истины" + ) + + +def test_migration_200_no_substring_collision_between_slugs() -> None: + """Ни один из шести слагов не является подстрокой другого — точное + сравнение сегмента пути через NOT IN (...) безопасно, LIKE '%slug%' не + нужен и не используется.""" + for a in _OUR_SIX_SLUGS: + for b in _OUR_SIX_SLUGS: + if a == b: + continue + assert a not in b, f"{a!r} is a substring of {b!r} — collision risk" + + flat = _flat(_executable_sql()) + assert "like '%" not in flat + + +def test_migration_200_extracts_exact_path_segment() -> None: + """Слаг извлекается точным сегментом пути через substring(...) regex + (тот же идиом, что 197), не LIKE-паттерном.""" + flat = _flat(_executable_sql()) + assert "substring(source_url from 'avito" in flat + + +def test_migration_200_no_ddl() -> None: + """Только UPDATE данных — никакого ALTER/CREATE/DROP.""" + flat = _flat(_executable_sql()) + assert "alter table" not in flat + assert "create table" not in flat + assert "drop table" not in flat + assert flat.count("update listings") == 1 + + +def test_migration_200_no_destructive_ddl() -> None: + """Миграция не должна содержать DROP TABLE / TRUNCATE / DELETE — ничего + не удаляется, ничего не деактивируется.""" + flat = _flat(_executable_sql()) + assert "drop table" not in flat + assert "truncate" not in flat + assert "delete from" not in flat + assert "is_active" not in flat + + +def test_migration_200_does_not_touch_other_sources_or_city() -> None: + """Явно вне scope: cian/domklik/yandex/n1 и listings.city не + упоминаются в исполняемом SQL этой миграции.""" + flat = _flat(_executable_sql()) + assert "cian" not in flat + assert "domklik" not in flat + assert "yandex" not in flat + assert " n1 " not in flat + assert "set city" not in flat + + +def test_migration_200_no_psycopg_trap() -> None: + """Никаких :param::type — psycopg v3 требует CAST(... AS type) (не + применимо в чистом .sql без bind params, но проверяем на регресс + copy-paste из Python-кода).""" + assert not re.search(r":\w+::", _sql()) From bed2b7bca9eb62ccca09ddebc454681820a822fa Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 1 Aug 2026 22:12:52 +0300 Subject: [PATCH 053/141] =?UTF-8?q?fix(tradein/proxy):=20pin=20ASocks=20ro?= =?UTF-8?q?tate=5Furl=20host=20=E2=80=94=20=D0=BD=D0=B5=20=D1=81=D0=BB?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D1=82=D0=BE=D0=BA=D0=B5=D0=BD=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D1=87=D1=83=D0=B6=D0=BE=D0=B9=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=BA=D1=81=D0=B8=20(#2600)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scrape_proxies.rotate_url колонка неоднородна: прод несёт и mobileproxy changeip-ссылки (id 3/4/5), и ASocks-ссылки (id 1/9/10/11). Без явной проверки хоста Authorization: Bearer ушёл бы на чужой провайдер — security review PR #2611. Добавлен ALLOWED_ROTATE_HOST-пиннинг (https-only, хост == api.asocks.com) ДО HTTP-вызова; несовпадение — отказ, не безголовый запрос без Authorization (смысл ручной ротации — конкретный провайдер). Заодно: класс исключения (не секрет) в note сетевой ошибки — отличить ConnectError от ReadTimeout; расширено leak-покрытие на текст log/Sentry сообщений (не только reason/note). --- .../backend/app/services/proxy_rotation.py | 71 +++++++++- .../tests/services/test_proxy_rotation.py | 123 +++++++++++++++++- 2 files changed, 187 insertions(+), 7 deletions(-) diff --git a/tradein-mvp/backend/app/services/proxy_rotation.py b/tradein-mvp/backend/app/services/proxy_rotation.py index 9ff922c8..ad711655 100644 --- a/tradein-mvp/backend/app/services/proxy_rotation.py +++ b/tradein-mvp/backend/app/services/proxy_rotation.py @@ -41,6 +41,21 @@ str(exc) — см. комментарий в app.api.v1.admin.rotate_proxy_ip (~ httpx-исключения несут полный request URL/детали, поэтому наружу — только нейтральный reason, полные детали — в лог с exc_info=True. +⛔ Хост-пиннинг (security review PR #2611): scrape_proxies.rotate_url колонка +НЕОДНОРОДНА — часть строк пула (id 3/4/5 на проде) несёт mobileproxy changeip- +ссылки (`https://changeip.mobileproxy.space/?proxy_key=<секрет mobileproxy>`, +см. app.api.v1.admin._provider_rotate_url / avito_proxy_rotate_url), не ASocks. +Без явной проверки хоста наш `Authorization: Bearer ` ушёл бы +на ЧУЖОЙ провайдер (mobileproxy) — плюс сам GET/POST по их changeip, вероятно, +реально ротирует ИХ IP и тратит ИХ суточный лимит, а мы бы записали это как +успех ASocks. rotate_proxy ПЕРЕД любым HTTP-вызовом проверяет +urlparse(rotate_url).hostname == ALLOWED_ROTATE_HOST (https-only) — несовпадение +это ОТКАЗ (ok=False, нейтральный reason), а НЕ попытка безголового запроса без +Authorization: смысл ручной ротации — конкретный провайдер (ASocks), молчаливый +вызов чужой ручки без авторизации — это сюрприз оператору (он думает "ASocks +ротировал", а фактически задел mobileproxy), которого проще не допустить, чем +потом объяснять админу расхождение счётчиков. + psycopg v3 / SQLAlchemy text(): все параметры через CAST(:x AS type), НЕ :x::type. """ @@ -49,6 +64,7 @@ from __future__ import annotations import logging from dataclasses import dataclass from typing import Any +from urllib.parse import urlparse import httpx from sqlalchemy import text @@ -59,6 +75,7 @@ from app.core.config import settings logger = logging.getLogger(__name__) __all__ = [ + "ALLOWED_ROTATE_HOST", "DAILY_ROTATION_LIMIT", "RotationResult", "rotate_proxy", @@ -70,6 +87,21 @@ DAILY_ROTATION_LIMIT = 3 # Таймаут POST refresh-ip. Пункт задачи требует "~30с". _ROTATE_TIMEOUT_S = 30.0 +# Единственный хост, на который разрешено уходить с ASOCKS_API_TOKEN в заголовке +# (см. "⛔ Хост-пиннинг" в docstring модуля). scrape_proxies.rotate_url может +# нести ЧУЖИЕ changeip-ссылки (mobileproxy и т.п.) — сравнение ДО HTTP-вызова. +ALLOWED_ROTATE_HOST = "api.asocks.com" + + +def _is_allowed_rotate_url(url: str) -> bool: + """https-only + hostname точно ALLOWED_ROTATE_HOST (регистронезависимо — + urlparse().hostname уже лоуеркейзит). Не бросает исключений на кривом url.""" + try: + parsed = urlparse(url) + except ValueError: + return False + return parsed.scheme == "https" and parsed.hostname == ALLOWED_ROTATE_HOST + @dataclass class RotationResult: @@ -194,11 +226,14 @@ async def rotate_proxy(db: Session, proxy_id: int) -> RotationResult: Порядок: 1. proxy_id не найден в scrape_proxies → ok=False, reason нейтральный. 2. rotate_url пусто → ok=False, "ротация не поддерживается" (НЕ ошибка). - 3. ASOCKS_API_TOKEN не задан (settings.asocks_api_token) → ok=False, + 3. rotate_url хост != ALLOWED_ROTATE_HOST (https://api.asocks.com) → ok=False + ДО HTTP-вызова — токен не должен уйти на чужой провайдер (mobileproxy + changeip и т.п. в этой же колонке пула, см. "⛔ Хост-пиннинг" в модуле). + 4. ASOCKS_API_TOKEN не задан (settings.asocks_api_token) → ok=False, внятный отказ, ничего не ломается. - 4. Суточный лимит (см. _quota_used_today) исчерпан → ok=False, отказ БЕЗ + 5. Суточный лимит (см. _quota_used_today) исчерпан → ok=False, отказ БЕЗ обращения к API. - 5. POST rotate_url с Authorization: Bearer , timeout ~30с. + 6. POST rotate_url с Authorization: Bearer , timeout ~30с. - Сетевая ошибка (нет ответа) → ok=False, аудит-запись http_status=NULL (НЕ считается в лимите), нейтральный reason, детали в лог exc_info=True. - 401 → громкий отказ (_alert_stale_token) + аудит-запись (НЕ считается @@ -229,6 +264,24 @@ async def rotate_proxy(db: Session, proxy_id: int) -> RotationResult: ok=False, reason="rotation not supported for this proxy (no rotate_url configured)" ) + if not _is_allowed_rotate_url(rotate_url): + # scrape_proxies.rotate_url колонка неоднородна (другие строки пула несут + # mobileproxy changeip-ссылки с ИХ секретом) — отправлять наш + # Authorization: Bearer на непроверенный хост нельзя. + # Логируем ТОЛЬКО hostname (не полный url — на других провайдерах он + # несёт их собственный секрет в query-string, тот же класс утечки, что + # и в rotate_proxy_ip, см. модуль docstring). + logger.warning( + "proxy_rotation: proxy_id=%d rotate_url host=%r is not the allowed ASocks host " + "(%s) — refusing before any HTTP call to avoid leaking the token to it", + proxy_id, + urlparse(rotate_url).hostname, + ALLOWED_ROTATE_HOST, + ) + return RotationResult( + ok=False, reason="rotation not supported for this proxy (unexpected rotate host)" + ) + token = settings.asocks_api_token if not token: logger.warning( @@ -254,15 +307,21 @@ async def rotate_proxy(db: Session, proxy_id: int) -> RotationResult: try: async with httpx.AsyncClient(timeout=_ROTATE_TIMEOUT_S) as client: resp = await client.post(rotate_url, headers={"Authorization": f"Bearer {token}"}) - except Exception: + except Exception as exc: # Ответа не было вообще — не подтверждено, что запрос дошёл до провайдера, # значит квота НЕ тратится. str(exc) НИКОГДА не идёт наружу (может нести - # служебные детали соединения) — только exc_info=True в лог. + # служебные детали соединения) — только exc_info=True в лог. type(exc).__name__ + # секрета не несёт (это имя класса — ConnectError/ReadTimeout/…) и в note + # ПОЛЕЗЕН оператору: отличить "не дозвонились" от "дозвонились, зависли". logger.warning( "proxy_rotation: request failed (no response) proxy_id=%d", proxy_id, exc_info=True ) _record_attempt( - db, proxy_id, success=False, http_status=None, note="request failed (no response)" + db, + proxy_id, + success=False, + http_status=None, + note=f"request failed: {type(exc).__name__}", ) return RotationResult( ok=False, diff --git a/tradein-mvp/backend/tests/services/test_proxy_rotation.py b/tradein-mvp/backend/tests/services/test_proxy_rotation.py index a51adbd4..a5762f9a 100644 --- a/tradein-mvp/backend/tests/services/test_proxy_rotation.py +++ b/tradein-mvp/backend/tests/services/test_proxy_rotation.py @@ -10,8 +10,12 @@ FakeSession эмулирует scrape_proxies (одна строка) + scrape_p - Успешная ротация пишет запись в scrape_proxy_rotations (success=True). - 401 → logger.error (громкий отказ) + sentry_sdk.capture_message (мониторинг), аудит-запись пишется, но НЕ считается против суточного лимита. - - Токен не появляется ни в RotationResult.reason, ни в note аудит-записи — + - Токен не появляется ни в RotationResult.reason, ни в note аудит-записи, ни в + тексте log-сообщений (caplog.getMessage()), ни в тексте, ушедшем в Sentry — ни в одном из сценариев (сеть-ошибка, 401, provider 5xx, success). + - rotate_url на ЧУЖОМ хосте (не ALLOWED_ROTATE_HOST) → отказ ДО HTTP-вызова — + scrape_proxies.rotate_url колонка неоднородна (несёт и mobileproxy changeip- + ссылки), наш ASOCKS_API_TOKEN не должен уйти на них (security review PR #2611). """ from __future__ import annotations @@ -167,6 +171,10 @@ def _no_http_allowed(): _DEFAULT_ROTATE_URL = "https://api.asocks.com/unlimited-proxy/1/refresh-ip" +# (name, rotate_url, response=(status, json_body)|None, exception|None) — ровно один +# из response/exception задан, либо оба None (локальный отказ, HTTP не идёт). +_LogScenario = tuple[str, str | None, tuple[int, dict[str, Any] | None] | None, Exception | None] + def _proxy_row(rotate_url: str | None = _DEFAULT_ROTATE_URL) -> dict[str, Any]: return {"id": 1, "rotate_url": rotate_url} @@ -202,6 +210,64 @@ async def test_no_rotate_url_is_not_an_error(monkeypatch: pytest.MonkeyPatch) -> assert db.rotations == [] # ничего не писалось — попытки не было +# ── host pinning (security review PR #2611) ───────────────────────────────── +# +# scrape_proxies.rotate_url колонка неоднородна: прод сейчас несёт mobileproxy +# changeip-ссылки (id 3/4/5) БОК О БОК с ASocks-ссылками (id 1/9/10/11, миграция +# 199). Без host-пиннинга наш Authorization: Bearer ушёл бы +# на чужой провайдер. + + +async def test_rotate_url_on_foreign_host_refused_before_http_call( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN) + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed()) + + foreign_url = "https://changeip.mobileproxy.space/?proxy_key=mobileproxy-own-secret" + db = FakeSession(_proxy_row(rotate_url=foreign_url)) + result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type] + + assert result.ok is False + assert result.reason is not None + # _no_http_allowed() would have raised AssertionError from within rotate_proxy + # if the code had tried an HTTP call (i.e. sent our token) — reaching this + # line means it refused first. Belt-and-suspenders: no audit row either + # (this is a local rejection, same as no-rotate_url/no-token/limit). + assert db.rotations == [] + assert SECRET_TOKEN not in result.reason + + +async def test_allowed_host_case_insensitive_still_proceeds( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Хост сверяется без учёта регистра (urlparse().hostname лоуеркейзит) — тот + же ALLOWED_ROTATE_HOST в другом регистре ДОЛЖЕН проходить, иначе пиннинг + превратился бы в ложный отказ на легитимном rotate_url.""" + monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN) + fake_client, calls = _fake_async_client(response=(200, {"ip": "1.2.3.4"}), exception=None) + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client) + + db = FakeSession(_proxy_row(rotate_url="https://API.ASOCKS.COM/unlimited-proxy/1/refresh-ip")) + result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type] + + assert result.ok is True + assert len(calls) == 1 + + +async def test_allowed_host_over_plain_http_is_refused(monkeypatch: pytest.MonkeyPatch) -> None: + """http:// (не https://) на тот же хост — отказ (защита от даунгрейда + транспорта, которым Authorization ушёл бы в открытом виде).""" + monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN) + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed()) + + db = FakeSession(_proxy_row(rotate_url="http://api.asocks.com/unlimited-proxy/1/refresh-ip")) + result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type] + + assert result.ok is False + assert db.rotations == [] + + # ── missing token → neutral refusal, no crash ─────────────────────────────── @@ -354,6 +420,9 @@ async def test_token_never_appears_in_reason_on_network_error( # сетевая ошибка не подтверждает, что провайдер обработал попытку → квота не тратится assert db.rotations[0]["http_status"] is None assert proxy_rotation._quota_used_today(db, 1) == 0 # type: ignore[arg-type] + # exception class name (не секрет) в note — оператор отличит "не дозвонились" + # (ConnectError) от "дозвонились, зависли" (ReadTimeout). + assert "ConnectError" in (db.rotations[0]["note"] or "") async def test_token_never_appears_on_provider_error_status( @@ -375,6 +444,58 @@ async def test_token_never_appears_on_provider_error_status( assert proxy_rotation._quota_used_today(db, 1) == 1 # type: ignore[arg-type] +async def test_token_never_appears_in_log_messages_or_sentry_text( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """Расширенное leak-покрытие (security review PR #2611): предыдущие тесты + проверяли только reason/note. Здесь — текст, реально уходящий в logging и в + Sentry (не exc_info-traceback, который по дизайну МОЖЕТ нести детали + исключения — см. модуль docstring; это осознанно разрешённое место). + caplog.records[i].getMessage() возвращает форматированный msg %% args, БЕЗ + exc_text — то есть эта проверка ловит именно "секрет попал в аргумент + logger.*()", а не в traceback. + """ + sentry_texts: list[str] = [] + monkeypatch.setattr( + "sentry_sdk.capture_message", + lambda msg, level=None: sentry_texts.append(msg), + ) + monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN) + + scenarios: list[_LogScenario] = [ + ("success", _DEFAULT_ROTATE_URL, (200, {"ip": "1.1.1.1"}), None), + ("401", _DEFAULT_ROTATE_URL, (401, {"message": "Unauthenticated"}), None), + ("provider_500", _DEFAULT_ROTATE_URL, (500, {"message": "err"}), None), + ( + "network_error", + _DEFAULT_ROTATE_URL, + None, + httpx.ConnectError(f"boom token={SECRET_TOKEN}"), + ), + ("foreign_host", "https://changeip.mobileproxy.space/?proxy_key=x", None, None), + ] + + for name, rotate_url, response, exception in scenarios: + if response is not None or exception is not None: + fake_client, _ = _fake_async_client(response=response, exception=exception) + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client) + else: + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed()) + + db = FakeSession(_proxy_row(rotate_url=rotate_url)) + with caplog.at_level(logging.DEBUG): + caplog.clear() + await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type] + + for record in caplog.records: + assert ( + SECRET_TOKEN not in record.getMessage() + ), f"scenario={name}: token leaked into log message args" + + assert sentry_texts, "expected at least one Sentry capture (401 scenario)" + assert all(SECRET_TOKEN not in text for text in sentry_texts) + + # ── proxy not found ────────────────────────────────────────────────────────── From 6ac4ad9867c01f60df39d0c602c4c06002f4182e Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 1 Aug 2026 23:01:19 +0300 Subject: [PATCH 054/141] =?UTF-8?q?feat(mera):=20=D0=BF=D1=83=D0=B1=D0=BB?= =?UTF-8?q?=D0=B8=D1=87=D0=BD=D1=8B=D0=B9=20=D0=BB=D1=8D=D0=BD=D0=B4=D0=B8?= =?UTF-8?q?=D0=BD=D0=B3=20=C2=AB=D0=9C=D0=95=D0=A0=D0=90=C2=BB=20=E2=80=94?= =?UTF-8?q?=20=D0=BA=D0=BE=D0=BD=D1=82=D0=B5=D0=BD=D1=82=20=D0=B8=20=D0=B2?= =?UTF-8?q?=D1=91=D1=80=D1=81=D1=82=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Только фронт. Caddyfile, DNS и бэкенд не тронуты: периметр и домен meraocenka.ru делаются отдельным PR, чтобы горячий Caddyfile не менялся параллельно с эпиком «единый вход». Заменяет заглушку из feat/mera-b2c-perimeter (48 строк «скоро откроется») на полноценную страницу: первый экран, как это работает, что человек получает, откуда данные, вопросы-ответы, подвал и страница обработки ПДн. ## Починен живой баг, из-за которого лэндинг уводил бы людей на форму входа RouteGuard регистрировал useEffect с router.push('/login') ДО early-return для публичных путей. Хуки выполняются всегда, поэтому на проде аноним на /mera-public получал фоновый GET /api/v1/me → 401 → редирект на логин, и «безусловный bypass» до этого просто не доходил. Guard разделён: RouteGuard теперь только смотрит pathname, а весь закрытый контур (useMe, RBAC, экраны отказа) вынесен в GuardedRoute и подключён через next/dynamic — это точка разрыва графа импортов, а не только логическая развилка. Проверено на живой странице: запросов к API — НОЛЬ. ## Обещания приведены в соответствие с продуктом Ревью по честности нашло 1 critical и 4 high — всё это обещания, которых продукт не выполняет. Убрано или переписано: - «Отчёт в PDF» — ручка owner-scoped, анониму отдаёт 401 by design; - «список объектов, на которых построен расчёт» — план прямо запрещает показывать анониму сырые объявления конкурентов; - шесть городов подавались как равнозначные, хотя сбор вне Екатеринбурга выключен (миграция 179, enabled=false) и данных единицы. Теперь честно: полное покрытие — Екатеринбург, по области данных меньше; - «в расчёте два типа данных» умалчивало третий — чужие оценочные модели, которые реально двигают итоговую цифру (estimator.py, IMV/Yandex blend); - страница ПДн обещала удаление данных, механизма которого нет. Тексты и правовые формулировки вынесены в content.ts одним местом, рядом с ссылками на код, который их подтверждает. Финальная редакция privacy — за юристом, это помечено в файле. ## Форма адреса — честная заглушка, и это вынужденно Живого автокомплита быть не может: _PUBLIC_PATHS «Меры» (rbac.py) открывает анониму только health/docs/login/logout и анонимный чат. /geocode/suggest и /trade-in/estimate отдают анониму 401. Открывать их до анти-абуза (этап 2 плана B2C) прямо запрещено планом. Форма честно говорит, что произойдёт, и не изображает работу, которой нет. Переключается флагом PUBLIC_ESTIMATE_ENABLED, рядом с ним — гейт из трёх условий. ## Проверено живьём, не по отчёту - 360px и 390px: горизонтального переполнения нет (единственный элемент за экраном — skip-link, так и задумано); - один h1, иерархия H1→H2→H3 без пропусков, landmark-разметка; - внешних ресурсов ноль — ни CDN, ни шрифтов, ни картинок с чужих доменов; - ноль запросов к /api/** со страницы; - tsc --noEmit чист. NB для ревьюера: .claude/rules/ui-*.md по frontmatter paths: матчат frontend/**, то есть корневой фронт Site Finder, а не tradein-mvp/frontend. Здесь применяется дизайн-система v2/tokens.ts. Tailwind в этом фронте нет. --- .../mera-public/_components/AddressForm.tsx | 232 +++++ .../mera-public/_components/DataSources.tsx | 55 ++ .../src/app/mera-public/_components/Faq.tsx | 65 ++ .../src/app/mera-public/_components/Hero.tsx | 87 ++ .../mera-public/_components/HowItWorks.tsx | 46 + .../mera-public/_components/SiteFooter.tsx | 97 ++ .../mera-public/_components/SiteHeader.tsx | 26 + .../mera-public/_components/WhatYouGet.tsx | 93 ++ .../frontend/src/app/mera-public/content.ts | 295 ++++++ .../src/app/mera-public/landing.module.css | 896 ++++++++++++++++++ .../frontend/src/app/mera-public/layout.tsx | 78 ++ .../frontend/src/app/mera-public/page.tsx | 30 + .../src/app/mera-public/privacy/page.tsx | 161 ++++ .../frontend/src/app/mera-public/theme.ts | 65 ++ .../src/components/auth/GuardedRoute.tsx | 142 +++ .../src/components/auth/RouteGuard.tsx | 153 ++- 16 files changed, 2424 insertions(+), 97 deletions(-) create mode 100644 tradein-mvp/frontend/src/app/mera-public/_components/AddressForm.tsx create mode 100644 tradein-mvp/frontend/src/app/mera-public/_components/DataSources.tsx create mode 100644 tradein-mvp/frontend/src/app/mera-public/_components/Faq.tsx create mode 100644 tradein-mvp/frontend/src/app/mera-public/_components/Hero.tsx create mode 100644 tradein-mvp/frontend/src/app/mera-public/_components/HowItWorks.tsx create mode 100644 tradein-mvp/frontend/src/app/mera-public/_components/SiteFooter.tsx create mode 100644 tradein-mvp/frontend/src/app/mera-public/_components/SiteHeader.tsx create mode 100644 tradein-mvp/frontend/src/app/mera-public/_components/WhatYouGet.tsx create mode 100644 tradein-mvp/frontend/src/app/mera-public/content.ts create mode 100644 tradein-mvp/frontend/src/app/mera-public/landing.module.css create mode 100644 tradein-mvp/frontend/src/app/mera-public/layout.tsx create mode 100644 tradein-mvp/frontend/src/app/mera-public/page.tsx create mode 100644 tradein-mvp/frontend/src/app/mera-public/privacy/page.tsx create mode 100644 tradein-mvp/frontend/src/app/mera-public/theme.ts create mode 100644 tradein-mvp/frontend/src/components/auth/GuardedRoute.tsx diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/AddressForm.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/AddressForm.tsx new file mode 100644 index 00000000..d1eb1e7f --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/_components/AddressForm.tsx @@ -0,0 +1,232 @@ +"use client"; + +/** + * AddressForm — поле адреса на первом экране. + * + * ЧТО ЭТА ФОРМА ДЕЛАЕТ СЕГОДНЯ И ПОЧЕМУ ИМЕННО ТАК + * + * Она не считает цену и не притворяется, что считает. Причина техническая и + * жёсткая: `rbac_guard` (backend/app/core/rbac.py) пропускает анонима только на + * пути из `_PUBLIC_PATHS`, а `/api/v1/geocode/suggest` и + * `/api/v1/trade-in/estimate` туда не входят — любой запрос отсюда вернул бы + * 401. Открытие анонимного периметра — отдельный backend-PR, вне границ этой + * задачи. Поэтому здесь честная валидация на клиенте + прямой ответ «публичный + * расчёт ещё не открыт» вместо фейкового спиннера. + * + * Что форма всё-таки делает по-настоящему: + * - проверяет, что адрес введён; + * - требует явно назвать город и не подставляет Екатеринбург молча. Это ровно + * тот баг, который чинил бэкенд в #2576: житель Нижнего Тагила вводил + * «Ленина, 1» и получал уверенную цену по одноимённой улице в ЕКБ. Правило + * из шапки `lib/city-registry.ts` — город считается известным только если + * пользователь его выбрал ИЛИ `detectCityInText` нашёл его в тексте; + * - если названного города нет в покрытии — мягко и честно говорит про + * Свердловскую область, не обещая «оценим любую квартиру в РФ». + * + * Осознанно НЕ переиспользован автокомплит из закрытого контура + * (ParamsPanel.tsx / AddressInput.tsx): он ходит в `/geocode/suggest` через + * `useGeocodeSuggest`, что для анонима = 401. Тянуть сюда хуки B2B-контура + * (useMe/useQuota/useHistory и соседей) запрещено — публичный экран не должен + * иметь к ним доступа даже теоретически. + * + * Когда бэкенд откроет анонимные ручки: переключить `PUBLIC_ESTIMATE_ENABLED` + * в content.ts и заменить ветку `notLaunched` в `handleSubmit` на реальный + * переход/запрос (комбобокс подсказок — по образцу ParamsPanel.tsx, вместе с + * его клавиатурной моделью и sr-live-регионом). + */ + +import { useId, useRef, useState } from "react"; +import type { FormEvent } from "react"; + +import { detectCityInText } from "@/lib/city-registry"; + +import { + COVERED_CITIES, + PRIMARY_CITY, + PUBLIC_ESTIMATE_ENABLED, + REGION_NAME, + SECONDARY_CITIES, +} from "../content"; +import styles from "../landing.module.css"; + +/** Значение