All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
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 5m5s
Два хвоста одной темы — проводка пула прокси в контейнере backend. #3197: `cian-login` и `domclick-detail-debug` были последними прямыми конструкциями `BrowserFetcher(source=, endpoint=)` мимо `build_browser_fetcher`. Без `proxy_provider`/`use_pool`/`environment` сайдкар брал свой env-узел `SCRAPER_PROXY_URL` (на проде выключенный узел 9: 407 → camoufox `InvalidIP`), а прод-отказ «пул пуст» (#2616) на этих путях был мёртв — он смотрит на `environment`, который до конструктора не доезжал. Соседи по эпику уже переведены (#3382 cian, #3389 yandex). Прямых конструкций без провайдера вне тестов больше не осталось: остальные (backfill-задачи, pipeline) пул получают своими kwargs, а `endpoint=None`-ветки providers — это документированный `config=None` для офлайн-тестов. #3386: `_PoolCurlConfig` в `cian_price_history` форсил `use_proxy_pool_curl=True`, потому что у контейнера `backend` не было переменной. #3387 задал `USE_PROXY_POOL_CURL: "true"` сервису `backend` в compose — зашитая константа стала лишней и делала рубильник неотключаемым ровно на этом пути (докстринг при этом описывал уже неверную причину). Теперь `RealScraperConfig()` напрямую. Тесты меряют значения, а не наличие kwarg'а: на откате исходников красные 4 параметризации нового `test_3197_admin_debug_browser_pool_wiring` (`assert None is not None` — провайдер не передан) и `test_price_history_honours_flag_off` (`assert ['cian'] == []` — пул дёргался при выключенном флаге).
294 lines
12 KiB
Python
294 lines
12 KiB
Python
"""Offline tests для Cian cookie-session admin-эндпоинтов (#инцидент 2026-08-10).
|
||
|
||
cian_session_svc.verify_session() возвращает 5 разных исходов (state dict / None /
|
||
VERIFY_BAN_SENTINEL / VERIFY_SOURCE_UNAVAILABLE_SENTINEL / VERIFY_MARKUP_CHANGED_SENTINEL)
|
||
и раньше admin.py проверял только `if state is None`, из-за чего реальный бан по IP
|
||
(403) и недоступность источника (5xx) выглядели как "куки протухли" — человек в
|
||
момент инцидента перезаливал заведомо валидные куки вместо починки egress/прокси.
|
||
|
||
Покрытие 3 эндпоинтов (db/verify_session/build_browser_fetcher мокаются, NO live network/DB),
|
||
зеркалит паттерн test_domclick_admin_apis.py (dependency_overrides[get_db] + TestClient):
|
||
- POST /api/v1/admin/scrape/cian/upload-cookies
|
||
- POST /api/v1/admin/scrape/cian/auto-login
|
||
- GET /api/v1/admin/scrape/cian/test-auth
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
from unittest.mock import AsyncMock, MagicMock, patch
|
||
|
||
import pytest
|
||
from fastapi import FastAPI
|
||
from fastapi.testclient import TestClient
|
||
|
||
from app.services.cian_session import (
|
||
VERIFY_BAN_SENTINEL,
|
||
VERIFY_MARKUP_CHANGED_SENTINEL,
|
||
VERIFY_SOURCE_UNAVAILABLE_SENTINEL,
|
||
)
|
||
|
||
|
||
@pytest.fixture
|
||
def client() -> 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():
|
||
yield MagicMock()
|
||
|
||
app.dependency_overrides[get_db] = fake_db
|
||
return TestClient(app)
|
||
|
||
|
||
_UPLOAD_URL = "/api/v1/admin/scrape/cian/upload-cookies"
|
||
_AUTOLOGIN_URL = "/api/v1/admin/scrape/cian/auto-login"
|
||
_TEST_AUTH_URL = "/api/v1/admin/scrape/cian/test-auth"
|
||
|
||
_RAW_COOKIES = {"DMIR_AUTH": "abc", "_CIAN_GK": "def"}
|
||
_AUTHENTICATED_STATE = {"user": {"isAuthenticated": True, "userId": 102963817}}
|
||
_AUTHENTICATED_STATE_NO_USERID = {"user": {"isAuthenticated": True}}
|
||
|
||
|
||
# ── POST /scrape/cian/upload-cookies — пять исходов verify_session ────────────
|
||
|
||
|
||
def test_upload_cookies_ban_returns_503(client: TestClient) -> None:
|
||
"""403 TLS/bot ban → 503, текст говорит чинить прокси, НЕ перезаливать куки."""
|
||
with (
|
||
patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
|
||
patch(
|
||
"app.api.v1.admin.cian_session_svc.verify_session",
|
||
new=AsyncMock(return_value=VERIFY_BAN_SENTINEL),
|
||
),
|
||
):
|
||
resp = client.post(_UPLOAD_URL, json=_RAW_COOKIES)
|
||
assert resp.status_code == 503
|
||
detail = resp.json()["detail"]
|
||
assert "заблокировал" in detail
|
||
assert "прокси" in detail or "egress" in detail
|
||
assert "бесполезно" in detail
|
||
# Текст обязан называть ЖИВУЮ переменную: инцидент 2026-08-10 — правка мёртвой
|
||
# CIAN_PROXY_URL не давала эффекта, реальный egress задаётся SCRAPER_PROXY_URL.
|
||
assert "SCRAPER_PROXY_URL" in detail
|
||
|
||
|
||
def test_upload_cookies_source_unavailable_returns_503(client: TestClient) -> None:
|
||
"""5xx/сеть → 503, текст говорит повторить позже, куки не трогать."""
|
||
with (
|
||
patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
|
||
patch(
|
||
"app.api.v1.admin.cian_session_svc.verify_session",
|
||
new=AsyncMock(return_value=VERIFY_SOURCE_UNAVAILABLE_SENTINEL),
|
||
),
|
||
):
|
||
resp = client.post(_UPLOAD_URL, json=_RAW_COOKIES)
|
||
assert resp.status_code == 503
|
||
detail = resp.json()["detail"]
|
||
assert "недоступен" in detail
|
||
assert "позже" in detail
|
||
|
||
|
||
def test_upload_cookies_markup_changed_returns_500(client: TestClient) -> None:
|
||
"""200 но auth-state не распарсился → 500, текст указывает на фикс парсера."""
|
||
with (
|
||
patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
|
||
patch(
|
||
"app.api.v1.admin.cian_session_svc.verify_session",
|
||
new=AsyncMock(return_value=VERIFY_MARKUP_CHANGED_SENTINEL),
|
||
),
|
||
):
|
||
resp = client.post(_UPLOAD_URL, json=_RAW_COOKIES)
|
||
assert resp.status_code == 500
|
||
detail = resp.json()["detail"]
|
||
assert "вёрстк" in detail
|
||
assert "перезалив" in detail
|
||
|
||
|
||
def test_upload_cookies_expired_returns_401(client: TestClient) -> None:
|
||
"""None → 401, текст просит перезалить куки (единственный случай re-upload)."""
|
||
with (
|
||
patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
|
||
patch(
|
||
"app.api.v1.admin.cian_session_svc.verify_session",
|
||
new=AsyncMock(return_value=None),
|
||
),
|
||
):
|
||
resp = client.post(_UPLOAD_URL, json=_RAW_COOKIES)
|
||
assert resp.status_code == 401
|
||
assert "перезалейте" in resp.json()["detail"]
|
||
|
||
|
||
def test_upload_cookies_success_missing_userid_returns_400(client: TestClient) -> None:
|
||
"""Успешный state, но без userId → 400 с уточнением что куки тут ни при чём."""
|
||
with (
|
||
patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
|
||
patch(
|
||
"app.api.v1.admin.cian_session_svc.verify_session",
|
||
new=AsyncMock(return_value=_AUTHENTICATED_STATE_NO_USERID),
|
||
),
|
||
):
|
||
resp = client.post(_UPLOAD_URL, json=_RAW_COOKIES)
|
||
assert resp.status_code == 400
|
||
detail = resp.json()["detail"]
|
||
assert "userId" in detail
|
||
assert "куки тут ни при" in detail
|
||
|
||
|
||
def test_upload_cookies_success_saves_and_returns_200(client: TestClient) -> None:
|
||
with (
|
||
patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
|
||
patch(
|
||
"app.api.v1.admin.cian_session_svc.verify_session",
|
||
new=AsyncMock(return_value=_AUTHENTICATED_STATE),
|
||
),
|
||
patch("app.api.v1.admin.cian_session_svc.save_session") as mock_save,
|
||
):
|
||
resp = client.post(_UPLOAD_URL, json=_RAW_COOKIES)
|
||
assert resp.status_code == 200
|
||
body = resp.json()
|
||
assert body["ok"] is True
|
||
assert body["userId"] == 102963817
|
||
mock_save.assert_called_once()
|
||
_, kwargs = mock_save.call_args
|
||
assert kwargs["account_user_id"] == 102963817
|
||
|
||
|
||
# ── POST /scrape/cian/auto-login — тот же маппинг после успешного browser-login ──
|
||
|
||
|
||
def _mock_browser_fetcher(raw_cookies: dict[str, str]) -> MagicMock:
|
||
fetcher = AsyncMock()
|
||
fetcher.__aenter__ = AsyncMock(return_value=fetcher)
|
||
fetcher.__aexit__ = AsyncMock(return_value=None)
|
||
fetcher.login = AsyncMock(return_value=raw_cookies)
|
||
return fetcher
|
||
|
||
|
||
def test_auto_login_ban_returns_503(client: TestClient) -> None:
|
||
"""Browser login прошёл, но verify_session ловит 403 ban → 503, не "куки протухли"."""
|
||
with (
|
||
patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
|
||
patch("app.api.v1.admin.settings.cian_login_email", "user@example.com"),
|
||
patch("app.api.v1.admin.settings.cian_login_password", "secret"),
|
||
patch(
|
||
# #3197 (хвост): ручка строит фетчер фабрикой, а не BrowserFetcher напрямую —
|
||
# только так до сайдкара доезжают proxy_provider/use_pool/environment.
|
||
"app.api.v1.admin.build_browser_fetcher",
|
||
return_value=_mock_browser_fetcher(_RAW_COOKIES),
|
||
),
|
||
patch(
|
||
"app.api.v1.admin.cian_session_svc.verify_session",
|
||
new=AsyncMock(return_value=VERIFY_BAN_SENTINEL),
|
||
),
|
||
):
|
||
resp = client.post(_AUTOLOGIN_URL)
|
||
assert resp.status_code == 503
|
||
assert "заблокировал" in resp.json()["detail"]
|
||
|
||
|
||
def test_auto_login_success_saves_and_returns_200(client: TestClient) -> None:
|
||
with (
|
||
patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
|
||
patch("app.api.v1.admin.settings.cian_login_email", "user@example.com"),
|
||
patch("app.api.v1.admin.settings.cian_login_password", "secret"),
|
||
patch(
|
||
# #3197 (хвост): ручка строит фетчер фабрикой, а не BrowserFetcher напрямую —
|
||
# только так до сайдкара доезжают proxy_provider/use_pool/environment.
|
||
"app.api.v1.admin.build_browser_fetcher",
|
||
return_value=_mock_browser_fetcher(_RAW_COOKIES),
|
||
),
|
||
patch(
|
||
"app.api.v1.admin.cian_session_svc.verify_session",
|
||
new=AsyncMock(return_value=_AUTHENTICATED_STATE),
|
||
),
|
||
patch("app.api.v1.admin.cian_session_svc.save_session") as mock_save,
|
||
):
|
||
resp = client.post(_AUTOLOGIN_URL)
|
||
assert resp.status_code == 200
|
||
assert resp.json()["userId"] == 102963817
|
||
mock_save.assert_called_once()
|
||
|
||
|
||
# ── GET /scrape/cian/test-auth — reason различает все пять исходов ────────────
|
||
|
||
|
||
def test_test_auth_ban_reason(client: TestClient) -> None:
|
||
with (
|
||
patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
|
||
patch("app.api.v1.admin.cian_session_svc.load_session", return_value=_RAW_COOKIES),
|
||
patch(
|
||
"app.api.v1.admin.cian_session_svc.verify_session",
|
||
new=AsyncMock(return_value=VERIFY_BAN_SENTINEL),
|
||
),
|
||
):
|
||
resp = client.get(_TEST_AUTH_URL)
|
||
assert resp.status_code == 200
|
||
body = resp.json()
|
||
assert body["authenticated"] is False
|
||
assert body["reason"] == "banned_403"
|
||
|
||
|
||
def test_test_auth_source_unavailable_reason(client: TestClient) -> None:
|
||
with (
|
||
patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
|
||
patch("app.api.v1.admin.cian_session_svc.load_session", return_value=_RAW_COOKIES),
|
||
patch(
|
||
"app.api.v1.admin.cian_session_svc.verify_session",
|
||
new=AsyncMock(return_value=VERIFY_SOURCE_UNAVAILABLE_SENTINEL),
|
||
),
|
||
):
|
||
resp = client.get(_TEST_AUTH_URL)
|
||
assert resp.status_code == 200
|
||
assert resp.json()["reason"] == "source_unavailable"
|
||
|
||
|
||
def test_test_auth_markup_changed_reason(client: TestClient) -> None:
|
||
with (
|
||
patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
|
||
patch("app.api.v1.admin.cian_session_svc.load_session", return_value=_RAW_COOKIES),
|
||
patch(
|
||
"app.api.v1.admin.cian_session_svc.verify_session",
|
||
new=AsyncMock(return_value=VERIFY_MARKUP_CHANGED_SENTINEL),
|
||
),
|
||
):
|
||
resp = client.get(_TEST_AUTH_URL)
|
||
assert resp.status_code == 200
|
||
assert resp.json()["reason"] == "markup_changed"
|
||
|
||
|
||
def test_test_auth_expired_reason(client: TestClient) -> None:
|
||
with (
|
||
patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
|
||
patch("app.api.v1.admin.cian_session_svc.load_session", return_value=_RAW_COOKIES),
|
||
patch(
|
||
"app.api.v1.admin.cian_session_svc.verify_session",
|
||
new=AsyncMock(return_value=None),
|
||
),
|
||
):
|
||
resp = client.get(_TEST_AUTH_URL)
|
||
assert resp.status_code == 200
|
||
assert resp.json()["reason"] == "session_expired_or_invalid"
|
||
|
||
|
||
def test_test_auth_success(client: TestClient) -> None:
|
||
with (
|
||
patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
|
||
patch("app.api.v1.admin.cian_session_svc.load_session", return_value=_RAW_COOKIES),
|
||
patch(
|
||
"app.api.v1.admin.cian_session_svc.verify_session",
|
||
new=AsyncMock(return_value=_AUTHENTICATED_STATE),
|
||
),
|
||
):
|
||
resp = client.get(_TEST_AUTH_URL)
|
||
assert resp.status_code == 200
|
||
body = resp.json()
|
||
assert body["authenticated"] is True
|
||
assert body["userId"] == 102963817
|
||
assert body["reason"] is None
|