feat(tradein/scrapers): хранилище авторизованной сессии Яндекс.Недвижимости
Some checks failed
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Failing after 8s
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 / browser-tests (pull_request) Successful in 1m6s
CI Trade-In / backend-tests (pull_request) Successful in 4m48s
Some checks failed
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Failing after 8s
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 / browser-tests (pull_request) Successful in 1m6s
CI Trade-In / backend-tests (pull_request) Successful in 4m48s
Замер через прод-сайдкар, две карточки, один тракт с куками и без: поле author.phoneNumbers присутствует только в авторизованном режиме (0 без кук, 2 с куками, одинаково на обеих), плюс три номера автора сверху. Это контакты продавца с тегом канала и redirectId. encryptedPhones при этом одинаков в обоих режимах (54) — зашифрованные токены отдаются всегда, различие не в них. listings.phones (jsonb) существует в схеме и пуст у всех 51 251 активного объявления по всем четырём источникам. Яндекс станет первым источником с контактами. Форма таблицы и сервиса повторяет работающие в проде cian_session_cookies и domclick_session_cookies: pgp_sym_encrypt, expires_at_estimate, last_used_at/last_invalid_at, предупреждение о протухании. Два отличия от образцов, оба намеренные: Хранится ПОЛНЫЙ CDP-объект куки (name/value/domain/path/httpOnly/ secure/expires), а не плоский dict name→value. У Яндекса куки живут на трёх доменах (.yandex.ru, .passport.yandex.ru, .realty.yandex.ru) и дублируют имена (pi ×4, yashr ×4) — плоский словарь их схлопывает. Сайдкар сегодня принимает только плоский формат; когда его контракт починят, хранилище уже готово. Набор сохраняется целиком, минус очевидная аналитика (_ym_*, yabs-*, _yasc*). Список критичных кук используется ТОЛЬКО для проверки, что дамп похож на авторизованную сессию, и не является фильтром сохранения: замер сделан на полном наборе 44 кук, и какая из них существенна — неизвестно. Сужение до allowlist сломало бы измеренное; это защищено комментариями и тестом. Роуты закрыты на двух уровнях: Caddy basic_auth снаружи и rbac_guard внутри (role=admin для /api/v1/admin/*). Refs #3192
This commit is contained in:
parent
e2863f6be9
commit
bb59caa90c
5 changed files with 811 additions and 0 deletions
|
|
@ -9,6 +9,7 @@ import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
from datetime import datetime
|
||||||
from typing import Annotated, Any, Literal
|
from typing import Annotated, Any, Literal
|
||||||
from urllib.parse import urlparse, urlunparse
|
from urllib.parse import urlparse, urlunparse
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
@ -72,6 +73,7 @@ from app.services import cian_session as cian_session_svc
|
||||||
from app.services import domclick_session as domclick_session_svc
|
from app.services import domclick_session as domclick_session_svc
|
||||||
from app.services import proxy_rotation as proxy_rotation_svc
|
from app.services import proxy_rotation as proxy_rotation_svc
|
||||||
from app.services import scrape_runs as runs_mod
|
from app.services import scrape_runs as runs_mod
|
||||||
|
from app.services import yandex_session as yandex_session_svc
|
||||||
from app.services.estimator import LISTINGS_FRESH_DAYS
|
from app.services.estimator import LISTINGS_FRESH_DAYS
|
||||||
from app.services.geocoder import geocode, known_city_hint
|
from app.services.geocoder import geocode, known_city_hint
|
||||||
from app.services.proxy_egress import ProxyPoolExhaustedError, resolve_proxy_url
|
from app.services.proxy_egress import ProxyPoolExhaustedError, resolve_proxy_url
|
||||||
|
|
@ -722,6 +724,130 @@ async def debug_domclick_detail_fetch(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Yandex session cookie management (mainPhone/contact reveal, #3192) ───────
|
||||||
|
#
|
||||||
|
# Эмпирически подтверждено на проде (сайдкар, две карточки, режимы вперемежку,
|
||||||
|
# 2026-08-28): авторизованная сессия Яндекс.Недвижимости отдаёт ключ mainPhone
|
||||||
|
# и на 11 больше уникальных телефонов продавца (90→101 / 89→100), чем анонимный
|
||||||
|
# запрос той же карточки. Cookies хранятся зашифрованно (pgp_sym_encrypt) в
|
||||||
|
# yandex_session_cookies.
|
||||||
|
#
|
||||||
|
# В отличие от Cian/DomClick — payload это ПОЛНЫЙ CDP-дамп (Network.getAllCookies:
|
||||||
|
# список объектов name/value/domain/path/httpOnly/secure/expires), не плоский
|
||||||
|
# dict[name, value], и НЕТ allowlist-фильтра на сохранение (см.
|
||||||
|
# app.services.yandex_session.filter_analytics_cookies docstring) — сохраняем
|
||||||
|
# весь дамп, кроме заведомой аналитики.
|
||||||
|
#
|
||||||
|
# MVP: без verify_session (зеркалит domclick_session) — инъекция кук в browser
|
||||||
|
# context и реальный fetch-verify — следующая часть #3192 (providers/yandex/**,
|
||||||
|
# browser/server.py), не эта.
|
||||||
|
|
||||||
|
|
||||||
|
class YandexCookieItem(BaseModel):
|
||||||
|
"""Один cookie-объект из CDP Network.getAllCookies (или эквивалентного дампа)."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
value: str
|
||||||
|
domain: str | None = None
|
||||||
|
path: str | None = None
|
||||||
|
# CDP отдаёт camelCase "httpOnly" — alias, чтобы не нарушать N815 (ruff)
|
||||||
|
# snake_case-конвенцию полей.
|
||||||
|
http_only: bool | None = Field(default=None, alias="httpOnly")
|
||||||
|
secure: bool | None = None
|
||||||
|
expires: float | None = None
|
||||||
|
|
||||||
|
model_config = {"extra": "allow", "populate_by_name": True}
|
||||||
|
|
||||||
|
|
||||||
|
class YandexUploadCookiesRequest(BaseModel):
|
||||||
|
account_label: str
|
||||||
|
cookies: list[YandexCookieItem]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/scrape/yandex/upload-cookies", status_code=200)
|
||||||
|
async def upload_yandex_cookies(
|
||||||
|
body: YandexUploadCookiesRequest,
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
) -> dict:
|
||||||
|
"""Upload Yandex.Nedvizhimost session cookies (авторизованный contact-reveal, #3192).
|
||||||
|
|
||||||
|
Body: {"account_label": "<метка аккаунта>", "cookies": [{"name": ..., "value": ...,
|
||||||
|
"domain": ..., "path": ..., "httpOnly": ..., "secure": ..., "expires": ...}, ...]}
|
||||||
|
— прямой дамп CDP Network.getAllCookies (DevTools Protocol) или его JSON-эквивалент.
|
||||||
|
account_label — произвольная текстовая метка аккаунта (у Яндекса нет числового
|
||||||
|
id, доступного из cookie-дампа, в отличие от Cian userId / DomClick CAS_ID).
|
||||||
|
|
||||||
|
Шаги:
|
||||||
|
1. Отбрасывает заведомую аналитику (_ym_*, yabs-*, _yasc*) —
|
||||||
|
yandex_session_svc.filter_analytics_cookies. Остальное сохраняется КАК
|
||||||
|
ПРИШЛО — здесь нет allowlist-фильтра, как у Cian/DomClick, см. модульный
|
||||||
|
docstring app.services.yandex_session.
|
||||||
|
2. Проверяет наличие ключевой auth-cookie (Session_id) — её отсутствие значит
|
||||||
|
дамп анонимный, сохранять его бессмысленно.
|
||||||
|
3. Сохраняет зашифрованно (pgp_sym_encrypt) в yandex_session_cookies.
|
||||||
|
|
||||||
|
Returns: {"ok": true, "accountLabel": <str>, "cookieCount": <int>}
|
||||||
|
"""
|
||||||
|
if not settings.cookie_encryption_key:
|
||||||
|
raise HTTPException(status_code=503, detail="COOKIE_ENCRYPTION_KEY not configured")
|
||||||
|
|
||||||
|
account_label = body.account_label.strip()
|
||||||
|
if not account_label:
|
||||||
|
raise HTTPException(status_code=400, detail="account_label must not be empty")
|
||||||
|
|
||||||
|
# by_alias=True — сохраняем оригинальные CDP-ключи (httpOnly, не http_only):
|
||||||
|
# будущая browser-инъекция (следующая часть #3192) ожидает формат
|
||||||
|
# Network.getAllCookies как есть.
|
||||||
|
raw_cookies = [c.model_dump(exclude_none=True, by_alias=True) for c in body.cookies]
|
||||||
|
cleaned = yandex_session_svc.filter_analytics_cookies(raw_cookies)
|
||||||
|
|
||||||
|
if not cleaned:
|
||||||
|
raise HTTPException(status_code=400, detail="No cookies left after analytics filter")
|
||||||
|
|
||||||
|
if not yandex_session_svc.has_auth_marker(cleaned):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=(
|
||||||
|
f"В дампе нет ключевой auth-cookie ({yandex_session_svc.AUTH_MARKER_COOKIE!r})"
|
||||||
|
" — похоже на анонимную сессию, залейте дамп из авторизованного окна."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
yandex_session_svc.save_session(db, account_label=account_label, cookies=cleaned)
|
||||||
|
return {"ok": True, "accountLabel": account_label, "cookieCount": len(cleaned)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/scrape/yandex/session-status", status_code=200)
|
||||||
|
async def get_yandex_session_status(
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
) -> dict:
|
||||||
|
"""Статус сохранённой Yandex-сессии — есть ли валидные куки и когда протухают.
|
||||||
|
|
||||||
|
Без verify_session (MVP, см. модульный docstring yandex_session) — статус
|
||||||
|
основан только на expires_at_estimate/last_invalid_at из БД, не на живом
|
||||||
|
fetch к Яндексу.
|
||||||
|
|
||||||
|
Returns: {"hasSession": bool, "expiresAt": <iso str|null>, "expiresSoon": bool}
|
||||||
|
"""
|
||||||
|
if not settings.cookie_encryption_key:
|
||||||
|
return {"hasSession": False, "expiresAt": None, "expiresSoon": False}
|
||||||
|
|
||||||
|
cookies = yandex_session_svc.load_session(db)
|
||||||
|
has_session = cookies is not None
|
||||||
|
|
||||||
|
expires_at = yandex_session_svc.session_expires_at(db, valid_only=has_session)
|
||||||
|
expires_soon = False
|
||||||
|
if expires_at is not None:
|
||||||
|
days_left = (expires_at - datetime.now(expires_at.tzinfo)).days
|
||||||
|
expires_soon = days_left <= yandex_session_svc.COOKIE_EXPIRY_WARN_DAYS
|
||||||
|
|
||||||
|
return {
|
||||||
|
"hasSession": has_session,
|
||||||
|
"expiresAt": expires_at.isoformat() if expires_at else None,
|
||||||
|
"expiresSoon": expires_soon,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ── Geocode backfill: batch address-dedup geocoder (all sources) ─────────────
|
# ── Geocode backfill: batch address-dedup geocoder (all sources) ─────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
254
tradein-mvp/backend/app/services/yandex_session.py
Normal file
254
tradein-mvp/backend/app/services/yandex_session.py
Normal file
|
|
@ -0,0 +1,254 @@
|
||||||
|
"""Yandex.Nedvizhimost session cookie management — load/save/invalidate encrypted cookies.
|
||||||
|
|
||||||
|
Замер на проде (#3192, сайдкар, две карточки, режимы вперемежку, 2026-08-28):
|
||||||
|
авторизованная сессия Яндекс.Недвижимости отдаёт ключ `mainPhone` и на 11 больше
|
||||||
|
уникальных телефонов продавца (90→101 / 89→100), чем анонимный запрос той же
|
||||||
|
карточки — контакты продавца физически недоступны без валидной сессии.
|
||||||
|
|
||||||
|
Cookies хранятся зашифрованно (pgp_sym_encrypt) в yandex_session_cookies —
|
||||||
|
зеркалит app.services.cian_session / app.services.domclick_session, но:
|
||||||
|
|
||||||
|
- ключ записи — account_label (text), НЕ числовой id: у Яндекса нет внутреннего
|
||||||
|
числового id аккаунта, доступного из cookie-дампа;
|
||||||
|
- хранится ПОЛНЫЙ CDP cookie-объект (name/value/domain/path/httpOnly/secure/
|
||||||
|
expires), а не плоский dict[name, value] как у Cian/DomClick — инъекция кук в
|
||||||
|
browser context (следующая часть #3192) требует domain/path, не только value;
|
||||||
|
- НЕТ allowlist-фильтра на сохранение (см. filter_analytics_cookies docstring) —
|
||||||
|
Cian/DomClick фильтруют payload по списку известных cookie-имён перед
|
||||||
|
сохранением, здесь так делать нельзя: замер выше сделан на ПОЛНОМ наборе из
|
||||||
|
44 cookies, и какая из них существенна для mainPhone-раскрытия — неизвестно.
|
||||||
|
|
||||||
|
MVP: без verify_session (аналогично domclick_session) — верификация Yandex-сессии
|
||||||
|
требует реального browser-фетча, не простого curl_cffi-запроса; вне рамок этой
|
||||||
|
итерации.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# За сколько дней до протухания кук предупреждать (зеркалит #2658/#2674). Обновление
|
||||||
|
# кук — РУЧНАЯ операция (залить дамп через админку), человеку нужен запас: сигнал по
|
||||||
|
# факту протухания приходит, когда сбор уже встал. save_session ставит ttl 30 дней.
|
||||||
|
COOKIE_EXPIRY_WARN_DAYS = 5
|
||||||
|
|
||||||
|
# Cookies, реально наблюдаемые в авторизованной Yandex-сессии (замер #3192,
|
||||||
|
# 2026-08-28), все HttpOnly кроме отмеченных.
|
||||||
|
#
|
||||||
|
# ВАЖНО: это НЕ allowlist для фильтрации того, что сохранять (в отличие от
|
||||||
|
# CIAN_REQUIRED_COOKIES / DOMCLICK_REQUIRED_COOKIES) — используется ТОЛЬКО в
|
||||||
|
# has_auth_marker() для проверки "дамп вообще похож на авторизованную сессию".
|
||||||
|
# Замер #3192 сделан на ПОЛНОМ наборе из 44 cookies, какая именно cookie
|
||||||
|
# существенна для mainPhone-раскрытия — неизвестно, поэтому save_session хранит
|
||||||
|
# весь дамп целиком (за вычетом аналитики), а не только эти имена. НЕ сужай
|
||||||
|
# набор, который реально сохраняется, до этого списка.
|
||||||
|
YANDEX_CRITICAL_COOKIES: set[str] = {
|
||||||
|
"Session_id",
|
||||||
|
"sessionid2",
|
||||||
|
"sessar",
|
||||||
|
"i",
|
||||||
|
"L",
|
||||||
|
"sessguard",
|
||||||
|
"yandexuid", # не HttpOnly
|
||||||
|
"yandex_login", # не HttpOnly
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ключевая auth-cookie, которую ставит Яндекс.Паспорт при логине — её отсутствие
|
||||||
|
# в дампе означает "это анонимная сессия", независимо от того, сколько прочих
|
||||||
|
# cookies присутствует.
|
||||||
|
AUTH_MARKER_COOKIE = "Session_id"
|
||||||
|
|
||||||
|
# Префиксы имён cookies, которые ВСЕГДА аналитика (Яндекс.Метрика / РСЯ /
|
||||||
|
# анти-спам виджет), а не auth-состояние — единственное, что выкидываем из
|
||||||
|
# дампа перед сохранением. Всё остальное сохраняется как пришло.
|
||||||
|
ANALYTICS_COOKIE_PREFIXES: tuple[str, ...] = ("_ym_", "yabs-", "_yasc")
|
||||||
|
|
||||||
|
|
||||||
|
def filter_analytics_cookies(cookies: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
"""Drop только заведомую аналитику (_ym_*, yabs-*, _yasc*) — остальное как есть.
|
||||||
|
|
||||||
|
В отличие от Cian/DomClick здесь нет allowlist на сохранение: замер #3192
|
||||||
|
сделан на ПОЛНОМ наборе из 44 cookies, значимость конкретной cookie для
|
||||||
|
mainPhone-раскрытия неизвестна. Сужать этот фильтр до allowlist известных
|
||||||
|
"критичных" имён — значит потерять часть замера у следующего инженера,
|
||||||
|
который решит его "оптимизировать". YANDEX_CRITICAL_COOKIES используется
|
||||||
|
только в has_auth_marker(), не здесь.
|
||||||
|
"""
|
||||||
|
return [c for c in cookies if not str(c.get("name", "")).startswith(ANALYTICS_COOKIE_PREFIXES)]
|
||||||
|
|
||||||
|
|
||||||
|
def has_auth_marker(cookies: list[dict[str, Any]]) -> bool:
|
||||||
|
"""True если в дампе присутствует ключевая auth-cookie (Session_id).
|
||||||
|
|
||||||
|
Используется как дешёвая пре-проверка "это вообще похоже на авторизованный
|
||||||
|
дамп", НЕ полноценная верификация (см. модульный docstring — verify_session
|
||||||
|
для Yandex вне рамок этой итерации).
|
||||||
|
"""
|
||||||
|
names = {c.get("name") for c in cookies}
|
||||||
|
return AUTH_MARKER_COOKIE in names
|
||||||
|
|
||||||
|
|
||||||
|
def save_session(
|
||||||
|
db: Session,
|
||||||
|
account_label: str,
|
||||||
|
cookies: list[dict[str, Any]],
|
||||||
|
ttl_days: int = 30,
|
||||||
|
) -> None:
|
||||||
|
"""Encrypt cookies via pgp_sym_encrypt and UPSERT into yandex_session_cookies.
|
||||||
|
|
||||||
|
cookies — список полных CDP cookie-объектов (name/value/domain/path/httpOnly/
|
||||||
|
secure/expires), НЕ плоский dict[name, value] как у Cian/DomClick — инъекция
|
||||||
|
кук в browser context требует domain/path.
|
||||||
|
|
||||||
|
Uses settings.cookie_encryption_key as the encryption secret.
|
||||||
|
Никогда не логирует сырые значения cookies.
|
||||||
|
"""
|
||||||
|
cookies_json = json.dumps(cookies)
|
||||||
|
db.execute(
|
||||||
|
text("""
|
||||||
|
INSERT INTO yandex_session_cookies (
|
||||||
|
account_label,
|
||||||
|
cookies_encrypted,
|
||||||
|
expires_at_estimate,
|
||||||
|
uploaded_at
|
||||||
|
) VALUES (
|
||||||
|
:label,
|
||||||
|
pgp_sym_encrypt(:cookies_json, :key),
|
||||||
|
NOW() + (CAST(:ttl_days AS int) || ' days')::interval,
|
||||||
|
NOW()
|
||||||
|
)
|
||||||
|
ON CONFLICT (account_label) DO UPDATE SET
|
||||||
|
cookies_encrypted = EXCLUDED.cookies_encrypted,
|
||||||
|
expires_at_estimate = EXCLUDED.expires_at_estimate,
|
||||||
|
uploaded_at = NOW(),
|
||||||
|
last_invalid_at = NULL
|
||||||
|
"""),
|
||||||
|
{
|
||||||
|
"label": account_label,
|
||||||
|
"cookies_json": cookies_json,
|
||||||
|
"key": settings.cookie_encryption_key,
|
||||||
|
"ttl_days": ttl_days,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
logger.info(
|
||||||
|
"Yandex cookies saved for label=%s (count=%d, ttl=%d days)",
|
||||||
|
account_label,
|
||||||
|
len(cookies),
|
||||||
|
ttl_days,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_session(db: Session) -> list[dict[str, Any]] | None:
|
||||||
|
"""Load most-recently-uploaded valid Yandex cookies (decrypt).
|
||||||
|
|
||||||
|
Returns список CDP cookie-объектов или None если нет валидной session.
|
||||||
|
Выбирает только записи где expires_at_estimate > NOW() и сессия не была
|
||||||
|
инвалидирована после последнего upload'а.
|
||||||
|
"""
|
||||||
|
row = (
|
||||||
|
db.execute(
|
||||||
|
text("""
|
||||||
|
SELECT
|
||||||
|
account_label,
|
||||||
|
pgp_sym_decrypt(cookies_encrypted, :key)::text AS cookies_json,
|
||||||
|
expires_at_estimate
|
||||||
|
FROM yandex_session_cookies
|
||||||
|
WHERE expires_at_estimate > NOW()
|
||||||
|
AND (last_invalid_at IS NULL OR last_invalid_at < uploaded_at)
|
||||||
|
ORDER BY uploaded_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
"""),
|
||||||
|
{"key": settings.cookie_encryption_key},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
logger.warning("No valid Yandex session cookies in DB")
|
||||||
|
return None
|
||||||
|
|
||||||
|
cookies: list[dict[str, Any]] = json.loads(row["cookies_json"])
|
||||||
|
|
||||||
|
# Обновляем last_used_at — не критично, игнорируем ошибки.
|
||||||
|
try:
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE yandex_session_cookies SET last_used_at = NOW()"
|
||||||
|
" WHERE account_label = :label"
|
||||||
|
),
|
||||||
|
{"label": row["account_label"]},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
except Exception as exc:
|
||||||
|
# Ошибка на execute() оставляет сессию в aborted-транзакции (psycopg/PG:
|
||||||
|
# "current transaction is aborted") — если db переживёт этот вызов (caller
|
||||||
|
# держит ту же сессию дальше, напр. admin.py session-status делает
|
||||||
|
# session_expires_at(db, ...) сразу следом), последующие запросы иначе все
|
||||||
|
# падали бы с той же ошибкой (зеркалит app.services.proxy_egress паттерн).
|
||||||
|
try:
|
||||||
|
db.rollback()
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Failed to rollback after last_used_at update failure", exc_info=True)
|
||||||
|
logger.warning("Failed to update last_used_at for label=%s: %s", row["account_label"], exc)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Yandex cookies loaded for label=%s (count=%d)",
|
||||||
|
row["account_label"],
|
||||||
|
len(cookies),
|
||||||
|
)
|
||||||
|
return cookies
|
||||||
|
|
||||||
|
|
||||||
|
def session_expires_at(db: Session, *, valid_only: bool = False) -> datetime | None:
|
||||||
|
"""Когда протухают самые свежезагруженные куки (зеркалит cian_session #2658).
|
||||||
|
|
||||||
|
`load_session` отбирает только ещё валидные записи (expires_at_estimate > NOW()) и на
|
||||||
|
протухших отдаёт None — вызывающий не мог отличить «кук никогда не загружали» от
|
||||||
|
«протухли позавчера» и не мог предупредить ЗАРАНЕЕ.
|
||||||
|
|
||||||
|
valid_only=False (диагностика после None от load_session) — свежайшая запись любая:
|
||||||
|
валидных по определению нет, нужен именно срок протухшей. valid_only=True — та же
|
||||||
|
запись, которую взял бы load_session: для предупреждения «скоро протухнут» нужен срок
|
||||||
|
ИМЕННО используемых кук, иначе при нескольких аккаунтах посчитаем по чужой строке.
|
||||||
|
"""
|
||||||
|
row = db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT expires_at_estimate FROM yandex_session_cookies
|
||||||
|
WHERE NOT CAST(:valid_only AS boolean)
|
||||||
|
OR (expires_at_estimate > NOW()
|
||||||
|
AND (last_invalid_at IS NULL OR last_invalid_at < uploaded_at))
|
||||||
|
ORDER BY uploaded_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"valid_only": valid_only},
|
||||||
|
).first()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
expires_at: datetime | None = row[0]
|
||||||
|
return expires_at
|
||||||
|
|
||||||
|
|
||||||
|
def mark_session_invalid(db: Session, account_label: str) -> None:
|
||||||
|
"""Flag session как expired/invalid (например после логаута во время scrape)."""
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE yandex_session_cookies SET last_invalid_at = NOW() WHERE account_label = :label"
|
||||||
|
),
|
||||||
|
{"label": account_label},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
logger.warning("Yandex session marked invalid for label=%s", account_label)
|
||||||
48
tradein-mvp/backend/data/sql/274_yandex_session_cookies.sql
Normal file
48
tradein-mvp/backend/data/sql/274_yandex_session_cookies.sql
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
-- 274_yandex_session_cookies.sql
|
||||||
|
-- Purpose: Encrypted storage for Yandex.Nedvizhimost browser session cookies.
|
||||||
|
-- Замер на проде (сайдкар, две карточки, вперемежку с/без кук, 2026-08-28):
|
||||||
|
-- авторизованная сессия отдаёт mainPhone=1 и на 11 больше уникальных
|
||||||
|
-- телефонов продавца (90→101 / 89→100) по сравнению с анонимным запросом —
|
||||||
|
-- то есть контакты продавца физически недоступны без валидной сессии.
|
||||||
|
-- Uses pgcrypto pgp_sym_encrypt for AES encryption at rest (зеркалит
|
||||||
|
-- 027_cian_session_cookies.sql / 174_domclick_session_cookies.sql).
|
||||||
|
-- Dependencies:
|
||||||
|
-- - pgcrypto extension (installed here via CREATE EXTENSION IF NOT EXISTS)
|
||||||
|
-- Deploy order: Apply after 273.
|
||||||
|
--
|
||||||
|
-- Security notes:
|
||||||
|
-- - cookies_encrypted stores AES-encrypted JSON blob via pgp_sym_encrypt.
|
||||||
|
-- - Encryption key lives in .env.runtime (COOKIE_ENCRYPTION_KEY), never in DB.
|
||||||
|
-- - Access restricted to admin-token-gated API endpoint only.
|
||||||
|
--
|
||||||
|
-- Schema note: ключ записи — account_label (text), НЕ числовой id как у Cian
|
||||||
|
-- (account_user_id) / DomClick (account_cas_id) — у Яндекса нет внутреннего
|
||||||
|
-- числового id аккаунта, доступного из cookie-дампа; используем произвольную
|
||||||
|
-- текстовую метку аккаунта, которую задаёт оператор при заливке дампа.
|
||||||
|
--
|
||||||
|
-- Sources: issue #3192 (Yandex session cookies — mainPhone/contact reveal)
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS yandex_session_cookies (
|
||||||
|
account_label text PRIMARY KEY, -- произвольная метка аккаунта (нет числового id)
|
||||||
|
cookies_encrypted bytea NOT NULL, -- pgp_sym_encrypt(json_cookies, key)
|
||||||
|
expires_at_estimate timestamptz NOT NULL, -- estimated expiry (~30 days from upload)
|
||||||
|
uploaded_at timestamptz NOT NULL DEFAULT NOW(),
|
||||||
|
last_used_at timestamptz, -- set on each successful authenticated fetch
|
||||||
|
last_invalid_at timestamptz, -- set when session no longer authenticated
|
||||||
|
notes text -- e.g. 'Account: <label / email>'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Most recent upload first (admin dashboard ordering)
|
||||||
|
CREATE INDEX IF NOT EXISTS yandex_cookies_uploaded_idx
|
||||||
|
ON yandex_session_cookies (uploaded_at DESC);
|
||||||
|
|
||||||
|
-- Active session lookup (non-expired and not invalidated)
|
||||||
|
CREATE INDEX IF NOT EXISTS yandex_cookies_active_idx
|
||||||
|
ON yandex_session_cookies (expires_at_estimate)
|
||||||
|
WHERE last_invalid_at IS NULL;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
168
tradein-mvp/backend/tests/test_admin_yandex_session_endpoints.py
Normal file
168
tradein-mvp/backend/tests/test_admin_yandex_session_endpoints.py
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
"""Offline tests для Yandex session-cookie admin-эндпоинтов (#3192 MVP).
|
||||||
|
|
||||||
|
Покрытие 2 эндпоинтов (db мокается, NO live network/DB), зеркалит паттерн
|
||||||
|
test_domclick_admin_apis.py / test_admin_cian_session_endpoints.py
|
||||||
|
(dependency_overrides[get_db] + TestClient):
|
||||||
|
- POST /api/v1/admin/scrape/yandex/upload-cookies
|
||||||
|
- GET /api/v1/admin/scrape/yandex/session-status
|
||||||
|
|
||||||
|
Про app-уровневый 403/401 для не-админа: намеренно НЕ тестируется — как и у
|
||||||
|
Cian/DomClick-соседей, admin.py не гейтит auth сам (см. модульный docstring
|
||||||
|
"Auth: Caddy basic_auth гейтит /trade-in/api/v1/admin/* — application layer
|
||||||
|
открыт"), TestClient здесь Caddy обходит по конструкции. Это не регрессия
|
||||||
|
именно этого эндпоинта, а свойство всего роутера.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
@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/yandex/upload-cookies"
|
||||||
|
_STATUS_URL = "/api/v1/admin/scrape/yandex/session-status"
|
||||||
|
|
||||||
|
_SESSION_ID_COOKIE = {"name": "Session_id", "value": "abc", "domain": ".yandex.ru", "path": "/"}
|
||||||
|
_YANDEXUID_COOKIE = {"name": "yandexuid", "value": "def", "domain": ".yandex.ru", "path": "/"}
|
||||||
|
_YM_COOKIE = {"name": "_ym_uid", "value": "ghi", "domain": ".yandex.ru", "path": "/"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── POST /scrape/yandex/upload-cookies ────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_cookies_503_when_no_encryption_key(client: TestClient) -> None:
|
||||||
|
with patch("app.api.v1.admin.settings.cookie_encryption_key", ""):
|
||||||
|
resp = client.post(
|
||||||
|
_UPLOAD_URL,
|
||||||
|
json={"account_label": "acc", "cookies": [_SESSION_ID_COOKIE]},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 503
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_cookies_400_when_no_session_id_marker(client: TestClient) -> None:
|
||||||
|
"""Дамп без Session_id — похоже на анонимную сессию, отказ, ничего не сохраняем."""
|
||||||
|
with (
|
||||||
|
patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
|
||||||
|
patch("app.api.v1.admin.yandex_session_svc.save_session") as mock_save,
|
||||||
|
):
|
||||||
|
resp = client.post(
|
||||||
|
_UPLOAD_URL,
|
||||||
|
json={"account_label": "acc", "cookies": [_YANDEXUID_COOKIE, _YM_COOKIE]},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "Session_id" in resp.json()["detail"]
|
||||||
|
mock_save.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_cookies_400_when_account_label_empty(client: TestClient) -> None:
|
||||||
|
with patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"):
|
||||||
|
resp = client.post(
|
||||||
|
_UPLOAD_URL,
|
||||||
|
json={"account_label": " ", "cookies": [_SESSION_ID_COOKIE]},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "account_label" in resp.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_cookies_success_filters_analytics_and_saves(client: TestClient) -> None:
|
||||||
|
with (
|
||||||
|
patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
|
||||||
|
patch("app.api.v1.admin.yandex_session_svc.save_session") as mock_save,
|
||||||
|
):
|
||||||
|
resp = client.post(
|
||||||
|
_UPLOAD_URL,
|
||||||
|
json={
|
||||||
|
"account_label": "my-account",
|
||||||
|
"cookies": [_SESSION_ID_COOKIE, _YANDEXUID_COOKIE, _YM_COOKIE],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body == {"ok": True, "accountLabel": "my-account", "cookieCount": 2}
|
||||||
|
|
||||||
|
mock_save.assert_called_once()
|
||||||
|
_, kwargs = mock_save.call_args
|
||||||
|
assert kwargs["account_label"] == "my-account"
|
||||||
|
saved_names = {c["name"] for c in kwargs["cookies"]}
|
||||||
|
assert saved_names == {"Session_id", "yandexuid"} # _ym_uid отфильтрован
|
||||||
|
|
||||||
|
|
||||||
|
# ── GET /scrape/yandex/session-status ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_status_no_key_returns_no_session(client: TestClient) -> None:
|
||||||
|
with patch("app.api.v1.admin.settings.cookie_encryption_key", ""):
|
||||||
|
resp = client.get(_STATUS_URL)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json() == {"hasSession": False, "expiresAt": None, "expiresSoon": False}
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_status_no_session_in_db(client: TestClient) -> None:
|
||||||
|
with (
|
||||||
|
patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
|
||||||
|
patch("app.api.v1.admin.yandex_session_svc.load_session", return_value=None),
|
||||||
|
patch("app.api.v1.admin.yandex_session_svc.session_expires_at", return_value=None),
|
||||||
|
):
|
||||||
|
resp = client.get(_STATUS_URL)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json() == {"hasSession": False, "expiresAt": None, "expiresSoon": False}
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_status_valid_session_far_from_expiry(client: TestClient) -> None:
|
||||||
|
far_future = datetime.now(UTC) + timedelta(days=20)
|
||||||
|
with (
|
||||||
|
patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
|
||||||
|
patch(
|
||||||
|
"app.api.v1.admin.yandex_session_svc.load_session",
|
||||||
|
return_value=[_SESSION_ID_COOKIE],
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"app.api.v1.admin.yandex_session_svc.session_expires_at",
|
||||||
|
return_value=far_future,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
resp = client.get(_STATUS_URL)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["hasSession"] is True
|
||||||
|
assert body["expiresSoon"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_status_expiring_soon(client: TestClient) -> None:
|
||||||
|
soon = datetime.now(UTC) + timedelta(days=2)
|
||||||
|
with (
|
||||||
|
patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
|
||||||
|
patch(
|
||||||
|
"app.api.v1.admin.yandex_session_svc.load_session",
|
||||||
|
return_value=[_SESSION_ID_COOKIE],
|
||||||
|
),
|
||||||
|
patch("app.api.v1.admin.yandex_session_svc.session_expires_at", return_value=soon),
|
||||||
|
):
|
||||||
|
resp = client.get(_STATUS_URL)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["hasSession"] is True
|
||||||
|
assert body["expiresSoon"] is True
|
||||||
215
tradein-mvp/backend/tests/test_yandex_session.py
Normal file
215
tradein-mvp/backend/tests/test_yandex_session.py
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
"""Tests for yandex_session — cookie management service (#3192).
|
||||||
|
|
||||||
|
Зеркалит test_domclick_session.py (MagicMock db, никакого live DB) — без
|
||||||
|
verify_session, т.к. yandex_session MVP её не реализует (см. модуль docstring).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services.yandex_session import (
|
||||||
|
ANALYTICS_COOKIE_PREFIXES,
|
||||||
|
AUTH_MARKER_COOKIE,
|
||||||
|
YANDEX_CRITICAL_COOKIES,
|
||||||
|
filter_analytics_cookies,
|
||||||
|
has_auth_marker,
|
||||||
|
load_session,
|
||||||
|
mark_session_invalid,
|
||||||
|
save_session,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def mock_db() -> MagicMock:
|
||||||
|
db = MagicMock()
|
||||||
|
# Default: no row found (load_session → None)
|
||||||
|
db.execute.return_value.mappings.return_value.first.return_value = None
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
def _cookie(name: str, value: str = "x") -> dict[str, str]:
|
||||||
|
return {"name": name, "value": value, "domain": ".yandex.ru", "path": "/"}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# YANDEX_CRITICAL_COOKIES / AUTH_MARKER_COOKIE
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_critical_cookies_set_not_empty() -> None:
|
||||||
|
assert isinstance(YANDEX_CRITICAL_COOKIES, set)
|
||||||
|
assert len(YANDEX_CRITICAL_COOKIES) >= 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_critical_cookies_contains_key_names() -> None:
|
||||||
|
assert "Session_id" in YANDEX_CRITICAL_COOKIES
|
||||||
|
assert "yandexuid" in YANDEX_CRITICAL_COOKIES
|
||||||
|
assert "sessguard" in YANDEX_CRITICAL_COOKIES
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_marker_is_session_id() -> None:
|
||||||
|
assert AUTH_MARKER_COOKIE == "Session_id"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# filter_analytics_cookies — NOT an allowlist, only drops analytics prefixes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_analytics_drops_only_analytics_prefixes() -> None:
|
||||||
|
cookies = [
|
||||||
|
_cookie("Session_id"),
|
||||||
|
_cookie("_ym_uid"),
|
||||||
|
_cookie("_ym_d"),
|
||||||
|
_cookie("yabs-sid"),
|
||||||
|
_cookie("_yasc"),
|
||||||
|
_cookie("some_unknown_cookie_not_in_any_allowlist"),
|
||||||
|
]
|
||||||
|
result = filter_analytics_cookies(cookies)
|
||||||
|
names = {c["name"] for c in result}
|
||||||
|
assert names == {"Session_id", "some_unknown_cookie_not_in_any_allowlist"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_analytics_keeps_full_dump_minus_analytics() -> None:
|
||||||
|
"""Замер #3192 сделан на 44 cookies — фильтр НЕ должен сужать до allowlist."""
|
||||||
|
cookies = [_cookie(f"unknown_cookie_{i}") for i in range(40)] + [
|
||||||
|
_cookie("Session_id"),
|
||||||
|
_cookie("_ym_isad"),
|
||||||
|
_cookie("_yasc"),
|
||||||
|
_cookie("yabs-sid"),
|
||||||
|
]
|
||||||
|
result = filter_analytics_cookies(cookies)
|
||||||
|
assert len(result) == 41 # 40 unknown + Session_id, 3 analytics dropped
|
||||||
|
|
||||||
|
|
||||||
|
def test_analytics_prefixes_tuple() -> None:
|
||||||
|
assert ANALYTICS_COOKIE_PREFIXES == ("_ym_", "yabs-", "_yasc")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# has_auth_marker
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_has_auth_marker_true_when_session_id_present() -> None:
|
||||||
|
assert has_auth_marker([_cookie("Session_id"), _cookie("yandexuid")]) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_has_auth_marker_false_when_session_id_absent() -> None:
|
||||||
|
assert has_auth_marker([_cookie("yandexuid"), _cookie("_ym_uid")]) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_has_auth_marker_false_on_empty_list() -> None:
|
||||||
|
assert has_auth_marker([]) is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# save_session
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_session_calls_pgp_sym_encrypt(mock_db: MagicMock) -> None:
|
||||||
|
save_session(mock_db, account_label="test-account", cookies=[_cookie("Session_id")])
|
||||||
|
args, _ = mock_db.execute.call_args
|
||||||
|
sql_text = str(args[0])
|
||||||
|
assert "pgp_sym_encrypt" in sql_text
|
||||||
|
assert "yandex_session_cookies" in sql_text
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_session_uses_cast_not_colon_colon(mock_db: MagicMock) -> None:
|
||||||
|
"""Verify psycopg v3 compatibility — no bind-param immediately followed by ::type.
|
||||||
|
|
||||||
|
`)::interval` is legit (`::` glued to a closing paren, not to a bind-name — see
|
||||||
|
`.claude/rules/backend.md` CAST(:x AS type) exception), so we assert against the
|
||||||
|
actual forbidden pattern (`:name::type`) rather than a blanket "::" absence.
|
||||||
|
"""
|
||||||
|
save_session(mock_db, account_label="acc", cookies=[_cookie("Session_id")])
|
||||||
|
args, _ = mock_db.execute.call_args
|
||||||
|
sql_text = str(args[0])
|
||||||
|
assert "CAST(" in sql_text
|
||||||
|
assert re.search(r":[a-z_]+::[a-z]", sql_text) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_session_commits(mock_db: MagicMock) -> None:
|
||||||
|
save_session(mock_db, account_label="acc", cookies=[_cookie("Session_id")])
|
||||||
|
assert mock_db.commit.called
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_session_on_conflict_do_update(mock_db: MagicMock) -> None:
|
||||||
|
save_session(mock_db, account_label="acc", cookies=[_cookie("Session_id")])
|
||||||
|
args, _ = mock_db.execute.call_args
|
||||||
|
sql_text = str(args[0])
|
||||||
|
assert "ON CONFLICT" in sql_text
|
||||||
|
assert "DO UPDATE" in sql_text
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_session_passes_correct_params(mock_db: MagicMock) -> None:
|
||||||
|
cookies = [_cookie("Session_id", "abc123")]
|
||||||
|
save_session(mock_db, account_label="my-account", cookies=cookies, ttl_days=14)
|
||||||
|
call_args = mock_db.execute.call_args
|
||||||
|
params = call_args[0][1] if len(call_args[0]) > 1 else call_args[1].get("params", {})
|
||||||
|
assert params["label"] == "my-account"
|
||||||
|
assert params["ttl_days"] == 14
|
||||||
|
cookies_payload = json.loads(params["cookies_json"])
|
||||||
|
assert cookies_payload == cookies
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# load_session
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_session_returns_none_when_empty(mock_db: MagicMock) -> None:
|
||||||
|
result = load_session(mock_db)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_session_decodes_json(mock_db: MagicMock) -> None:
|
||||||
|
stored_cookies = [_cookie("Session_id", "abc"), _cookie("yandexuid", "def")]
|
||||||
|
mock_row = {
|
||||||
|
"account_label": "acc",
|
||||||
|
"cookies_json": json.dumps(stored_cookies),
|
||||||
|
"expires_at_estimate": None,
|
||||||
|
}
|
||||||
|
mock_db.execute.return_value.mappings.return_value.first.return_value = mock_row
|
||||||
|
result = load_session(mock_db)
|
||||||
|
assert result == stored_cookies
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_session_updates_last_used_at(mock_db: MagicMock) -> None:
|
||||||
|
mock_row = {
|
||||||
|
"account_label": "acc",
|
||||||
|
"cookies_json": json.dumps([_cookie("Session_id")]),
|
||||||
|
"expires_at_estimate": None,
|
||||||
|
}
|
||||||
|
mock_db.execute.return_value.mappings.return_value.first.return_value = mock_row
|
||||||
|
load_session(mock_db)
|
||||||
|
# Должно быть минимум 2 вызова execute: SELECT + UPDATE last_used_at
|
||||||
|
assert mock_db.execute.call_count >= 2
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# mark_session_invalid
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_mark_session_invalid_updates_table(mock_db: MagicMock) -> None:
|
||||||
|
mark_session_invalid(mock_db, account_label="acc")
|
||||||
|
args, _ = mock_db.execute.call_args
|
||||||
|
sql = str(args[0])
|
||||||
|
assert "last_invalid_at" in sql
|
||||||
|
assert "yandex_session_cookies" in sql
|
||||||
|
|
||||||
|
|
||||||
|
def test_mark_session_invalid_commits(mock_db: MagicMock) -> None:
|
||||||
|
mark_session_invalid(mock_db, account_label="acc")
|
||||||
|
assert mock_db.commit.called
|
||||||
Loading…
Add table
Reference in a new issue