feat: 新增用户管理(用户增删改查、密码重置、角色权限、会话认证)与 API 指南

- 新增 app/api/deps.py、app/core/users.py、app/core/sessions.py:会话鉴权依赖、
  用户存储(PBKDF2-HMAC-SHA256 + 随机 salt,Redis/内存降级)、会话签发与校验(TTL 12h)
- auth.py 新增用户管理端点(列表/创建/重置密码/删除)与 admin/user 角色权限边界,
  user 访问用户管理返回 1006,禁删自己与最后一个 admin
- admin.html 新增用户管理面板(仅 admin 挂载)与 API 指南在线测试台
- Dockerfile 将 uv 放入 PATH;docker-compose 调整 qdrant 依赖为 service_started
  并移除依赖 curl 的 healthcheck(官方镜像不含 curl)
- 新增用户管理测试(users/sessions/auth_api/auth_integration),全量 461 项测试通过

Co-Authored-By: WorkBuddy <workbuddy@tencent.com>
This commit is contained in:
2026-07-31 21:29:02 +08:00
parent 2ab8b56a01
commit 92b062c048
24 changed files with 2771 additions and 350 deletions
+171
View File
@@ -0,0 +1,171 @@
"""SessionStore 单元测试(FakeRedis 与内存降级,不连真实 Redis)
覆盖:
- create/get/delete 生命周期;伪造 token 返回 None
- TTL=43200 传入 Redissetex
- delete_by_username 清除指定用户全部会话
- 内存降级(构造传 None)全功能可用 + 过期惰性清理
- Redis 异常包装为 SessionStoreError
"""
import fnmatch
import time
import pytest
from app.core.sessions import SessionStore, SessionStoreError
class FakeRedis:
"""最小内存版 redis.asyncio 客户端:get/set/setex/delete/scan_iter/keys + TTL 记录
fail=True 时所有操作抛 ConnectionError,用于验证异常包装语义。
"""
def __init__(self, fail: bool = False) -> None:
self.fail = fail
self.store: dict[str, str] = {}
self.ttls: dict[str, int] = {}
def _check(self) -> None:
if self.fail:
raise ConnectionError("redis down")
async def get(self, key: str) -> str | None:
self._check()
return self.store.get(key)
async def set(self, key: str, value: str) -> bool:
self._check()
self.store[key] = value
return True
async def setex(self, key: str, ttl: int, value: str) -> bool:
self._check()
self.store[key] = value
self.ttls[key] = ttl
return True
async def delete(self, *keys: str) -> int:
self._check()
deleted = 0
for key in keys:
if key in self.store:
del self.store[key]
deleted += 1
return deleted
async def scan_iter(self, match: str = "*"):
self._check()
for key in list(self.store):
if fnmatch.fnmatch(key, match):
yield key
async def keys(self, pattern: str = "*") -> list[str]:
self._check()
return [key for key in self.store if fnmatch.fnmatch(key, pattern)]
class TestLifecycle:
"""会话签发 / 校验 / 删除"""
async def test_create_get_delete(self):
store = SessionStore(FakeRedis())
token = await store.create("alice", "admin")
assert isinstance(token, str) and len(token) == 64 # token_hex(32)
assert await store.get(token) == {"username": "alice", "role": "admin"}
await store.delete(token)
assert await store.get(token) is None
async def test_forged_token_returns_none(self):
assert await SessionStore(FakeRedis()).get("f" * 64) is None
async def test_tokens_unique(self):
store = SessionStore(FakeRedis())
assert await store.create("alice", "user") != await store.create("alice", "user")
async def test_delete_is_idempotent(self):
store = SessionStore(FakeRedis())
token = await store.create("alice", "user")
await store.delete(token)
await store.delete(token) # 二次删除不报错
assert await store.get(token) is None
class TestTtl:
"""TTL 透传 Redis"""
async def test_ttl_passed_to_setex(self):
redis = FakeRedis()
store = SessionStore(redis)
token = await store.create("alice", "user")
assert SessionStore.TTL == 43200
assert redis.ttls[f"session:{token}"] == 43200
class TestDeleteByUsername:
"""按用户清除全部会话"""
async def test_clears_only_target_user(self):
store = SessionStore(FakeRedis())
t1 = await store.create("alice", "user")
t2 = await store.create("alice", "user")
t3 = await store.create("bob", "user")
await store.delete_by_username("alice")
assert await store.get(t1) is None
assert await store.get(t2) is None
assert await store.get(t3) is not None
async def test_unknown_username_noop(self):
store = SessionStore(FakeRedis())
token = await store.create("alice", "user")
await store.delete_by_username("nobody")
assert await store.get(token) is not None
class TestMemoryFallback:
"""构造传 None:内存 dict 降级 + 过期惰性清理"""
async def test_full_lifecycle(self):
store = SessionStore(None)
token = await store.create("alice", "admin")
assert await store.get(token) == {"username": "alice", "role": "admin"}
await store.delete_by_username("alice")
assert await store.get(token) is None
async def test_expired_session_returns_none_and_purged(self):
store = SessionStore(None)
token = await store.create("alice", "user")
# 手动把过期时间戳拨到过去,模拟 TTL 到期
payload, _ = store._memory[token]
store._memory[token] = (payload, time.time() - 1)
assert await store.get(token) is None
assert token not in store._memory # 惰性清理
async def test_create_purges_expired_entries(self):
store = SessionStore(None)
expired_token = await store.create("old", "user")
payload, _ = store._memory[expired_token]
store._memory[expired_token] = (payload, time.time() - 1)
await store.create("new", "user") # 签发时顺手清理过期项
assert expired_token not in store._memory
class TestRedisErrors:
"""Redis 读写异常统一包装为 SessionStoreError(不静默)"""
async def test_create_raises_store_error(self):
with pytest.raises(SessionStoreError):
await SessionStore(FakeRedis(fail=True)).create("alice", "user")
async def test_get_raises_store_error(self):
with pytest.raises(SessionStoreError):
await SessionStore(FakeRedis(fail=True)).get("t" * 64)
async def test_delete_raises_store_error(self):
with pytest.raises(SessionStoreError):
await SessionStore(FakeRedis(fail=True)).delete("t" * 64)
async def test_delete_by_username_raises_store_error(self):
with pytest.raises(SessionStoreError):
await SessionStore(FakeRedis(fail=True)).delete_by_username("alice")