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 下类型 不可用。
146 lines
5.2 KiB
Python
Executable File
146 lines
5.2 KiB
Python
Executable File
"""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()
|