"""Refresh token 链路(FR-ACC-05)—— 真实 PostgreSQL,auth 用例不涉及 LLM。 与 test_vertical_slice.py 不同:这里不替换 LLM provider(没人会调到它), 只验证 dev-token → refresh 轮换 → logout 撤销的会话生命周期。 """ from __future__ import annotations import os import uuid from collections.abc import AsyncIterator from typing import Any import httpx import pytest def _infra_available() -> bool: return bool(os.getenv("MEIDA_RUN_INTEGRATION")) pytestmark = [ pytest.mark.integration, pytest.mark.skipif( not _infra_available(), reason="set MEIDA_RUN_INTEGRATION=1 with PostgreSQL and Redis running", ), ] @pytest.fixture async def client() -> AsyncIterator[httpx.AsyncClient]: from apps.api.main import create_app from packages.shared.config import get_settings app = create_app(get_settings()) transport = httpx.ASGITransport(app=app) async with ( httpx.AsyncClient(transport=transport, base_url="http://test") as http, app.router.lifespan_context(app), ): yield http async def _dev_token(client: httpx.AsyncClient) -> dict[str, Any]: """每个用例用独立 device_key,避免共享用户状态互相污染。""" resp = await client.post( "/v1/auth/dev-token", json={"device_key": f"t4-{uuid.uuid4().hex[:8]}"} ) assert resp.status_code == 200 body = resp.json() assert body["code"] == 0 data = body["data"] # 兼容性:旧字段名一字不改,新字段在场 assert data["access_token"] assert data["expires_in"] > 0 assert data["user_id"] assert data["refresh_token"] return data async def test_refresh_rotates_and_new_access_calls_quota(client: httpx.AsyncClient) -> None: pair = await _dev_token(client) resp = await client.post("/v1/auth/refresh", json={"refresh_token": pair["refresh_token"]}) assert resp.status_code == 200 assert resp.json()["code"] == 0 data = resp.json()["data"] # 轮换的核心契约是 refresh 换新(随机串必不同)。access JWT 不比不等: # 同一用户同一秒签发时 claims 完全一致,token 字节级相同是正常现象, # 断言不等只会让用例变成秒边界的掷硬币。 assert data["refresh_token"] != pair["refresh_token"] assert data["refresh_expires_in"] > 0 quota = await client.get( "/v1/quota", headers={"Authorization": f"Bearer {data['access_token']}"} ) assert quota.status_code == 200 assert quota.json()["code"] == 0 async def test_old_refresh_is_single_use(client: httpx.AsyncClient) -> None: pair = await _dev_token(client) first = await client.post("/v1/auth/refresh", json={"refresh_token": pair["refresh_token"]}) assert first.status_code == 200 second = await client.post("/v1/auth/refresh", json={"refresh_token": pair["refresh_token"]}) assert second.status_code == 401 assert second.json()["code"] == 40105 async def test_logout_revokes_the_session(client: httpx.AsyncClient) -> None: pair = await _dev_token(client) headers = {"Authorization": f"Bearer {pair['access_token']}"} out = await client.post( "/v1/auth/logout", json={"refresh_token": pair["refresh_token"]}, headers=headers ) assert out.status_code == 200 assert out.json()["data"] == {"revoked": True} # 幂等登出:同一 token 再登出一次不报错,revoked=false again = await client.post( "/v1/auth/logout", json={"refresh_token": pair["refresh_token"]}, headers=headers ) assert again.status_code == 200 assert again.json()["data"] == {"revoked": False} refreshed = await client.post("/v1/auth/refresh", json={"refresh_token": pair["refresh_token"]}) assert refreshed.status_code == 401 assert refreshed.json()["code"] == 40105 async def test_forged_refresh_token_is_rejected(client: httpx.AsyncClient) -> None: resp = await client.post("/v1/auth/refresh", json={"refresh_token": "forged-" + "x" * 32}) assert resp.status_code == 401 assert resp.json()["code"] == 40103 async def test_expired_refresh_token_reports_40104() -> None: """TTL=0 注入过期路径:签发即过期,refresh 必须走 40104 而非 40103/40105。""" from apps.api.main import create_app from packages.shared.config import get_settings with pytest.MonkeyPatch.context() as mp: mp.setenv("REFRESH_TOKEN_TTL_SECONDS", "0") get_settings.cache_clear() app = create_app(get_settings()) try: transport = httpx.ASGITransport(app=app) async with ( httpx.AsyncClient(transport=transport, base_url="http://test") as http, app.router.lifespan_context(app), ): pair = await _dev_token(http) assert pair["refresh_expires_in"] == 0 resp = await http.post( "/v1/auth/refresh", json={"refresh_token": pair["refresh_token"]} ) assert resp.status_code == 401 assert resp.json()["code"] == 40104 finally: # 恢复进程级配置缓存:打补丁的 Settings 不能泄漏给后续用例 get_settings.cache_clear()