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,311 @@
|
||||
"""UserStore / bootstrap_admin 单元测试(FakeRedis 与内存降级,不连真实 Redis)
|
||||
|
||||
覆盖:
|
||||
- 哈希:同密码同 salt 一致;不同 salt 不同
|
||||
- create:重名 UserExistsError;非法用户名/弱密码 ValueError;记录不含明文密码
|
||||
- get/list/delete/verify_password/set_password/count_admins
|
||||
- bootstrap_admin:空库创建 admin 并打印/返回密码;非空库不重复创建
|
||||
- 内存降级(构造传 None)全功能可用
|
||||
- Redis 异常包装为 UserStoreError
|
||||
"""
|
||||
|
||||
import fnmatch
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.users import (
|
||||
UserExistsError,
|
||||
UserStore,
|
||||
UserStoreError,
|
||||
bootstrap_admin,
|
||||
)
|
||||
|
||||
|
||||
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 FakeLogger:
|
||||
"""捕获 warning 调用的假 logger(structlog 风格:event + kwargs)"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.warnings: list[tuple[str, dict]] = []
|
||||
|
||||
def warning(self, event: str, **kwargs) -> None:
|
||||
self.warnings.append((event, kwargs))
|
||||
|
||||
|
||||
class TestHashPassword:
|
||||
"""PBKDF2 哈希确定性"""
|
||||
|
||||
def test_same_password_same_salt_consistent(self):
|
||||
store = UserStore(None)
|
||||
salt = b"0123456789abcdef"
|
||||
assert store.hash_password("password123", salt) == store.hash_password("password123", salt)
|
||||
|
||||
def test_different_salt_different_hash(self):
|
||||
store = UserStore(None)
|
||||
assert store.hash_password("password123", b"a" * 16) != store.hash_password("password123", b"b" * 16)
|
||||
|
||||
|
||||
class TestCreate:
|
||||
"""创建用户:校验、重名、无明文"""
|
||||
|
||||
async def test_create_success(self):
|
||||
store = UserStore(FakeRedis())
|
||||
record = await store.create("alice", "password123")
|
||||
assert record.username == "alice"
|
||||
assert record.role == "user"
|
||||
assert record.must_change_password is False
|
||||
# created_at 为可解析的 ISO8601
|
||||
datetime.fromisoformat(record.created_at)
|
||||
|
||||
async def test_record_contains_no_plaintext(self):
|
||||
redis = FakeRedis()
|
||||
store = UserStore(redis)
|
||||
record = await store.create("alice", "password123")
|
||||
assert record.password_hash != "password123"
|
||||
assert "password123" not in record.password_hash
|
||||
# salt 为 16 字节 hex
|
||||
assert len(bytes.fromhex(record.salt)) == 16
|
||||
# 落库 JSON 同样不含明文密码
|
||||
raw = redis.store["user:alice"]
|
||||
assert "password123" not in raw
|
||||
assert json.loads(raw)["username"] == "alice"
|
||||
|
||||
async def test_duplicate_raises_user_exists(self):
|
||||
store = UserStore(FakeRedis())
|
||||
await store.create("alice", "password123")
|
||||
with pytest.raises(UserExistsError):
|
||||
await store.create("alice", "another-password")
|
||||
|
||||
@pytest.mark.parametrize("username", ["a", "x" * 33, "bad name", "bad!name", "中文名", ""])
|
||||
async def test_invalid_username_raises_value_error(self, username: str):
|
||||
with pytest.raises(ValueError):
|
||||
await UserStore(FakeRedis()).create(username, "password123")
|
||||
|
||||
@pytest.mark.parametrize("username", ["ab", "a" * 32, "A-Z_0-9"])
|
||||
async def test_valid_username_boundary(self, username: str):
|
||||
record = await UserStore(FakeRedis()).create(username, "password123")
|
||||
assert record.username == username
|
||||
|
||||
@pytest.mark.parametrize("password", ["", "short", "1234567"])
|
||||
async def test_short_password_raises_value_error(self, password: str):
|
||||
with pytest.raises(ValueError):
|
||||
await UserStore(FakeRedis()).create("alice", password)
|
||||
|
||||
async def test_exact_min_password_length_ok(self):
|
||||
record = await UserStore(FakeRedis()).create("alice", "12345678")
|
||||
assert record.username == "alice"
|
||||
|
||||
|
||||
class TestGetListDelete:
|
||||
"""查询 / 列表 / 删除"""
|
||||
|
||||
async def test_get_roundtrip_and_miss(self):
|
||||
store = UserStore(FakeRedis())
|
||||
created = await store.create("alice", "password123")
|
||||
assert await store.get("alice") == created
|
||||
assert await store.get("nobody") is None
|
||||
|
||||
async def test_list_scans_only_user_keys(self):
|
||||
redis = FakeRedis()
|
||||
store = UserStore(redis)
|
||||
await store.create("alice", "password123")
|
||||
await store.create("bob", "password456", role="admin")
|
||||
redis.store["other:key"] = "not-a-user" # 非 user: 前缀不参与
|
||||
names = {r.username for r in await store.list()}
|
||||
assert names == {"alice", "bob"}
|
||||
|
||||
async def test_delete_idempotent(self):
|
||||
store = UserStore(FakeRedis())
|
||||
await store.create("alice", "password123")
|
||||
assert await store.delete("alice") is True
|
||||
assert await store.get("alice") is None
|
||||
assert await store.delete("alice") is False
|
||||
|
||||
|
||||
class TestVerifyPassword:
|
||||
"""密码校验"""
|
||||
|
||||
async def test_success_returns_record(self):
|
||||
store = UserStore(FakeRedis())
|
||||
created = await store.create("alice", "password123")
|
||||
assert await store.verify_password("alice", "password123") == created
|
||||
|
||||
async def test_wrong_password_returns_none(self):
|
||||
store = UserStore(FakeRedis())
|
||||
await store.create("alice", "password123")
|
||||
assert await store.verify_password("alice", "wrong-password") is None
|
||||
|
||||
async def test_unknown_user_returns_none(self):
|
||||
assert await UserStore(FakeRedis()).verify_password("nobody", "password123") is None
|
||||
|
||||
|
||||
class TestSetPassword:
|
||||
"""重置密码"""
|
||||
|
||||
async def test_reset_clears_flag_and_invalidates_old(self):
|
||||
store = UserStore(FakeRedis())
|
||||
await store.create("alice", "old-password", must_change_password=True)
|
||||
assert await store.set_password("alice", "new-password") is True
|
||||
record = await store.get("alice")
|
||||
assert record is not None
|
||||
assert record.must_change_password is False
|
||||
assert await store.verify_password("alice", "old-password") is None
|
||||
assert await store.verify_password("alice", "new-password") is not None
|
||||
|
||||
async def test_unknown_user_returns_false(self):
|
||||
assert await UserStore(FakeRedis()).set_password("nobody", "password123") is False
|
||||
|
||||
async def test_short_password_raises_value_error(self):
|
||||
store = UserStore(FakeRedis())
|
||||
await store.create("alice", "password123")
|
||||
with pytest.raises(ValueError):
|
||||
await store.set_password("alice", "short")
|
||||
|
||||
|
||||
class TestCountAdmins:
|
||||
async def test_counts_only_admin_role(self):
|
||||
store = UserStore(FakeRedis())
|
||||
assert await store.count_admins() == 0
|
||||
await store.create("admin1", "password123", role="admin")
|
||||
await store.create("admin2", "password123", role="admin")
|
||||
await store.create("user1", "password123")
|
||||
assert await store.count_admins() == 2
|
||||
await store.delete("admin1")
|
||||
assert await store.count_admins() == 1
|
||||
|
||||
|
||||
class TestBootstrapAdmin:
|
||||
"""空库引导创建默认管理员"""
|
||||
|
||||
async def test_empty_store_creates_admin_and_returns_password(self):
|
||||
store = UserStore(FakeRedis())
|
||||
logger = FakeLogger()
|
||||
password = await bootstrap_admin(store, logger) # type: ignore[arg-type]
|
||||
assert isinstance(password, str) and len(password) > 0
|
||||
record = await store.get("admin")
|
||||
assert record is not None
|
||||
assert record.role == "admin"
|
||||
assert record.must_change_password is True
|
||||
# 返回的明文密码可直接通过校验
|
||||
assert await store.verify_password("admin", password) is not None
|
||||
# 明文密码仅在日志中打印一次
|
||||
assert len(logger.warnings) == 1
|
||||
assert logger.warnings[0][1].get("password") == password
|
||||
|
||||
async def test_non_empty_store_returns_none_without_changes(self):
|
||||
store = UserStore(FakeRedis())
|
||||
await store.create("alice", "password123")
|
||||
logger = FakeLogger()
|
||||
assert await bootstrap_admin(store, logger) is None # type: ignore[arg-type]
|
||||
assert [r.username for r in await store.list()] == ["alice"]
|
||||
assert logger.warnings == []
|
||||
|
||||
async def test_second_call_returns_none(self):
|
||||
store = UserStore(FakeRedis())
|
||||
logger = FakeLogger()
|
||||
assert await bootstrap_admin(store, logger) is not None # type: ignore[arg-type]
|
||||
assert await bootstrap_admin(store, logger) is None # type: ignore[arg-type]
|
||||
assert len(await store.list()) == 1
|
||||
|
||||
|
||||
class TestMemoryFallback:
|
||||
"""构造传 None:纯内存 dict 降级,全功能可用"""
|
||||
|
||||
async def test_full_flow_without_redis(self):
|
||||
store = UserStore(None)
|
||||
await store.create("alice", "password123", role="admin")
|
||||
await store.create("bob", "password456")
|
||||
assert await store.count_admins() == 1
|
||||
assert {r.username for r in await store.list()} == {"alice", "bob"}
|
||||
assert await store.verify_password("alice", "password123") is not None
|
||||
assert await store.verify_password("alice", "wrong-password") is None
|
||||
assert await store.set_password("bob", "new-password") is True
|
||||
assert await store.verify_password("bob", "new-password") is not None
|
||||
assert await store.delete("bob") is True
|
||||
assert await store.get("bob") is None
|
||||
|
||||
async def test_bootstrap_admin_memory(self):
|
||||
store = UserStore(None)
|
||||
password = await bootstrap_admin(store, FakeLogger()) # type: ignore[arg-type]
|
||||
assert password is not None
|
||||
record = await store.get("admin")
|
||||
assert record is not None and record.role == "admin"
|
||||
|
||||
|
||||
class TestRedisErrors:
|
||||
"""Redis 读写异常统一包装为 UserStoreError(不静默)"""
|
||||
|
||||
async def test_create_raises_store_error(self):
|
||||
with pytest.raises(UserStoreError):
|
||||
await UserStore(FakeRedis(fail=True)).create("alice", "password123")
|
||||
|
||||
async def test_get_raises_store_error(self):
|
||||
with pytest.raises(UserStoreError):
|
||||
await UserStore(FakeRedis(fail=True)).get("alice")
|
||||
|
||||
async def test_list_raises_store_error(self):
|
||||
with pytest.raises(UserStoreError):
|
||||
await UserStore(FakeRedis(fail=True)).list()
|
||||
|
||||
async def test_delete_raises_store_error(self):
|
||||
with pytest.raises(UserStoreError):
|
||||
await UserStore(FakeRedis(fail=True)).delete("alice")
|
||||
|
||||
async def test_set_password_raises_store_error(self):
|
||||
with pytest.raises(UserStoreError):
|
||||
await UserStore(FakeRedis(fail=True)).set_password("alice", "password123")
|
||||
|
||||
async def test_count_admins_raises_store_error(self):
|
||||
with pytest.raises(UserStoreError):
|
||||
await UserStore(FakeRedis(fail=True)).count_admins()
|
||||
Reference in New Issue
Block a user