"""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