Files
meida_backend/tests/integration/test_auth_refresh.py
T
xsl 140a63effa feat(identity): refresh token rotation with server-side revocable sessions
id_session 落地:opaque refresh(token_urlsafe(48))+ sha256 哈希存储 +
一次性轮换(旧行撤销与新行同一事务)+ logout 幂等撤销 + 已撤销重放检测
告警(V1 不做家族连坐);错误码 40103/40104/40105 接入。新增 POST
/auth/refresh、POST /auth/logout;dev-token 扩展为双令牌,旧字段
access_token/token_type/expires_in/user_id 原名保留,新增
refresh_token/refresh_expires_in(smoke.sh 不受影响)。

测试:tests/unit/test_refresh_tokens.py 覆盖 token 长度/字符域/唯一性、
哈希稳定性与不可逆(7 例,不连网不连库);tests/integration/
test_auth_refresh.py 覆盖轮换后新 access 可调 /quota、旧 refresh 二次
使用 40105、logout 后失效 40105、伪造 40103、TTL=0 过期 40104(5 例)。
门禁全绿:ruff check/format、mypy 两段、lint-imports 4 契约、
单测 176 通过、集成 8 通过。

偏差说明:1) SessionIssued 较任务文档字段清单增加 user_id ——
/auth/refresh 换发 access JWT 必须知道用户,opaque refresh 本身不携带
信息,只能随签发结果带回;2) 过期分支「顺手落库 revoked=true」在 HTTP
路径会随 401 的统一回滚一起丢失(仅事务内标记,已注释说明,不影响判定
语义);3) repo 撤销用 SELECT FOR UPDATE + ORM 变更而非 UPDATE
rowcount —— 对齐 billing.repo 现行模式,且 rowcount 在 mypy 下类型
不可用。
2026-08-14 16:21:52 +08:00

146 lines
5.2 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()