"""Refresh token rotation HTTP scenarios (sqlite + real AuthService).""" from __future__ import annotations import asyncio import uuid from datetime import timedelta from unittest.mock import MagicMock import pytest from fastapi import FastAPI from httpx import ASGITransport, AsyncClient from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from app.core.config import settings from app.core.db import get_async_db, utc_now from app.core.dependencies import get_object_storage, get_sms_sender from app.features.auth import repo from app.features.auth.router import router as auth_router from tests.conftest import install_test_error_handlers from tests.support.auth_async_sqlite import seed_user_with_refresh_token @pytest.fixture async def refresh_http_app( auth_session_factory: async_sessionmaker[AsyncSession], ) -> FastAPI: async def _override_db(): async with auth_session_factory() as session: yield session app = install_test_error_handlers(FastAPI()) app.include_router(auth_router) app.dependency_overrides[get_async_db] = _override_db app.dependency_overrides[get_sms_sender] = lambda: MagicMock() app.dependency_overrides[get_object_storage] = lambda: MagicMock() return app @pytest.mark.asyncio async def test_refresh_rotates_token_http( refresh_http_app: FastAPI, auth_session_factory: async_sessionmaker[AsyncSession], ) -> None: async with auth_session_factory() as session: await seed_user_with_refresh_token(session, refresh_token="token-v1") transport = ASGITransport(app=refresh_http_app) async with AsyncClient(transport=transport, base_url="http://test") as client: response = await client.post( "/api/auth/refresh", json={"refresh_token": "token-v1"}, ) assert response.status_code == 200 body = response.json() assert body["access_token"] assert body["refresh_token"] != "token-v1" @pytest.mark.asyncio async def test_refresh_concurrent_same_token_within_grace( refresh_http_app: FastAPI, auth_session_factory: async_sessionmaker[AsyncSession], ) -> None: async with auth_session_factory() as session: await seed_user_with_refresh_token(session, refresh_token="token-concurrent") transport = ASGITransport(app=refresh_http_app) async with AsyncClient(transport=transport, base_url="http://test") as client: first, second = await asyncio.gather( client.post( "/api/auth/refresh", json={"refresh_token": "token-concurrent"}, ), client.post( "/api/auth/refresh", json={"refresh_token": "token-concurrent"}, ), ) assert first.status_code in (200, 401) assert second.status_code in (200, 401) assert first.status_code == 200 or second.status_code == 200 responses = [first, second] success = [r for r in responses if r.status_code == 200] assert len(success) >= 1 assert all( r.json().get("error_code") != "REFRESH_TOKEN_REUSE" for r in responses if r.status_code != 200 ) if len(success) == 2: assert success[0].json()["refresh_token"] == success[1].json()["refresh_token"] assert success[0].json()["refresh_token"] != "token-concurrent" @pytest.mark.asyncio async def test_refresh_sequential_reuse_within_grace( refresh_http_app: FastAPI, auth_session_factory: async_sessionmaker[AsyncSession], ) -> None: """Network retry: second call with old refresh token succeeds within grace.""" async with auth_session_factory() as session: await seed_user_with_refresh_token(session, refresh_token="token-retry") transport = ASGITransport(app=refresh_http_app) async with AsyncClient(transport=transport, base_url="http://test") as client: first = await client.post( "/api/auth/refresh", json={"refresh_token": "token-retry"}, ) second = await client.post( "/api/auth/refresh", json={"refresh_token": "token-retry"}, ) assert first.status_code == 200 assert second.status_code == 200 assert first.json()["refresh_token"] == second.json()["refresh_token"] assert first.json()["refresh_token"] != "token-retry" @pytest.mark.asyncio async def test_refresh_reuse_after_grace_returns_401( refresh_http_app: FastAPI, auth_session_factory: async_sessionmaker[AsyncSession], ) -> None: async with auth_session_factory() as session: await seed_user_with_refresh_token(session, refresh_token="token-grace") transport = ASGITransport(app=refresh_http_app) async with AsyncClient(transport=transport, base_url="http://test") as client: first = await client.post( "/api/auth/refresh", json={"refresh_token": "token-grace"}, ) assert first.status_code == 200 async with auth_session_factory() as session: old = await repo.get_refresh_token_by_token("token-grace", session) assert old is not None old.rotated_at = utc_now() - timedelta( seconds=settings.refresh_token_reuse_grace_seconds + 5 ) await session.commit() async with AsyncClient(transport=transport, base_url="http://test") as client: reuse = await client.post( "/api/auth/refresh", json={"refresh_token": "token-grace"}, ) assert reuse.status_code == 401 body = reuse.json() assert body["error_code"] == "REFRESH_TOKEN_REUSE" assert body["message"] assert "request_id" in body @pytest.mark.asyncio async def test_refresh_unknown_token_401( refresh_http_app: FastAPI, auth_session_factory: async_sessionmaker[AsyncSession], ) -> None: async with auth_session_factory() as session: await seed_user_with_refresh_token( session, refresh_token="unused", user_id=str(uuid.uuid4()) ) transport = ASGITransport(app=refresh_http_app) async with AsyncClient(transport=transport, base_url="http://test") as client: response = await client.post( "/api/auth/refresh", json={"refresh_token": "missing-token"}, ) assert response.status_code == 401 body = response.json() assert body["error_code"] == "AUTHENTICATION_FAILED" assert "request_id" in body