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
Правки по 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
376 lines
16 KiB
Python
376 lines
16 KiB
Python
"""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
|