From feff55bea9eec7fc1ff536e381123f0e11f94d17 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 31 Jul 2026 23:16:19 +0300 Subject: [PATCH 01/23] =?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 02/23] =?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 03/23] =?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 04/23] =?UTF-8?q?feat(auth):=20=D1=80=D0=BE=D0=BB=D0=B8=20?= =?UTF-8?q?=D0=B8=20=D1=82=D1=80=D1=91=D1=85=D0=B7=D0=BD=D0=B0=D1=87=D0=BD?= =?UTF-8?q?=D0=BE=D0=B5=20=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=B4=D0=BE=D1=81=D1=82=D1=83=D0=BF=D0=B0=20=D0=B2=20?= =?UTF-8?q?=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 05/23] =?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 06/23] =?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 07/23] =?UTF-8?q?feat(tradein):=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=BA=D0=BB=D1=8E=D1=87=D0=B0=D0=B5=D0=BC=D1=8B=D0=B9=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B5=D1=81=D1=82=D1=80=20=D0=BB=D1=8E=D0=B4=D0=B5=D0=B9?= =?UTF-8?q?=20=E2=80=94=20=D0=BF=D0=BE=D0=B4=D0=B3=D0=BE=D1=82=D0=BE=D0=B2?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B5=D0=B7=D0=B4=D0=B0?= =?UTF-8?q?=20=C2=AB=D0=9C=D0=B5=D1=80=D1=8B=C2=BB=20=D0=B2=20=D0=91=D0=94?= =?UTF-8?q?=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 08/23] =?UTF-8?q?fix(tradein/proxy):=20=D1=81=D0=B0=D0=BC?= =?UTF-8?q?=D0=BE=D0=B2=D0=BE=D1=81=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=83=D0=BB=D0=B0=20?= =?UTF-8?q?=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 09/23] =?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 10/23] =?UTF-8?q?feat(tradein/proxy):=20=D1=80=D0=BE=D1=82?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20exit-IP=20ASocks=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D0=B1=D0=B0=D0=BD=D1=83=20=D1=81=D0=BE=20=D1=81=D1=87=D1=91?= =?UTF-8?q?=D1=82=D1=87=D0=B8=D0=BA=D0=BE=D0=BC=20=D0=B8=20=D0=B3=D1=80?= =?UTF-8?q?=D0=BE=D0=BC=D0=BA=D0=B8=D0=BC=20=D0=BE=D1=82=D0=BA=D0=B0=D0=B7?= =?UTF-8?q?=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 11/23] =?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 12/23] =?UTF-8?q?fix(tradein/proxy):=20pin=20ASocks=20rota?= =?UTF-8?q?te=5Furl=20host=20=E2=80=94=20=D0=BD=D0=B5=20=D1=81=D0=BB=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20=D1=82=D0=BE=D0=BA=D0=B5=D0=BD=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D1=87=D1=83=D0=B6=D0=BE=D0=B9=20=D0=BF=D1=80=D0=BE=D0=BA=D1=81?= =?UTF-8?q?=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 13/23] =?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"; + +/** Значение