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:
@@ -0,0 +1,171 @@
|
||||
"""SessionStore 单元测试(FakeRedis 与内存降级,不连真实 Redis)
|
||||
|
||||
覆盖:
|
||||
- create/get/delete 生命周期;伪造 token 返回 None
|
||||
- TTL=43200 传入 Redis(setex)
|
||||
- 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")
|
||||
Reference in New Issue
Block a user