Files
life-echo/api/tests/test_auth_refresh_http.py
Sully 53e0065e3e refactor(api): TOML 配置 SSOT、统一错误契约、Auth/事务加固与可观测性 (#33)
配置 SSOT(TOML + .env)
统一错误契约
Auth 与事务边界
Redis / Celery 可靠性:业务 Redis(DB/0)与 Celery broker/backend(DB/1)显式拆分;连接池、sync client
可观测性(OpenTelemetry + LGTM)
2026-05-22 13:44:50 +08:00

181 lines
6.2 KiB
Python

"""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_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()
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