gendesign/tradein-mvp/backend/tests/test_admin_proxies.py
bot-backend 00bc07a55f
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 7s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m45s
fix(tradein/proxy): backup-узел должен быть пригоден + рычаг снятия бана (#2600 п.2)
Правки по deep-review PR #2654.

MEDIUM. Внутренний EXISTS считал backup'ом любой enabled-узел affinity. До п.2 это
было эквивалентно «пригоден», потому что бан выключал узел глобально; теперь узел
бывает enabled и одновременно забанен СВОИМ же источником. Fallback мог увести
последний реально рабочий узел выделенной affinity (два domclick-узла, один забанен
domclick'ом → второй уходит под avito → domclick без прокси). Добавлено требование,
что backup не забанен своим источником — в acquire и зеркально в защите mark_banned.

MEDIUM. У оператора не осталось способа снять бан: в п.1 ложное срабатывание
лечилось PATCH enabled=true (он обнулял disabled_reason), теперь бан живёт в
отдельной таблице и истекает только по таймеру, до 72ч при эскалации. Добавлен
proxy_pool.clear_source_bans; зовётся из patch_proxy при ручном включении и после
УСПЕШНОЙ ротации exit-IP (бан привязан к proxy_id, а банился IP — после смены
адреса строка держала бы узел вне выдачи без причины).

LOW. Тест защиты дублировал логику вместо её проверки: ban-предикаты в фейксессии
теперь гейтятся по подстрокам боевого SQL (как в acquire-ветке) — проверено
мутацией, тесты краснеют при удалении NOT EXISTS из запроса.

LOW. Конверсия в миграции 210 матчила disabled_reason по LIKE 'banned:%' и могла
отменить ручное выключение оператора (формат подсказан комментарием 209-й) — сужено
до точного списка значений домена provider_affinity.

LOW. Docstring report_ban в browser_fetcher описывал старую модель (enabled=false);
формула в COMMENT ON COLUMN была на шаг мимо (срок ТЕКУЩЕГО бана, не следующего).
Расхождение с acquire по leased_by зафиксировано в докстринге как осознанное.

Refs #2600
2026-08-05 17:45:07 +05:00

376 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Offline-тесты admin proxy-pool ручек (#2161, #2610).
Покрытие (db мокается, NO live network/DB):
- POST /api/v1/admin/proxies/bulk — UPSERT-счётчики, валидация affinity/kind
- GET /api/v1/admin/proxies — маскировка пароля, фильтры, disabled_reason в ответе,
активные баны по источникам (#2600 п.2 — узел бывает enabled=true и при этом не
выдаётся конкретному источнику)
- PATCH /api/v1/admin/proxies/{id} — enable/disable, 404
- #2610: PATCH enabled=false ставит disabled_reason (ручное выключение отличимо от
авто); PATCH enabled=true сбрасывает disabled_reason в NULL (снова авто-восстанавливаем)
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from datetime import UTC, datetime
from typing import Any
from unittest.mock import MagicMock
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
@pytest.fixture
def db() -> MagicMock:
return MagicMock()
@pytest.fixture
def client(db: MagicMock) -> TestClient:
from app.api.v1 import admin as admin_module
from app.core.db import get_db
app = FastAPI()
app.include_router(admin_module.router, prefix="/api/v1/admin")
def fake_db() -> Any:
yield db
app.dependency_overrides[get_db] = fake_db
return TestClient(app)
def _scalar_result(value: object) -> MagicMock:
res = MagicMock()
res.scalar.return_value = value
return res
def _cleared_bans_result(rows: list[dict[str, Any]] | None = None) -> MagicMock:
"""Ответ на DELETE ... RETURNING source (proxy_pool.clear_source_bans, #2600 п.2).
PATCH enabled=true снимает баны узла по источникам — «ручное включение = чистый
лист», как и обнуление disabled_reason рядом.
"""
res = MagicMock()
res.fetchall.return_value = [type("Row", (), r)() for r in (rows or [])]
return res
def _bans_result(rows: list[dict[str, Any]] | None = None) -> MagicMock:
"""Ответ на ВТОРОЙ execute в /proxies-ручках — активные баны по источникам (#2600 п.2).
list_proxies/patch_proxy после основного запроса дочитывают scrape_proxy_source_bans
(_fetch_source_bans), поэтому мок обязан отдавать два разных результата по порядку.
"""
res = MagicMock()
res.mappings.return_value.all.return_value = rows or []
return res
# ── _mask_proxy_url unit ─────────────────────────────────────────────────────
def test_mask_proxy_url_hides_password() -> None:
from app.api.v1.admin import _mask_proxy_url
assert _mask_proxy_url("socks5://user:secret@host:1080") == "socks5://user:***@host:1080"
assert _mask_proxy_url("http://u:p@1.2.3.4:8080") == "http://u:***@1.2.3.4:8080"
def test_mask_proxy_url_no_password_untouched() -> None:
from app.api.v1.admin import _mask_proxy_url
assert _mask_proxy_url("http://host:8080") == "http://host:8080"
assert _mask_proxy_url(None) is None
assert _mask_proxy_url("") == ""
# ── POST /proxies/bulk ───────────────────────────────────────────────────────
def test_bulk_upsert_counts_inserted_and_updated(client: TestClient, db: MagicMock) -> None:
# первая строка вставлена (xmax=0 → True), вторая обновлена (False)
db.execute.side_effect = [_scalar_result(True), _scalar_result(False)]
r = client.post(
"/api/v1/admin/proxies/bulk",
json={
"proxies": [
{"url": "http://u:p@host1:8080", "provider_affinity": "avito"},
{"url": "http://u:p@host2:8080", "provider_affinity": "cian", "kind": "socks5"},
]
},
)
assert r.status_code == 200, r.text
assert r.json() == {"inserted": 1, "updated": 1}
assert db.commit.called
def test_bulk_invalid_affinity_422(client: TestClient) -> None:
r = client.post(
"/api/v1/admin/proxies/bulk",
json={"proxies": [{"url": "http://host:8080", "provider_affinity": "garbage"}]},
)
assert r.status_code == 422
def test_bulk_invalid_kind_422(client: TestClient) -> None:
r = client.post(
"/api/v1/admin/proxies/bulk",
json={"proxies": [{"url": "http://host:8080", "kind": "ftp"}]},
)
assert r.status_code == 422
def test_bulk_empty_list_422(client: TestClient) -> None:
r = client.post("/api/v1/admin/proxies/bulk", json={"proxies": []})
assert r.status_code == 422
def test_bulk_defaults_affinity_any(client: TestClient, db: MagicMock) -> None:
db.execute.side_effect = [_scalar_result(True)]
r = client.post(
"/api/v1/admin/proxies/bulk",
json={"proxies": [{"url": "http://host:8080"}]},
)
assert r.status_code == 200
# affinity по умолчанию 'any' попал в параметры INSERT
_, kwargs = db.execute.call_args
assert kwargs is not None
params = db.execute.call_args.args[1]
assert params["aff"] == "any"
assert params["kind"] == "http"
# ── GET /proxies ─────────────────────────────────────────────────────────────
def _proxy_db_row(**over: Any) -> dict[str, Any]:
base: dict[str, Any] = {
"id": 1,
"label": "test",
"url": "socks5://user:secret@host:1080",
"kind": "socks5",
"provider_affinity": "avito",
"rotate_url": "http://user:secret@rot:9000/changeip",
"enabled": True,
"disabled_reason": None,
"consecutive_fails": 0,
"exit_ip": "1.2.3.4",
"latency_ms": 120,
"last_check_at": datetime(2026, 7, 1, tzinfo=UTC),
"last_ok_at": datetime(2026, 7, 1, tzinfo=UTC),
"leased_by": None,
"leased_at": None,
"geo": "RU",
"operator": "beeline",
"expires_at": None,
"created_at": datetime(2026, 6, 1, tzinfo=UTC),
"updated_at": datetime(2026, 7, 1, tzinfo=UTC),
}
base.update(over)
return base
def test_list_masks_password(client: TestClient, db: MagicMock) -> None:
result = MagicMock()
result.mappings.return_value.all.return_value = [_proxy_db_row()]
db.execute.side_effect = [result, _bans_result()]
r = client.get("/api/v1/admin/proxies")
assert r.status_code == 200, r.text
rows = r.json()
assert len(rows) == 1
assert rows[0]["url"] == "socks5://user:***@host:1080"
assert rows[0]["rotate_url"] == "http://user:***@rot:9000/changeip"
assert "secret" not in r.text
def test_list_exposes_disabled_reason(client: TestClient, db: MagicMock) -> None:
"""(#2610) Оператор должен видеть причину ручного выключения в листинге."""
result = MagicMock()
result.mappings.return_value.all.return_value = [
_proxy_db_row(id=1, enabled=False, disabled_reason=None),
_proxy_db_row(id=2, enabled=False, disabled_reason="забанен Авито"),
]
db.execute.side_effect = [result, _bans_result()]
r = client.get("/api/v1/admin/proxies")
assert r.status_code == 200, r.text
rows = {row["id"]: row for row in r.json()}
assert rows[1]["disabled_reason"] is None # авто-выключен
assert rows[2]["disabled_reason"] == "забанен Авито" # выключен вручную
def test_list_exposes_active_source_bans(client: TestClient, db: MagicMock) -> None:
"""(#2600 п.2) Узел enabled=true, но забанен Авито — оператор должен видеть, почему
он не выдаётся конкретному источнику; для остальных источников узел в строю."""
result = MagicMock()
result.mappings.return_value.all.return_value = [
_proxy_db_row(id=1, enabled=True),
_proxy_db_row(id=2, enabled=True),
]
db.execute.side_effect = [
result,
_bans_result(
[
{
"proxy_id": 1,
"source": "avito",
"banned_until": datetime(2026, 8, 5, 12, tzinfo=UTC),
"ban_count": 2,
}
]
),
]
r = client.get("/api/v1/admin/proxies")
assert r.status_code == 200, r.text
rows = {row["id"]: row for row in r.json()}
assert rows[1]["source_bans"] == [
{"source": "avito", "banned_until": "2026-08-05T12:00:00+00:00", "ban_count": 2}
]
assert rows[2]["source_bans"] == [] # чистый узел — пустой список, а не отсутствие поля
def test_list_passes_filters(client: TestClient, db: MagicMock) -> None:
result = MagicMock()
result.mappings.return_value.all.return_value = []
db.execute.return_value = result
r = client.get("/api/v1/admin/proxies", params={"provider": "cian", "enabled": "false"})
assert r.status_code == 200
params = db.execute.call_args.args[1]
assert params["provider"] == "cian"
assert params["enabled"] is False
# ── PATCH /proxies/{id} ──────────────────────────────────────────────────────
def test_patch_disable(client: TestClient, db: MagicMock) -> None:
result = MagicMock()
result.mappings.return_value.fetchone.return_value = _proxy_db_row(enabled=False)
db.execute.side_effect = [result, _bans_result()]
r = client.patch("/api/v1/admin/proxies/1", json={"enabled": False})
assert r.status_code == 200, r.text
assert r.json()["enabled"] is False
assert db.commit.called
def test_patch_not_found_404(client: TestClient, db: MagicMock) -> None:
result = MagicMock()
result.mappings.return_value.fetchone.return_value = None
db.execute.return_value = result
r = client.patch("/api/v1/admin/proxies/999", json={"enabled": True})
assert r.status_code == 404
# ── PATCH /proxies/{id} — disabled_reason (#2610) ───────────────────────────
def test_patch_disable_sets_disabled_reason_default(client: TestClient, db: MagicMock) -> None:
"""(#2610) Ручное выключение без явной причины — ставится дефолтный текст."""
result = MagicMock()
result.mappings.return_value.fetchone.return_value = _proxy_db_row(
enabled=False, disabled_reason="manually disabled via admin API"
)
db.execute.side_effect = [result, _bans_result()]
r = client.patch("/api/v1/admin/proxies/1", json={"enabled": False})
assert r.status_code == 200, r.text
assert r.json()["disabled_reason"] == "manually disabled via admin API"
# дефолтная причина реально передана в SQL как fallback-параметр (первый execute —
# сам UPDATE; второй, #2600 п.2, дочитывает активные баны по источникам)
params = db.execute.call_args_list[0].args[1]
assert params["default_reason"]
assert params["reason"] is None
def test_patch_disable_sets_disabled_reason_custom(client: TestClient, db: MagicMock) -> None:
"""(#2610) Оператор передаёт свою причину — она уходит в SQL-параметры."""
result = MagicMock()
result.mappings.return_value.fetchone.return_value = _proxy_db_row(
enabled=False, disabled_reason="забанен Авито"
)
db.execute.side_effect = [result, _bans_result()]
r = client.patch("/api/v1/admin/proxies/1", json={"enabled": False, "reason": "забанен Авито"})
assert r.status_code == 200, r.text
assert r.json()["disabled_reason"] == "забанен Авито"
params = db.execute.call_args_list[0].args[1]
assert params["reason"] == "забанен Авито"
def test_patch_enable_clears_disabled_reason(client: TestClient, db: MagicMock) -> None:
"""(#2610, red/green c) Ручное включение сбрасывает disabled_reason → узел снова
авто-восстанавливаем через mark_health."""
result = MagicMock()
result.mappings.return_value.fetchone.return_value = _proxy_db_row(
enabled=True, disabled_reason=None
)
db.execute.side_effect = [result, _cleared_bans_result(), _bans_result()]
r = client.patch("/api/v1/admin/proxies/1", json={"enabled": True})
assert r.status_code == 200, r.text
assert r.json()["disabled_reason"] is None
params = db.execute.call_args_list[0].args[1]
assert params["enabled"] is True
def test_patch_enable_clears_source_bans(client: TestClient, db: MagicMock) -> None:
"""(#2600 п.2) Ручное включение = чистый лист: снимаются и per-source баны, иначе у
оператора нет способа отменить ложный бан (детектор капчи, #2642) — узел был бы
enabled=true и всё равно невыдаваемым источнику до 72 часов."""
result = MagicMock()
result.mappings.return_value.fetchone.return_value = _proxy_db_row(
enabled=True, disabled_reason=None
)
db.execute.side_effect = [result, _cleared_bans_result([{"source": "avito"}]), _bans_result()]
r = client.patch("/api/v1/admin/proxies/1", json={"enabled": True})
assert r.status_code == 200, r.text
delete_sql = str(db.execute.call_args_list[1].args[0])
assert "DELETE FROM scrape_proxy_source_bans" in delete_sql
assert db.execute.call_args_list[1].args[1]["proxy_id"] == 1
def test_patch_disable_keeps_source_bans(client: TestClient, db: MagicMock) -> None:
"""Выключение узла бан-строки НЕ снимает — снятие это «оператор говорит, что узел
в порядке», а выключение утверждает обратное."""
result = MagicMock()
result.mappings.return_value.fetchone.return_value = _proxy_db_row(enabled=False)
db.execute.side_effect = [result, _bans_result()]
r = client.patch("/api/v1/admin/proxies/1", json={"enabled": False})
assert r.status_code == 200, r.text
assert not any(
"DELETE FROM scrape_proxy_source_bans" in str(c.args[0]) for c in db.execute.call_args_list
)
# ── POST /proxies/bulk — не глушит ручной disable (#2610) ──────────────────
def test_bulk_upsert_sql_preserves_manual_disable_on_conflict(
client: TestClient, db: MagicMock
) -> None:
"""Bulk-upsert (например повторный прогон загрузчика) не должен молча вернуть в
строй узел, выключенный оператором вручную — ON CONFLICT SET enabled должен
проверять disabled_reason существующей строки, а не ставить true безусловно."""
db.execute.side_effect = [_scalar_result(False)]
r = client.post(
"/api/v1/admin/proxies/bulk",
json={"proxies": [{"url": "http://u:p@host1:8080", "provider_affinity": "avito"}]},
)
assert r.status_code == 200, r.text
sql_text = str(db.execute.call_args.args[0])
assert "disabled_reason IS NULL" in sql_text
assert "scrape_proxies.enabled" in sql_text # fallback — не трогает при ручном disable