Newer
Older
gnexus-tasks / backend / tests / test_auth.py
"""Smoke-тесты M0: health, защита API, redirect OAuth-флоу."""

from typing import Any

from fastapi.testclient import TestClient

from app.config import get_settings
from app.main import app

client = TestClient(app)


def test_health() -> None:
    assert client.get("/api/health").json() == {"status": "ok"}


def test_auth_me_requires_auth() -> None:
    assert client.get("/auth/me").status_code == 401


def test_logout_redirects_to_login_screen() -> None:
    """Выход не оставляет пользователя на JSON {"ok": true}: редирект на
    экран входа SPA (ТЗ 3.17), там единственная кнопка «Войти»."""
    resp = client.get("/auth/logout", follow_redirects=False)
    assert resp.status_code in (302, 307)
    assert resp.headers["location"] == "/login"


def test_login_redirects_to_sso() -> None:
    base_url = get_settings().gauth_base_url
    resp = client.get("/auth/login", follow_redirects=False)
    assert resp.status_code in (302, 307)
    location = resp.headers["location"]
    assert location.startswith(base_url)
    assert "state=" in location and "code_challenge=" in location


def test_safe_return_to() -> None:
    """Open redirect: наружный return_to превращается в корень приложения."""
    from app.auth.routes import _safe_return_to

    assert _safe_return_to("/tasks/5") == "/tasks/5"
    assert _safe_return_to("https://evil.com") == "/"
    assert _safe_return_to("//evil.com") == "/"
    assert _safe_return_to("") == "/"


def test_callback_upserts_user_and_claims_orphans(client: TestClient, monkeypatch: Any) -> None:
    """Регрессия 500 на первом логине: upsert_user держал INSERT pending,
    а claim_orphan_data (Core UPDATE) не триггерит autoflush — FK
    projects.user_id → users падала на 'Key (user_id)=(x) is not present'."""
    from types import SimpleNamespace

    from sqlalchemy import select

    from app.auth import routes as auth_routes
    from app.models import Project, User
    from tests.conftest import _test_session_factory

    setup = _test_session_factory()
    try:
        setup.add(Project(name="Наследие"))  # user_id NULL — данные прежней эпохи
        setup.commit()
    finally:
        setup.close()

    token_set = SimpleNamespace(access_token="at", refresh_token="rt")
    sso_user = SimpleNamespace(
        user_id="9",
        email="nine@example.com",
        avatar_url=None,
        profile={"locale": "ru", "display_name": "Девятый"},
    )
    stub = SimpleNamespace(
        exchange_authorization_code=lambda code, state: token_set,
        fetch_user=lambda token: sso_user,
    )
    monkeypatch.setattr(auth_routes, "get_gauth_client", lambda: stub)

    resp = client.get("/auth/callback", params={"code": "c", "state": "s"}, follow_redirects=False)
    assert resp.status_code in (302, 307)
    assert resp.headers["location"] == "/"

    check = _test_session_factory()
    try:
        user9 = check.get(User, "9")
        assert user9 is not None
        assert user9.name == "Девятый"
        assert check.scalars(select(Project).where(Project.user_id.is_(None))).all() == []
        claimed = check.scalars(select(Project).where(Project.user_id == "9")).all()
        assert [p.name for p in claimed] == ["Наследие"]
    finally:
        check.close()


def test_me_serves_fresh_profile_from_db(client: TestClient, monkeypatch: Any) -> None:
    """/auth/me читает профиль из БД: изменения от webhook SSO видны без релогина."""
    from types import SimpleNamespace

    from app.auth import routes as auth_routes
    from app.models import User
    from tests.conftest import _test_session_factory

    token_set = SimpleNamespace(access_token="at", refresh_token="rt")
    sso_user = SimpleNamespace(
        user_id="7",
        email="seven@example.com",
        avatar_url=None,
        profile={"locale": "ru", "display_name": "Седьмой"},
    )
    stub = SimpleNamespace(
        exchange_authorization_code=lambda code, state: token_set,
        fetch_user=lambda token: sso_user,
    )
    monkeypatch.setattr(auth_routes, "get_gauth_client", lambda: stub)
    assert client.get(
        "/auth/callback", params={"code": "c", "state": "s"}, follow_redirects=False
    ).status_code in (302, 307)

    # webhook меняет профиль в БД напрямую (сессия-кука остаётся старой)
    session = _test_session_factory()
    try:
        user = session.get(User, "7")
        user.name = "Новое Имя"
        user.avatar_url = "https://sso/new.jpg"
        session.commit()
    finally:
        session.close()

    me = client.get("/auth/me").json()["user"]
    assert me["name"] == "Новое Имя"
    assert me["avatar_url"] == "https://sso/new.jpg"
    assert me["email"] == "seven@example.com"


def test_default_session_secret_rejected() -> None:
    """Забытый SESSION_SECRET не должен поднимать приложение молча."""
    import pytest as _pytest

    from app.config import Settings

    with _pytest.raises(ValueError, match="SESSION_SECRET"):
        Settings(_env_file=None, session_secret="change-me-session-secret")


def test_api_requires_auth() -> None:
    """Регрессия: все /api эндпоинты должны требовать сессию."""
    from app.dependencies import require_user

    saved = app.dependency_overrides.pop(require_user, None)
    try:
        # Без `with`: lifespan запускает MCP session manager — только один раз
        # на инстанс, см. test_mcp.py::test_mcp_requires_bearer_token.
        c = TestClient(app)
        assert c.get("/api/tasks").status_code == 401
        assert c.get("/api/projects").status_code == 401
        assert c.get("/api/tags").status_code == 401
        assert c.post("/api/tasks", json={"title": "x"}).status_code == 401
        assert c.delete("/api/tags/1").status_code == 401
    finally:
        if saved is not None:
            app.dependency_overrides[require_user] = saved


def test_every_api_route_requires_user() -> None:
    """Каждый /api-роут обязан иметь require_user в зависимостях: один новый
    эндпоинт без UserDep (как было с DELETE /api/tags) не должен проходить мимо."""
    from fastapi.routing import APIRoute

    from app.dependencies import require_user

    bare = []
    for route in app.routes:
        if not isinstance(route, APIRoute):
            continue
        if not route.path.startswith("/api/") or route.path == "/api/health":
            continue
        deps = [d.call for d in route.dependant.dependencies]
        if require_user not in deps:
            bare.append(route.path)
    assert bare == [], f"роуты без авторизации: {bare}"