92b062c048
- 新增 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>
172 lines
5.8 KiB
Python
172 lines
5.8 KiB
Python
"""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")
|