merge: T4 刷新令牌与服务端可撤销会话(FR-ACC-05)

This commit is contained in:
xsl
2026-08-14 16:22:47 +08:00
7 changed files with 497 additions and 28 deletions
+145
View File
@@ -0,0 +1,145 @@
"""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()