diff --git a/tradein-mvp/backend/data/sql/201_purge_dead_mobileproxy_proxies.sql b/tradein-mvp/backend/data/sql/201_purge_dead_mobileproxy_proxies.sql new file mode 100644 index 00000000..8c9e275b --- /dev/null +++ b/tradein-mvp/backend/data/sql/201_purge_dead_mobileproxy_proxies.sql @@ -0,0 +1,83 @@ +-- 201_purge_dead_mobileproxy_proxies.sql +-- Issue #2613 — выпилить мёртвые узлы mobileproxy из пула scrape_proxies +-- вместе с чужим API-ключом, который лежал у них в rotate_url. +-- +-- WHY: +-- Владелец подтвердил: подписка mobileproxy закрыта, продлевать не будут. +-- Прямая проба каждого узла из контейнера tradein-scraper (2026-08-01) +-- подтверждает смерть: id 2 — connection refused, id 3/4/5 — 407 Proxy +-- Authentication Required. Последняя успешная проверка (last_check_at) у +-- всех четырёх — 4-9 июля, все четыре enabled=false, consecutive_fails=5. +-- +-- Две причины удалить, вторая важнее: +-- 1. Мёртвые узлы засоряют пул и его health-метрики. +-- 2. rotate_url у трёх из четырёх строк (id 3, 4, 5) хранит открытым +-- текстом чужой ключ провайдера в query-параметре ссылки ротации +-- (https://changeip.mobileproxy.space/?proxy_key=...). Именно из-за +-- неоднородности этой колонки (вперемешку с ASocks-строками, где +-- rotate_url — наш собственный API-эндпоинт БЕЗ секрета в URL, +-- авторизация Bearer-заголовком) глубокое ревью PR #2611 нашло +-- блокер: вызов ротации для такой строки отправил бы НАШ токен +-- ASocks на changeip.mobileproxy.space. Пин хоста в #2611 уже +-- закрывает саму уязвимость, но чужой секрет в базе держать незачем. +-- +-- ПОЧЕМУ DELETE, А НЕ UPDATE (очистка полей + enabled=false): +-- Единственный FK, ссылающийся на scrape_proxies — scrape_proxy_rotations +-- .proxy_id (заведён 198_scrape_proxy_rotations.sql), delete_rule NO ACTION. +-- На момент миграции (замер ниже) в scrape_proxy_rotations нет НИ ОДНОЙ +-- строки вообще — таблица введена в этом же цикле работ (#2600 п.5) и +-- ручная ротация ни разу не запускалась. DELETE четырёх строк scrape_proxies +-- ничего не упирает. Если бы к строкам 2-5 успела прилипнуть история ротаций +-- к моменту применения — DELETE упадёт по FK-violation ВНУТРИ этой же +-- транзакции (BEGIN/COMMIT ниже), миграция целиком откатится, deploy +-- завершится ошибкой (auto-apply strict, exit 1) без частичного эффекта и +-- без порчи данных; отдельного ON DELETE-обработчика не требуется — узлы +-- disabled=false уже сейчас, acquire() их не выдаёт (idx_scrape_proxies_pick +-- фильтрует по enabled), новых ротаций на них взяться неоткуда до deploy. +-- Строки — исторический мусор без ссылок, полное удаление честнее частичной +-- очистки (не оставляет призрачную запись мёртвого узла в пуле) и убирает +-- секрет из базы целиком, а не только из одной колонки. +-- +-- Matching (по домену url, НЕ по id): +-- id в scrape_proxies разъезжается между средами (bulk-загрузка независима +-- per-среда, тот же класс проблемы решён в 199 через host:port-matching). +-- Условие — WHERE url LIKE '%mobileproxy.space%' — ловит все четыре узла +-- независимо от порта/поддомена (ha./gi./auv./aup.mobileproxy.space) и не +-- заденет ASocks-строки (212.8.249.134 / 190.2.145.131 / 175.110.115.153 / +-- 109.236.82.42 — IP-адреса, без mobileproxy.space в url вовсе). +-- +-- ЗАМЕРЫ (SELECT, read-only, прод, перед миграцией, 2026-08-01): +-- Строк под условие (url LIKE '%mobileproxy.space%'): 4 (id 2, 3, 4, 5) +-- Остаток пула после удаления (url NOT LIKE '%mobileproxy.space%'): +-- 4 (id 1, 9, 10, 11) — все ASocks +-- Строк в scrape_proxy_rotations на id 2/3/4/5: 0 +-- Строк в scrape_proxy_rotations всего (таблица пуста): 0 +-- Секрет-паттерн (token|bearer|secret|key=|password, regex +-- case-insensitive) в rotate_url ОСТАЮЩИХСЯ 4 строк: 0 совпадений +-- (rotate_url остающихся — https://api.asocks.com/unlimited-proxy/ +-- /refresh-ip, без query-параметров вообще, авторизация Bearer +-- заголовком вне URL, см. 199_scrape_proxies_asocks_rotate_url.sql) +-- FK, ссылающиеся на scrape_proxies: ровно один — +-- scrape_proxy_rotations.proxy_id -> scrape_proxies.id, delete_rule NO ACTION. +-- +-- Idempotency: +-- Обычный DELETE ... WHERE — повторный прогон находит 0 строк (уже +-- удалены), no-op. Весь файл в BEGIN/COMMIT. +-- +-- ГРАНИЦЫ: НЕ трогает ASocks-строки (id 1, 9, 10, 11) и их rotate_url. НЕ +-- трогает переменные окружения (*_PROXY_URL, BROWSER_PROXY_*, +-- *_PROXY_ROTATE_URL) — их снятие отдельная задача и НЕ раньше неё, иначе +-- при пустом прокси curl_proxy_url отдаёт None = скрапер идёт напрямую с IP +-- сервера. НЕ трогает app/services/proxy_pool.py, proxy_rotation.py, +-- скраперы. Никакого DDL. +-- +-- Dependencies: +-- 157_scrape_proxies.sql (scrape_proxies.url/rotate_url/enabled). +-- 198_scrape_proxy_rotations.sql (FK proxy_id -> scrape_proxies.id, NO ACTION). + +BEGIN; + +DELETE FROM scrape_proxies +WHERE url LIKE '%mobileproxy.space%'; + +COMMIT; diff --git a/tradein-mvp/backend/tests/test_migration_201_purge_dead_mobileproxy_proxies.py b/tradein-mvp/backend/tests/test_migration_201_purge_dead_mobileproxy_proxies.py new file mode 100644 index 00000000..72500fe3 --- /dev/null +++ b/tradein-mvp/backend/tests/test_migration_201_purge_dead_mobileproxy_proxies.py @@ -0,0 +1,106 @@ +"""Static guards for migration 201 (issue #2613 — выпилить мёртвые узлы +mobileproxy из scrape_proxies вместе с чужим API-ключом в rotate_url). + +Прод применяет data/sql построчно строго (ON_ERROR_STOP). Полный DB-прогон +требует живой БД; здесь фиксируем структурные инварианты, которые ГАРАНТИРУЮТ +идемпотентность, domain-based scope (НЕ по id — они разъезжаются между +средами) и то, что ASocks-строки миграция не задевает. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql" +_MIGRATION_201 = _SQL_DIR / "201_purge_dead_mobileproxy_proxies.sql" + + +def _sql() -> str: + return _MIGRATION_201.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_201_exists() -> None: + assert _MIGRATION_201.exists(), f"missing migration: {_MIGRATION_201}" + + +def test_migration_201_is_transactional() -> None: + sql = _sql() + assert "BEGIN;" in sql + assert "COMMIT;" in sql + + +def test_migration_201_deletes_by_domain_not_id() -> None: + """Условие удаления — по домену mobileproxy.space в url, НЕ по id (id + разъезжается между средами, тот же класс проблемы решён в 199 через + host:port-matching).""" + flat = _flat(_executable_sql()) + assert "delete from scrape_proxies" in flat + assert "where url like '%mobileproxy.space%'" in flat + assert ( + re.search(r"where\s+id\s*(=|in)", flat) is None + ), "миграция не должна фильтровать по id — id разъезжается между средами" + + +def test_migration_201_is_idempotent_by_construction() -> None: + """DELETE ... WHERE без вспомогательного флага — повторный прогон + находит 0 строк (уже удалены в первом прогоне), сам DELETE идемпотентен + по построению, отдельного guard-условия не требуется.""" + flat = _flat(_executable_sql()) + assert flat.count("delete from") == 1 + assert "delete from scrape_proxies" in flat + + +def test_migration_201_does_not_touch_asocks_rows() -> None: + """ASocks-строки (id 1, 9, 10, 11) адресуются IP-хостами + (212.8.249.134 / 190.2.145.131 / 175.110.115.153 / 109.236.82.42) без + mobileproxy.space в url — WHERE их не задевает. Явно запрещаем regression + в сторону id-based или asocks-упоминающего условия.""" + flat = _flat(_executable_sql()) + assert "asocks" not in flat + for asocks_ip in ( + "212.8.249.134", + "190.2.145.131", + "175.110.115.153", + "109.236.82.42", + ): + assert asocks_ip not in flat + + +def test_migration_201_no_ddl() -> None: + """Только DELETE данных — никакого ALTER/CREATE/DROP TABLE/TRUNCATE.""" + flat = _flat(_executable_sql()) + assert "alter table" not in flat + assert "create table" not in flat + assert "drop table" not in flat + assert "truncate" not in flat + + +def test_migration_201_no_secret_literal_in_file() -> None: + """Файл миграции не должен содержать сам секрет (query-параметр + proxy_key mobileproxy.space) — только описание проблемы текстом.""" + sql = _sql() + assert "proxy_key=" not in sql or "proxy_key=..." in sql or "<секрет>" in sql + # Явный запрет на длинные alnum-токены рядом с 'proxy_key=' (сам секрет). + assert not re.search(r"proxy_key=[A-Za-z0-9_-]{10,}", sql) + + +def test_migration_201_no_psycopg_trap() -> None: + """Никаких :param::type — psycopg v3 требует CAST(... AS type) (не + применимо в чистом .sql без bind params, но проверяем на регресс + copy-paste из Python-кода).""" + assert not re.search(r":\w+::", _sql())