Files

613 lines
22 KiB
Python

# =============================================================================
# 企微IT智能服务台 — 管理后台账号密码+OTP登录测试
# =============================================================================
# 覆盖范围:
# 1. 超级管理员初始化逻辑 (init_super_admin)
# 2. 账号密码登录流程 (/agents/login)
# 3. 管理员 CRUD API (/admin/users) — 注意:nginx已剥离/api前缀
# 4. 密码验证 (bcrypt)
# 5. OTP/MFA 二次验证
# =============================================================================
import os
import pytest
import pytest_asyncio
import pyotp
import bcrypt
from sqlalchemy import select
from app.models.agent import Agent
from app.models.role import Role
from app.models.user_role import UserRole
from app.services.admin_user_service import AdminUserService, init_super_admin
from tests.conftest import create_test_agent
# -----------------------------------------------------------------------------
# 辅助函数
# -----------------------------------------------------------------------------
async def _seed_admin_role(db_session, employee_id: str, role_name: str = "admin") -> str:
"""为用户分配指定角色."""
stmt = select(Role).where(Role.name == role_name)
role = (await db_session.execute(stmt)).scalars().first()
if not role:
role = Role(
id=str(__import__("uuid").uuid4()),
name=role_name,
display_name={"admin": "管理员", "super_admin": "超级管理员"}.get(role_name, role_name),
is_default=False,
permissions=[],
)
db_session.add(role)
await db_session.flush()
# 检查关联是否已存在
stmt = select(UserRole).where(
UserRole.employee_id == employee_id,
UserRole.role_id == role.id,
)
existing = (await db_session.execute(stmt)).scalars().first()
if not existing:
user_role = UserRole(
id=str(__import__("uuid").uuid4()),
employee_id=employee_id,
role_id=role.id,
source="manual",
assigned_at=__import__("datetime").datetime.now(),
)
db_session.add(user_role)
await db_session.flush()
return role.id
def _bearer(token: str) -> dict:
"""构造 Authorization header."""
return {"Authorization": f"Bearer {token}"}
async def _login_and_get_token(client, user_id: str, name: str, password: str = None, otp_code: str = None) -> dict:
"""调用 /agents/login 拿 token.
Returns:
dict: 包含 token, require_otp 等字段的响应数据
"""
json_body = {"user_id": user_id, "name": name}
if password:
json_body["password"] = password
if otp_code:
json_body["otp_code"] = otp_code
response = await client.post("/agents/login", json=json_body)
assert response.status_code == 200, f"登录失败: {response.text}"
body = response.json()
return body
# =============================================================================
# 1. 超级管理员初始化测试
# =============================================================================
class TestSuperAdminInit:
"""测试 init_super_admin 函数"""
@pytest.mark.asyncio
async def test_init_super_admin_no_env_vars(self, db_session):
"""未配置环境变量时,返回 None,不创建用户"""
# 确保环境变量未设置
with pytest.MonkeyPatch.context() as mp:
mp.delenv("ADMIN_USERNAME", raising=False)
mp.delenv("ADMIN_PASSWORD", raising=False)
mp.delenv("ADMIN_NAME", raising=False)
result = await init_super_admin(db_session)
assert result is None
# 确认没有创建任何管理员
stmt = select(Agent).where(Agent.role == "super_admin")
result = await db_session.execute(stmt)
assert result.scalars().first() is None
@pytest.mark.asyncio
async def test_init_super_admin_with_env_vars(self, db_session):
"""配置环境变量时,创建超级管理员"""
with pytest.MonkeyPatch.context() as mp:
mp.setenv("ADMIN_USERNAME", "superadmin")
mp.setenv("ADMIN_PASSWORD", "superpass123")
mp.setenv("ADMIN_NAME", "超级管理员")
result = await init_super_admin(db_session)
assert result is not None
assert result.user_id == "superadmin"
assert result.name == "超级管理员"
assert result.role == "super_admin"
# 验证密码已哈希存储
assert result.password_hash is not None
assert bcrypt.checkpw("superpass123".encode("utf-8"), result.password_hash.encode("utf-8"))
@pytest.mark.asyncio
async def test_init_super_admin_already_exists(self, db_session):
"""超级管理员已存在时,跳过创建"""
# 先创建一个
agent = create_test_agent(user_id="superadmin", name="超级管理员")
agent.role = "super_admin"
agent.password_hash = bcrypt.hashpw("oldpass".encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
db_session.add(agent)
await db_session.flush()
with pytest.MonkeyPatch.context() as mp:
mp.setenv("ADMIN_USERNAME", "superadmin")
mp.setenv("ADMIN_PASSWORD", "newpass123")
result = await init_super_admin(db_session)
assert result is not None
assert result.user_id == "superadmin"
# 密码应该是原来的,不应该被覆盖
assert bcrypt.checkpw("oldpass".encode("utf-8"), result.password_hash.encode("utf-8"))
# =============================================================================
# 2. AdminUserService 单元测试
# =============================================================================
class TestAdminUserService:
"""AdminUserService 静态方法直接测试"""
@pytest.mark.asyncio
async def test_create_admin_user(self, db_session):
"""创建管理员用户"""
service = AdminUserService(db_session)
agent = await service.create_admin_user(
user_id="admin001",
name="管理员1",
role="admin",
password="test123456"
)
assert agent.user_id == "admin001"
assert agent.name == "管理员1"
assert agent.role == "admin"
assert bcrypt.checkpw("test123456".encode("utf-8"), agent.password_hash.encode("utf-8"))
@pytest.mark.asyncio
async def test_create_admin_user_duplicate(self, db_session):
"""重复创建管理员应抛出异常"""
service = AdminUserService(db_session)
await service.create_admin_user(
user_id="admin001",
name="管理员1",
role="admin",
password="test123456"
)
# 重复创建应抛出异常
from app.utils.error_codes import ErrorCode
from app.utils.response import AppException
with pytest.raises(AppException) as exc_info:
await service.create_admin_user(
user_id="admin001",
name="管理员1",
role="admin",
password="test123456"
)
assert "已存在" in str(exc_info.value.message)
@pytest.mark.asyncio
async def test_verify_password_correct(self, db_session):
"""密码验证 - 正确密码"""
service = AdminUserService(db_session)
await service.create_admin_user(
user_id="admin002",
name="管理员2",
role="admin",
password="correctpassword"
)
agent = await service.verify_password("admin002", "correctpassword")
assert agent is not None
assert agent.user_id == "admin002"
@pytest.mark.asyncio
async def test_verify_password_wrong(self, db_session):
"""密码验证 - 错误密码"""
service = AdminUserService(db_session)
await service.create_admin_user(
user_id="admin003",
name="管理员3",
role="admin",
password="correctpassword"
)
agent = await service.verify_password("admin003", "wrongpassword")
assert agent is None
@pytest.mark.asyncio
async def test_verify_password_nonexistent_user(self, db_session):
"""密码验证 - 不存在的用户"""
service = AdminUserService(db_session)
agent = await service.verify_password("nonexistent", "anypassword")
assert agent is None
@pytest.mark.asyncio
async def test_reset_password(self, db_session):
"""重置密码"""
service = AdminUserService(db_session)
agent = await service.create_admin_user(
user_id="admin004",
name="管理员4",
role="admin",
password="oldpassword"
)
updated = await service.reset_password(agent.id, "newpassword")
assert bcrypt.checkpw("newpassword".encode("utf-8"), updated.password_hash.encode("utf-8"))
assert not bcrypt.checkpw("oldpassword".encode("utf-8"), updated.password_hash.encode("utf-8"))
@pytest.mark.asyncio
async def test_delete_admin_user(self, db_session):
"""删除管理员"""
service = AdminUserService(db_session)
agent = await service.create_admin_user(
user_id="admin005",
name="管理员5",
role="admin",
password="test123"
)
result = await service.delete_admin_user(agent.id)
assert result is True
# 验证已删除
deleted = await service.get_user_by_id(agent.id)
assert deleted is None
@pytest.mark.asyncio
async def test_delete_super_admin_forbidden(self, db_session):
"""删除超级管理员应被拒绝"""
service = AdminUserService(db_session)
agent = await service.create_admin_user(
user_id="superadmin",
name="超级管理员",
role="super_admin",
password="test123"
)
from app.utils.error_codes import ErrorCode
from app.utils.response import AppException
with pytest.raises(AppException) as exc_info:
await service.delete_admin_user(agent.id)
assert "超级管理员" in str(exc_info.value.message)
# =============================================================================
# 3. 管理员 CRUD API 测试
# =============================================================================
class TestAdminUserAPI:
"""管理员用户 CRUD API 测试
注意: nginx 配置会将 /api 前缀剥离,所以实际路径是 /admin/users 而非 /api/admin/users
"""
@pytest.mark.asyncio
async def test_list_admin_users(self, client, db_session):
"""GET /admin/users - 获取管理员列表"""
# 创建测试管理员
service = AdminUserService(db_session)
await service.create_admin_user("admin_list_1", "管理员A", "admin", "pass123")
await service.create_admin_user("admin_list_2", "管理员B", "admin", "pass456")
await db_session.commit()
# 创建管理员用户并分配角色
admin_agent = create_test_agent(user_id="test_admin_user", name="测试管理员")
admin_agent.role = "admin"
db_session.add(admin_agent)
await db_session.flush()
await _seed_admin_role(db_session, "test_admin_user", "admin")
# 登录获取 token
login_resp = await _login_and_get_token(client, "test_admin_user", "测试管理员")
token = login_resp["data"]["token"]
# 调用 API (无 /api 前缀)
response = await client.get("/admin/users", headers=_bearer(token))
assert response.status_code == 200
body = response.json()
assert body["code"] == 0
assert body["data"]["total"] >= 2
@pytest.mark.asyncio
async def test_create_admin_user_api(self, client, db_session):
"""POST /admin/users - 创建管理员"""
# 创建超级管理员
super_agent = create_test_agent(user_id="test_super_admin", name="测试超级管理员")
super_agent.role = "super_admin"
db_session.add(super_agent)
await db_session.flush()
await _seed_admin_role(db_session, "test_super_admin", "super_admin")
# 登录获取 token
login_resp = await _login_and_get_token(client, "test_super_admin", "测试超级管理员")
token = login_resp["data"]["token"]
# 创建管理员
response = await client.post(
"/admin/users",
headers=_bearer(token),
json={
"user_id": "new_admin",
"name": "新管理员",
"role": "admin",
"password": "newpass123"
}
)
assert response.status_code == 200
body = response.json()
assert body["code"] == 0
assert body["data"]["user_id"] == "new_admin"
assert body["data"]["role"] == "admin"
@pytest.mark.asyncio
async def test_get_admin_user(self, client, db_session):
"""GET /admin/users/{id} - 获取管理员详情"""
# 创建管理员
service = AdminUserService(db_session)
agent = await service.create_admin_user("admin_detail", "管理员详情", "admin", "pass123")
await db_session.commit()
# 查询用户并分配角色
stmt = select(Agent).where(Agent.user_id == "admin_detail")
result = await db_session.execute(stmt)
admin_agent = result.scalars().first()
# 分配 admin 角色
await _seed_admin_role(db_session, "admin_detail", "admin")
# 登录获取 token
login_resp = await _login_and_get_token(client, "admin_detail", "管理员详情")
token = login_resp["data"]["token"]
# 获取详情
response = await client.get(f"/admin/users/{admin_agent.id}", headers=_bearer(token))
assert response.status_code == 200
body = response.json()
assert body["code"] == 0
assert body["data"]["user_id"] == "admin_detail"
@pytest.mark.asyncio
async def test_update_admin_user(self, client, db_session):
"""PUT /admin/users/{id} - 更新管理员"""
# 创建管理员
service = AdminUserService(db_session)
agent = await service.create_admin_user("admin_update", "管理员更新", "admin", "pass123")
await db_session.commit()
# 分配角色
await _seed_admin_role(db_session, "admin_update", "admin")
# 登录获取 token
login_resp = await _login_and_get_token(client, "admin_update", "管理员更新")
token = login_resp["data"]["token"]
# 更新管理员
response = await client.put(
f"/admin/users/{agent.id}",
headers=_bearer(token),
json={"name": "新名字", "is_active": True}
)
assert response.status_code == 200
body = response.json()
assert body["code"] == 0
@pytest.mark.asyncio
async def test_delete_admin_user_api(self, client, db_session):
"""DELETE /admin/users/{id} - 删除管理员"""
# 创建超级管理员
super_agent = create_test_agent(user_id="test_super_delete", name="测试超级管理员")
super_agent.role = "super_admin"
db_session.add(super_agent)
await db_session.flush()
await _seed_admin_role(db_session, "test_super_delete", "super_admin")
# 创建待删除的管理员
service = AdminUserService(db_session)
agent = await service.create_admin_user("admin_to_delete", "待删除管理员", "admin", "pass123")
await db_session.commit()
# 登录获取 token
login_resp = await _login_and_get_token(client, "test_super_delete", "测试超级管理员")
token = login_resp["data"]["token"]
# 删除管理员
response = await client.delete(f"/admin/users/{agent.id}", headers=_bearer(token))
assert response.status_code == 200
body = response.json()
assert body["code"] == 0
# =============================================================================
# 4. 账号密码+OTP登录测试
# =============================================================================
class TestPasswordOTPLogin:
"""账号密码 + OTP 登录流程测试
登录流程说明:
1. 优先尝试企微通讯录验证
2. 企微不可达时,已注册坐席可降级登录(需验证本地密码)
3. 启用MFA后需要OTP验证
"""
@pytest.mark.asyncio
async def test_login_without_password(self, client, db_session):
"""登录 - 无密码的新坐席(企微验证通过)"""
# 创建管理员但不设置密码
service = AdminUserService(db_session)
agent = await service.create_admin_user(
user_id="login_test_user",
name="登录测试用户",
role="admin"
# 不设置 password
)
await db_session.commit()
# 登录(企微验证通过)
response = await _login_and_get_token(
client,
user_id="login_test_user",
name="登录测试用户"
)
body = response
assert body["code"] == 0
assert "token" in body["data"]
@pytest.mark.asyncio
async def test_login_require_otp_when_mfa_enabled(self, client, db_session):
"""登录 - MFA 启用时需要 OTP 验证"""
# 创建管理员并启用 MFA
service = AdminUserService(db_session)
agent = await service.create_admin_user(
user_id="mfa_user",
name="MFA用户",
role="admin",
password="testpassword123"
)
# 模拟已绑定 MFA
secret = pyotp.random_base32()
agent.mfa_secret = secret
agent.mfa_enabled = True
await db_session.commit()
# 登录但不提供 OTP
response = await _login_and_get_token(
client,
user_id="mfa_user",
name="MFA用户",
password="testpassword123"
)
body = response
assert body["code"] == 0
assert body["data"]["require_otp"] is True
assert "token" not in body["data"]
@pytest.mark.asyncio
async def test_login_with_correct_otp(self, client, db_session):
"""登录 - 提供正确的 OTP"""
# 创建管理员并启用 MFA
service = AdminUserService(db_session)
agent = await service.create_admin_user(
user_id="otp_user",
name="OTP用户",
role="admin",
password="testpassword123"
)
# 模拟已绑定 MFA
secret = pyotp.random_base32()
agent.mfa_secret = secret
agent.mfa_enabled = True
await db_session.commit()
# 生成当前有效的 OTP
totp = pyotp.TOTP(secret)
otp_code = totp.now()
# 登录并提供 OTP
response = await _login_and_get_token(
client,
user_id="otp_user",
name="OTP用户",
password="testpassword123",
otp_code=otp_code
)
body = response
assert body["code"] == 0
assert "token" in body["data"]
@pytest.mark.asyncio
async def test_login_with_wrong_otp(self, client, db_session):
"""登录 - 提供错误的 OTP"""
# 创建管理员并启用 MFA
service = AdminUserService(db_session)
agent = await service.create_admin_user(
user_id="wrong_otp_user",
name="错误OTP用户",
role="admin",
password="testpassword123"
)
# 模拟已绑定 MFA
secret = pyotp.random_base32()
agent.mfa_secret = secret
agent.mfa_enabled = True
await db_session.commit()
# 使用错误的 OTP 登录
response = await _login_and_get_token(
client,
user_id="wrong_otp_user",
name="错误OTP用户",
password="testpassword123",
otp_code="000000"
)
# 应该返回业务错误
assert response["code"] != 0
# =============================================================================
# 5. 权限控制测试
# =============================================================================
class TestAdminPermission:
"""管理员权限控制测试"""
@pytest.mark.asyncio
async def test_create_admin_requires_super_admin(self, client, db_session):
"""创建管理员需要 super_admin 角色"""
# 创建普通管理员
normal_admin = create_test_agent(user_id="normal_admin_perm", name="普通管理员")
normal_admin.role = "admin"
db_session.add(normal_admin)
await db_session.flush()
await _seed_admin_role(db_session, "normal_admin_perm", "admin")
# 登录获取 token
login_resp = await _login_and_get_token(client, "normal_admin_perm", "普通管理员")
token = login_resp["data"]["token"]
# 尝试创建管理员 (无 /api 前缀)
response = await client.post(
"/admin/users",
headers=_bearer(token),
json={
"user_id": "should_fail",
"name": "应该失败",
"role": "admin"
}
)
# 应该返回非0业务码
body = response.json()
assert body["code"] != 0
@pytest.mark.asyncio
async def test_delete_admin_requires_super_admin(self, client, db_session):
"""删除管理员需要 super_admin 角色"""
# 创建普通管理员
normal_admin = create_test_agent(user_id="normal_admin_del", name="普通管理员")
normal_admin.role = "admin"
db_session.add(normal_admin)
await db_session.flush()
await _seed_admin_role(db_session, "normal_admin_del", "admin")
# 创建待删除的管理员
service = AdminUserService(db_session)
to_delete = await service.create_admin_user("target_admin", "目标管理员", "admin", "pass123")
await db_session.commit()
# 登录获取 token
login_resp = await _login_and_get_token(client, "normal_admin_del", "普通管理员")
token = login_resp["data"]["token"]
# 尝试删除管理员 (无 /api 前缀)
response = await client.delete(f"/admin/users/{to_delete.id}", headers=_bearer(token))
body = response.json()
assert body["code"] != 0