gendesign/backend/tests/test_auth_session_service.py
bot-backend 7be07efe70
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m59s
CI / backend-tests (pull_request) Successful in 15m13s
feat(sitefinder): «Птица» принимает сессию общего реестра [PR-2c/6]
Дефолт AUTH_MODE=legacy — сегодняшнее поведение байт-в-байт: соединение с БД
auth не открывается, кука не читается, отсутствие настроек не роняет старт.
Popup Caddy стоит и снимается последним PR эпика — инвариант «гейт уходит
последним» не нарушен.

У Site Finder не было авторизации вообще: rbac_guard доверял заголовку
X-Authenticated-User от Caddy. Теперь он умеет резолвить сессионную куку
общего реестра. ВЫДАВАТЬ сессии «Птица» не будет — логин один, у «Меры», а
кука host-only на gendsgn.ru с path=/ и так долетает до обоих продуктов.
Меньше кода и меньше мест, где можно ошибиться.

Режим трёхзначный, а не булев: legacy | dual | db_only. Это прямое следствие
ревью. При булевом флаге фолбэк «сессия не нашлась → верим заголовку» после
снятия popup превращался бы в полный обход аутентификации, и ничто в коде не
заставило бы про него вспомнить. В db_only легаси-ветка недостижима: 401.

Резолв уехал в threadpool. Три независимых ревьюера нашли одно и то же:
sync-запрос к БД в async-guard блокирует event loop на каждом non-public
запросе — ровно инцидент #1202, который в этом же файле уже лечили. Кука
разбирается на loop'е, в поток уезжает только строка токена; запрос без куки
не платит ни за поток, ни за коннект.

Срок годности сессии считают часы БД, а не приложения. Раньше проверка шла в
Python, а sliding-refresh переписывал строку через now() базы — при
расхождении часов истёкшая сессия не просто проходила, а продлевалась заново,
то есть воскресала навсегда. Теперь `expires_at > now()` в самом SELECT;
питоновская проверка оставлена вторым поясом.

Fail-fast на старте проверяет не синтаксис DSN, а живое соединение: SELECT 1.
Иначе неверный пароль или хост выглядели бы как «ни у кого нет сессии» —
сутками, потому что ошибку ловил бы except в guard'е.

Ещё из ревью: connect_timeout и statement_timeout по 3с (недоступный хост
вешал коннект на минуты); throttling логов сбоя реестра (иначе шторм в
GlitchTip на каждый запрос); тела SQL закреплены ассертами формы — мутация
любого фрагмента теперь красит тесты, до этого не красила ничего.

Дефолт хоста БД — postgres, и это зеркально «Мере». У неё gendesign-postgres,
потому что внутри её стека `postgres` — чужой контейнер; здесь стек главный, и
`postgres` из корневого compose и есть нужный сервер. Алиас gendesign-postgres
дефолтом был бы багом: контейнер beat состоит только в сети default и это имя
из него не разрезолвится.

Сверка реестра с ролевой картой сделана на живом проде: все 13 юзеров
auth.users присутствуют в auth/roles.yaml, ни один не получит 403 на всё.
Четыре QA-фикстуры (admintest, pilottest, analysttest, expiredtest) есть в
yaml, но не в реестре — после снятия popup войти ими через браузер будет
нельзя, только внутрисетевым заголовком.

Осознанный долг, вписан ⚠️-блоком перед guard'ом: paths/deny из roles.yaml
бэкендом не применяются (их энфорсит фронтовый RouteGuard), guard проверяет
только известность username и admin-пути. Это предсуществующее поведение;
менять его здесь значило бы изменить и легаси-ветку, то есть нарушить
«дефолт = сегодня».

Тесты: 4594 passed, 0 failed. Главный — подделка: валидная кука плюс
присланный клиентом X-Authenticated-User другого пользователя, выигрывает
кука, и роут, читающий заголовок напрямую, видит владельца куки. У «Птицы»
таких прямых читателей одиннадцать, поэтому перезапись ASGI-scope обязана быть
полной, а не skip-if-present (CRITICAL #2552).
2026-08-02 17:00:54 +03:00

305 lines
14 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.

"""Резолв сессии общего реестра — `app/services/auth_session.py` (эпик «единый вход»).
Слой ниже guard'а: «что считать валидной сессией» и «когда продлевать». Через guard
эти правила проверяются end-to-end в `tests/test_auth_session_guard.py`; здесь —
поштучно, включая ветки, до которых из guard'а дотянуться дорого (sliding refresh,
сбой продления, исключения БД).
Дублёр сессии БД (`FakeAuthDb`) намеренно ОДИН на оба файла и живёт в guard-тестах:
разъехавшиеся двойники — типовой способ получить два зелёных теста при одном сломанном
поведении. Прецедент кросс-импорта внутри пакета tests — `tests/integration/*`.
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime, timedelta
import pytest
from pydantic import SecretStr
from app.core import auth_db
from app.core.auth_db import AuthDatabaseNotConfiguredError
from app.core.config import settings
from app.services import auth_session as svc
from app.services.auth_session import AccessState, SessionUser, get_session_user, to_access_state
from tests.test_auth_session_guard import FakeAuthDb, _install_auth_db, _Row
_TOKEN = "tok-1"
_USER = "user1"
def _row(
*,
expires_in: timedelta = timedelta(days=7),
last_seen_ago: timedelta | None = timedelta(seconds=30),
username: str = _USER,
access_state: str = "active",
) -> _Row:
now = datetime.now(UTC)
return _Row(
expires_at=now + expires_in,
last_seen_at=None if last_seen_ago is None else now - last_seen_ago,
username=username,
access_state=access_state,
)
# ---------------------------------------------------------------------------
# AccessState / to_access_state — fail-closed
# ---------------------------------------------------------------------------
def test_only_active_can_sign_in() -> None:
assert AccessState.ACTIVE.can_sign_in is True
assert AccessState.TRIAL_EXPIRED.can_sign_in is False
assert AccessState.DISABLED.can_sign_in is False
def test_to_access_state_known_values() -> None:
assert to_access_state("active") is AccessState.ACTIVE
assert to_access_state("trial_expired") is AccessState.TRIAL_EXPIRED
assert to_access_state("disabled") is AccessState.DISABLED
@pytest.mark.parametrize("value", ["frozen", "", None, 42])
def test_to_access_state_unknown_is_disabled_with_warning(
value: object, caplog: pytest.LogCaptureFixture
) -> None:
"""Неизвестное/NULL/чужой тип → disabled + WARNING.
Миграции БД `auth` применяет деплой «Птицы», то есть новое состояние может
появиться в базе раньше, чем код о нём узнает. Обратный выбор («не disabled =
пускаем») означал бы, что такая миграция молча раздаёт доступ.
"""
with caplog.at_level(logging.WARNING, logger="app.services.auth_session"):
assert to_access_state(value) is AccessState.DISABLED
assert caplog.records
# ---------------------------------------------------------------------------
# get_session_user — что считается валидной сессией
# ---------------------------------------------------------------------------
def test_valid_session_resolves_to_user() -> None:
db = FakeAuthDb({_TOKEN: _row()})
assert get_session_user(db, _TOKEN) == SessionUser(
username=_USER, access_state=AccessState.ACTIVE
)
assert db.select_tokens == [_TOKEN]
def test_empty_token_short_circuits_without_query() -> None:
db = FakeAuthDb({_TOKEN: _row()})
assert get_session_user(db, "") is None
assert db.select_tokens == []
def test_unknown_token_returns_none() -> None:
db = FakeAuthDb()
assert get_session_user(db, "never-issued") is None
assert db.updates == []
def test_expired_session_returns_none_and_is_not_refreshed() -> None:
"""Истёкшая сессия не воскресает sliding-refresh'ем — иначе TTL был бы вечным."""
db = FakeAuthDb(
{_TOKEN: _row(expires_in=timedelta(seconds=-1), last_seen_ago=timedelta(days=1))}
)
assert get_session_user(db, _TOKEN) is None
assert db.updates == []
def test_null_expires_at_returns_none() -> None:
"""`expires_at IS NULL` (колонку ослабили) → сессии нет, а не TypeError в auth-пути."""
row = _row()
row.expires_at = None
db = FakeAuthDb({_TOKEN: row})
assert get_session_user(db, _TOKEN) is None
@pytest.mark.parametrize("access_state", ["disabled", "trial_expired", "unheard_of"])
def test_non_active_user_returns_none_immediately(access_state: str) -> None:
"""Блокировка в реестре бьёт сразу, не дожидаясь expires_at (иначе до 30 дней)."""
db = FakeAuthDb({_TOKEN: _row(access_state=access_state)})
assert get_session_user(db, _TOKEN) is None
assert db.updates == []
# ---------------------------------------------------------------------------
# Sliding refresh
# ---------------------------------------------------------------------------
def test_refresh_skipped_for_recent_session() -> None:
"""Свежий last_seen_at → ни одного UPDATE: иначе каждый API-запрос бил бы в БД."""
db = FakeAuthDb({_TOKEN: _row(last_seen_ago=timedelta(seconds=30))})
assert get_session_user(db, _TOKEN) is not None
assert db.updates == []
assert db.commits == 0
@pytest.mark.parametrize(
"last_seen_ago", [timedelta(minutes=5), timedelta(hours=3), None], ids=["at-5m", "3h", "null"]
)
def test_refresh_extends_after_interval(last_seen_ago: timedelta | None) -> None:
""">= 5 минут (и NULL) → один UPDATE на обе колонки + commit.
TTL берётся из настроек и обязан совпадать с «Мерой»: продлевает сессию тот
продукт, в котором кликнули последним, и срок жизни не должен от этого зависеть.
"""
db = FakeAuthDb({_TOKEN: _row(last_seen_ago=last_seen_ago)})
assert get_session_user(db, _TOKEN) is not None
assert db.updates == [{"ttl_hours": settings.session_ttl_hours, "token": _TOKEN}]
assert db.commits == 1
def test_refresh_uses_configured_ttl(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "session_ttl_hours", 12)
db = FakeAuthDb({_TOKEN: _row(last_seen_ago=timedelta(hours=1))})
get_session_user(db, _TOKEN)
assert db.updates == [{"ttl_hours": 12, "token": _TOKEN}]
def test_refresh_failure_does_not_block_valid_session(caplog: pytest.LogCaptureFixture) -> None:
"""Продление — best-effort: сбой логируется и откатывается, юзер всё равно валиден.
Иначе read-only реплика или блокировка строки разлогинивала бы всех, у кого
сессия старше пяти минут.
"""
db = FakeAuthDb({_TOKEN: _row(last_seen_ago=timedelta(hours=1))}, fail_refresh=True)
with caplog.at_level(logging.WARNING, logger="app.services.auth_session"):
user = get_session_user(db, _TOKEN)
assert user == SessionUser(username=_USER, access_state=AccessState.ACTIVE)
assert db.rollbacks == 1
assert any("sliding refresh failed" in r.getMessage() for r in caplog.records)
# В сообщении не должно быть ни username, ни токена: лог — не место для связки
# «кто именно» + «когда», а разбор идёт по времени.
assert not any(_USER in r.getMessage() or _TOKEN in r.getMessage() for r in caplog.records)
def test_select_failure_is_not_swallowed() -> None:
"""Сбой SELECT'а летит наружу: решение «что делать со сломанным реестром» — не здесь.
Проглоти резолвер ошибку — вызывающий получил бы «сессии нет», то есть отказ
выглядел бы как «просто не залогинен», а откат на trusted-header — как норма.
"""
db = FakeAuthDb()
def _boom(*_a: object, **_k: object) -> None:
raise RuntimeError("auth registry is down")
db.execute = _boom # type: ignore[method-assign]
with pytest.raises(RuntimeError, match="auth registry is down"):
get_session_user(db, _TOKEN)
# ---------------------------------------------------------------------------
# resolve_session_token — точка входа guard'а
# ---------------------------------------------------------------------------
def test_resolve_returns_none_without_touching_db_when_flag_off(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""🔒 Инвариант «выключенный флаг = ни одного коннекта» держится этим модулем.
Он не полагается на то, что вызывающий сам не позовёт резолв: даже с валидным
токеном соединение не открывается.
"""
monkeypatch.setattr(settings, "auth_mode", "legacy")
_install_auth_db(monkeypatch, None) # открытие сессии → RuntimeError
assert svc.resolve_session_token(_TOKEN) is None
@pytest.mark.parametrize("token", [None, ""])
def test_resolve_returns_none_for_empty_token(
monkeypatch: pytest.MonkeyPatch, token: str | None
) -> None:
monkeypatch.setattr(settings, "auth_mode", "dual")
_install_auth_db(monkeypatch, None)
assert svc.resolve_session_token(token) is None
def test_resolve_opens_registry_session_when_flag_on(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "auth_mode", "dual")
db = FakeAuthDb({_TOKEN: _row()})
_install_auth_db(monkeypatch, db)
assert svc.resolve_session_token(_TOKEN) == SessionUser(
username=_USER, access_state=AccessState.ACTIVE
)
assert db.select_tokens == [_TOKEN]
def test_resolve_propagates_not_configured_error(monkeypatch: pytest.MonkeyPatch) -> None:
"""Флаг включён, DSN пуст → исключение наружу, а не «сессия не найдена».
Тихий None здесь означал бы либо массовый отказ доступа под видом «не залогинен»,
либо (в guard'е) бессрочную раздачу прав в обход реестра. Настоящий `auth_db` не
подменяется — проверяется именно связка сервис ↔ конфигурация.
"""
monkeypatch.setattr(settings, "auth_mode", "dual")
monkeypatch.setattr(settings, "auth_database_url", "")
monkeypatch.setattr(settings, "auth_db_password", SecretStr(""))
auth_db.reset_auth_db()
try:
with pytest.raises(AuthDatabaseNotConfiguredError):
svc.resolve_session_token(_TOKEN)
finally:
auth_db.reset_auth_db()
def test_expiry_is_also_filtered_by_db_clock() -> None:
"""Срок годности отсекается ЧАСАМИ БД, а не только часами процесса.
Асимметрия, которую это закрывает: решение «жива ли сессия» принимал Python
(`datetime.now(UTC)`), а продление писало `expires_at = now() + interval` часами
СЕРВЕРА. Отставание часов приложения давало бы не «сессия проживёт на дельту
дольше», а необратимое воскрешение: строку, которую БД уже считает мёртвой, Python
пропускал бы, тут же срабатывал sliding-refresh и отодвигал expires_at на полный
TTL от серверного now(). Секунда расхождения → +30 дней жизни.
Форма запроса проверяется дублёром (`_assert_select_shape`), поэтому здесь
достаточно одного прохода: потеряется `AND s.expires_at > now()` — тест покраснеет.
"""
db = FakeAuthDb({_TOKEN: _row()})
assert get_session_user(db, _TOKEN) is not None
def test_rollback_failure_does_not_break_the_resolve(caplog: pytest.LogCaptureFixture) -> None:
"""Сбой самого rollback'а (оборванный коннект) не отменяет валидную сессию.
Иначе «best-effort продление» переставало быть best-effort: исключение улетало бы
из get_session_user наружу, и валидный юзер получал бы вместо доступа ERROR в
GlitchTip и деградацию на легаси-заголовок (а в db_only — отказ).
"""
db = FakeAuthDb({_TOKEN: _row(last_seen_ago=timedelta(hours=1))}, fail_refresh=True)
def _dead_connection() -> None:
raise RuntimeError("server closed the connection unexpectedly")
db.rollback = _dead_connection # type: ignore[method-assign]
with caplog.at_level(logging.WARNING, logger="app.services.auth_session"):
user = get_session_user(db, _TOKEN)
assert user == SessionUser(username=_USER, access_state=AccessState.ACTIVE)
assert any("rollback" in r.getMessage() for r in caplog.records)