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
+311
View File
@@ -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 调用的假 loggerstructlog 风格: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()