Files
kplam 92b062c048 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>
2026-07-31 21:29:02 +08:00

172 lines
5.8 KiB
Python
Raw Permalink 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.
"""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")