2026-05-22 13:44:50 +08:00
|
|
|
"""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
|
2026-05-22 16:01:03 +08:00
|
|
|
from app.core.dependencies import get_object_storage, get_sms_sender
|
2026-05-22 13:44:50 +08:00
|
|
|
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()
|
2026-05-22 16:01:03 +08:00
|
|
|
app.dependency_overrides[get_object_storage] = lambda: MagicMock()
|
2026-05-22 13:44:50 +08:00
|
|
|
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
|