gendesign/tradein-mvp/backend/tests/test_admin_cian_session_endpoints.py
lekss361 29db137375
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m9s
Deploy Trade-In / build-backend (push) Successful in 1m1s
Deploy Trade-In / deploy (push) Successful in 1m31s
fix(tradein/admin): развести исходы проверки кук — бан не равен «плохим кукам» (#2829)
2026-08-11 06:08:16 +00:00

290 lines
12 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 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/BrowserFetcher мокаются, 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(
"app.api.v1.admin.BrowserFetcher",
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(
"app.api.v1.admin.BrowserFetcher",
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