import uuid from datetime import UTC, datetime, timedelta import asyncio import importlib from fastapi.testclient import TestClient import pytest from sqlalchemy import text from sqlalchemy.ext.asyncio import create_async_engine from src.auth.jwt_service import mint_access_token from src.config import Settings, build_database_url from src.models import Base from src.models.user import User @pytest.fixture(autouse=True) def configure_local_database(monkeypatch) -> None: local_url = build_database_url( user="headquarter", password="headquarter", host="localhost", port=5432, database="headquarter", ) monkeypatch.setenv("DATABASE_URL", local_url) def _prepare_auth_test_db() -> None: async def _run() -> None: engine = create_async_engine( build_database_url( user="headquarter", password="headquarter", host="localhost", port=5432, database="headquarter", ) ) async with engine.begin() as connection: await connection.run_sync(Base.metadata.create_all) await connection.execute(text("TRUNCATE TABLE refresh_tokens, users RESTART IDENTITY CASCADE")) await engine.dispose() asyncio.run(_run()) def _load_app(): import src.database as database_module import src.api.auth as auth_module import src.main as main_module importlib.reload(database_module) importlib.reload(auth_module) importlib.reload(main_module) return main_module.app def _insert_user_for_refresh(user_id: str) -> None: async def _run() -> None: engine = create_async_engine( build_database_url( user="headquarter", password="headquarter", host="localhost", port=5432, database="headquarter", ) ) async with engine.begin() as connection: await connection.run_sync(Base.metadata.create_all) from sqlalchemy.ext.asyncio import async_sessionmaker session_factory = async_sessionmaker(engine, expire_on_commit=False) async with session_factory() as session: user = User( id=uuid.UUID(user_id), email="refresh@headquarter.local", name="Refresh User", authentik_id="refresh-user", avatar_url=None, ) await session.merge(user) await session.commit() await engine.dispose() asyncio.run(_run()) def test_login_redirects_to_authentik_authorize_endpoint() -> None: _prepare_auth_test_db() app = _load_app() client = TestClient(app) response = client.get("/auth/login", follow_redirects=False) assert response.status_code == 307 assert "response_type=code" in response.headers["location"] def test_me_returns_401_without_access_cookie() -> None: _prepare_auth_test_db() app = _load_app() client = TestClient(app) response = client.get("/auth/me") assert response.status_code == 401 def test_me_returns_user_payload_with_valid_access_cookie() -> None: _prepare_auth_test_db() app = _load_app() settings = Settings() token = mint_access_token( settings=settings, subject=str(uuid.uuid4()), email="dev@headquarter.local", name="Dev User", expires_at=datetime.now(UTC) + timedelta(minutes=15), ) client = TestClient(app) client.cookies.set("access_token", token) response = client.get("/auth/me") assert response.status_code == 200 assert response.json()["email"] == "dev@headquarter.local" def test_logout_clears_auth_cookies() -> None: _prepare_auth_test_db() app = _load_app() client = TestClient(app) client.cookies.set("refresh_token", "opaque-token") response = client.post("/auth/logout") assert response.status_code == 200 assert "access_token=" in response.headers.get("set-cookie", "") def test_callback_rejects_mismatched_state() -> None: _prepare_auth_test_db() app = _load_app() client = TestClient(app) client.cookies.set("auth_state", "expected") response = client.get("/auth/callback?code=test-code&state=wrong") assert response.status_code == 401 def test_callback_sets_auth_cookies_after_success(monkeypatch) -> None: _prepare_auth_test_db() app = _load_app() async def fake_exchange_code_for_tokens(*, settings, code, redirect_uri, client): return {"access_token": "provider-access", "refresh_token": "provider-refresh"} def fake_verify_provider_access_token(*, settings, token, jwks): return {"sub": "auth-sub-1", "email": "callback@headquarter.local", "name": "Callback User"} async def fake_fetch_jwks(*, settings, client): return {"keys": []} monkeypatch.setattr("src.api.auth.exchange_code_for_tokens", fake_exchange_code_for_tokens) monkeypatch.setattr("src.api.auth.verify_provider_access_token", fake_verify_provider_access_token) monkeypatch.setattr("src.api.auth.fetch_jwks", fake_fetch_jwks) client = TestClient(app) client.cookies.set("auth_state", "good-state") response = client.get("/auth/callback?code=valid-code&state=good-state") assert response.status_code == 200 assert response.json()["email"] == "callback@headquarter.local" set_cookie_header = response.headers.get("set-cookie", "") assert "access_token=" in set_cookie_header assert "refresh_token=" in set_cookie_header def test_refresh_rotates_cookie_and_returns_user_payload(monkeypatch) -> None: _prepare_auth_test_db() _insert_user_for_refresh("7f4b7ad8-c4ce-4d1b-8c83-7ce0f4f66dfb") app = _load_app() async def fake_rotate_refresh_token(*, session, raw_token, user_agent, ip_address): class StoredToken: user_id = uuid.UUID("7f4b7ad8-c4ce-4d1b-8c83-7ce0f4f66dfb") return "new-refresh-token", StoredToken() monkeypatch.setattr("src.api.auth.rotate_refresh_token", fake_rotate_refresh_token) client = TestClient(app) client.cookies.set("refresh_token", "old-refresh-token") response = client.post("/auth/refresh") assert response.status_code == 200 assert response.json()["sub"] == "7f4b7ad8-c4ce-4d1b-8c83-7ce0f4f66dfb" assert "refresh_token=" in response.headers.get("set-cookie", "") def test_refresh_returns_401_for_invalid_refresh_token(monkeypatch) -> None: _prepare_auth_test_db() app = _load_app() async def fake_rotate_refresh_token(*, session, raw_token, user_agent, ip_address): raise ValueError("refresh token not found") monkeypatch.setattr("src.api.auth.rotate_refresh_token", fake_rotate_refresh_token) client = TestClient(app) client.cookies.set("refresh_token", "invalid") response = client.post("/auth/refresh") assert response.status_code == 401