feat(system): 完成后端系统管理(审计/角色/操作日志/日志查询)
- 操作日志审计机制:audit 上下文(成功与失败都记、独立提交、操作人标量 固化、commit 后查询前置约定),用户/角色全部写操作接入;request_data 按键名的结构化脱敏 + UTF-8 字节边界截断 - 角色管理:CRUD 与启停;admin 禁删禁停、有绑定禁删(FOR UPDATE 锁行)、 编码创建后不可改、停用角色不可再分配 - 日志查询:操作/登录两端点,列表与详情两段式(详情含已脱敏请求参数); 时间参数统一转本地 naive(修混用时区 500 与偏移丢弃) - 用户管理:补齐 reset_password 操作人、编辑空串归一 NULL、并发唯一键兜底 400 - 测试 301 例全绿(单元 223 / 集成 78) Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
1 parent
e22c4bcdc9
commit
c83287819a
32 files changed
+3158
-137
No files matched your search
@@ -0,0 +1,169 @@
|
||||
"""系统管理集成测试共用夹具(需要 MySQL 与 Redis)。
|
||||
|
||||
运行前置:``docker compose -f docker/docker-compose.dev.yml up -d``
|
||||
|
||||
数据卫生约定:
|
||||
|
||||
- 测试账号/角色统一 ``t_`` 前缀;工厂夹具清理自己创建的对象,另有
|
||||
autouse 兜底按前缀全量清理(用例经**接口**创建的数据不在工厂名下,
|
||||
失败中断时会残留并影响后续用例的 total 断言)
|
||||
- 操作日志的清理同时按 username 与 user_id —— 账号被删除后其 ``user_id``
|
||||
会被 SET NULL,只能靠冗余的 username 追溯,清理逻辑必须覆盖两种状态
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from app.core.constants import ROLE_ADMIN, ROLE_USER
|
||||
from app.core.db import get_session_factory
|
||||
from app.core.security import create_access_token, hash_password
|
||||
from app.models import OperationLog, Role, User, UserRole
|
||||
|
||||
TEST_PASSWORD = "TestPwd@2026"
|
||||
# 模块级预计算一次:bcrypt(rounds=12)单次约 0.2–0.4 秒,逐用例重复哈希
|
||||
# 会让整个集成套件多花 20 秒以上;这些测试账号全部走直签 JWT,
|
||||
# 哈希值不被任何断言消费,复用一个哈希与每次重算等价
|
||||
_TEST_PASSWORD_HASH = hash_password(TEST_PASSWORD)
|
||||
|
||||
|
||||
def _purge_test_data() -> None:
|
||||
"""按 `t_` 前缀清理测试残留(先日志后账号;角色先断关联再删)。
|
||||
|
||||
``t\\_%`` 中的下划线做了转义 —— 不转义时 `_` 是 LIKE 的单字符通配,
|
||||
会把 tn... 这类无关账号一并卷入。
|
||||
"""
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
users = list(
|
||||
session.execute(select(User.id, User.username).where(User.username.like("t\\_%"))).all()
|
||||
)
|
||||
ids = [row[0] for row in users]
|
||||
names = [row[1] for row in users]
|
||||
if ids:
|
||||
session.execute(delete(OperationLog).where(OperationLog.user_id.in_(ids)))
|
||||
if names:
|
||||
session.execute(delete(OperationLog).where(OperationLog.username.in_(names)))
|
||||
session.execute(delete(User).where(User.id.in_(ids)))
|
||||
|
||||
role_ids = list(
|
||||
session.execute(select(Role.id).where(Role.role_code.like("t\\_%"))).scalars()
|
||||
)
|
||||
if role_ids:
|
||||
session.execute(delete(UserRole).where(UserRole.role_id.in_(role_ids)))
|
||||
session.execute(delete(Role).where(Role.id.in_(role_ids)))
|
||||
session.commit()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _purge_after_each_test():
|
||||
"""每个集成用例结束后兜底清理,避免残留影响后续用例的计数断言。"""
|
||||
yield
|
||||
_purge_test_data()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_user():
|
||||
"""独立测试账号工厂:``make_user(["admin"])`` → dict(id/username/password)。
|
||||
|
||||
不复用真实 admin —— 测试会改密码、提 token_version、删除账号,
|
||||
污染真实账号会让后续手工验证产生困惑。
|
||||
"""
|
||||
|
||||
def _factory(role_codes: list[str] | None = None, username: str | None = None) -> dict:
|
||||
codes = role_codes if role_codes is not None else [ROLE_USER]
|
||||
name = username or f"t_{uuid.uuid4().hex[:8]}"
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
roles = list(session.execute(select(Role).where(Role.role_code.in_(codes))).scalars())
|
||||
assert len(roles) == len(codes), f"角色不存在:{codes}"
|
||||
user = User(
|
||||
username=name,
|
||||
password=_TEST_PASSWORD_HASH,
|
||||
real_name="测试账号",
|
||||
status=1,
|
||||
)
|
||||
user.user_roles = [UserRole(role_id=role.id) for role in roles]
|
||||
session.add(user)
|
||||
session.commit()
|
||||
return {"id": user.id, "username": name, "password": TEST_PASSWORD}
|
||||
|
||||
return _factory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_headers():
|
||||
"""令牌直签工厂:``auth_headers(user)`` → Authorization 请求头。
|
||||
|
||||
绕开验证码与登录接口 —— 令牌只承载身份,授权判定以数据库中的角色为准
|
||||
(`require_admin` 每次查库),直签不影响测试有效性,省去清理 Redis
|
||||
限流键与黑名单。令牌版本变化(如重置密码、禁用)后旧令牌会失效,
|
||||
再次调用本工厂可换新令牌。
|
||||
"""
|
||||
|
||||
def _factory(user: dict) -> dict[str, str]:
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
db_user = session.get(User, user["id"])
|
||||
assert db_user is not None, "账号已被删除,无法签发令牌"
|
||||
roles = [link.role.role_code for link in db_user.user_roles if link.role]
|
||||
token, _, _ = create_access_token(
|
||||
user_id=db_user.id,
|
||||
token_version=db_user.token_version,
|
||||
roles=roles,
|
||||
)
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
return _factory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_role():
|
||||
"""独立测试角色工厂(ORM 直建,用后删除)。
|
||||
|
||||
清理时先删除 user_roles 关联(用例失败时可能残留绑定),再删角色。
|
||||
"""
|
||||
created: list[int] = []
|
||||
|
||||
def _factory(**overrides: object) -> dict:
|
||||
code = str(overrides.pop("role_code", f"t_role_{uuid.uuid4().hex[:6]}"))
|
||||
name = str(overrides.pop("role_name", "测试角色"))
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
role = Role(
|
||||
role_code=code,
|
||||
role_name=name,
|
||||
description=overrides.pop("description", None),
|
||||
sort_order=overrides.pop("sort_order", 99),
|
||||
status=overrides.pop("status", 1),
|
||||
**overrides,
|
||||
)
|
||||
session.add(role)
|
||||
session.commit()
|
||||
created.append(role.id)
|
||||
return {"id": role.id, "role_code": code, "role_name": name}
|
||||
|
||||
yield _factory
|
||||
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
session.execute(delete(UserRole).where(UserRole.role_id.in_(created)))
|
||||
session.execute(delete(Role).where(Role.id.in_(created)))
|
||||
session.commit()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin(make_user, auth_headers) -> dict:
|
||||
"""管理员账号(含请求头)。"""
|
||||
user = make_user([ROLE_ADMIN])
|
||||
user["headers"] = auth_headers(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def normal(make_user, auth_headers) -> dict:
|
||||
"""普通用户账号(含请求头)。"""
|
||||
user = make_user([ROLE_USER])
|
||||
user["headers"] = auth_headers(user)
|
||||
return user
|
||||
@@ -0,0 +1,198 @@
|
||||
"""日志查询接口集成测试(需要 MySQL 与 Redis)。
|
||||
|
||||
守的是:两类日志的筛选、分页、权限,以及**写入与查询的口径一致** ——
|
||||
写操作产生的审计记录必须能被查询接口按同样口径检索到(否则"有写入
|
||||
没查询"或"查询条件对不上",日志功能形同虚设)。
|
||||
|
||||
登录日志造数直接 ORM 插入,不经过登录接口:绕开验证码依赖,且能精确
|
||||
控制 success/fail_reason/ip 组合。
|
||||
|
||||
运行前置:``docker compose -f docker/docker-compose.dev.yml up -d``
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import delete
|
||||
|
||||
from app.core.db import get_session_factory
|
||||
from app.models import LoginLog
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
_OPERATION_LOGS = "/api/v1/system/operation-logs"
|
||||
_LOGIN_LOGS = "/api/v1/system/login-logs"
|
||||
_USERS = "/api/v1/system/users"
|
||||
|
||||
|
||||
def _make_audit(client: TestClient, admin: dict, action: str = "删除") -> None:
|
||||
"""触发一条审计记录(失败操作最省事、不留业务数据)。"""
|
||||
resp = client.delete(f"{_USERS}/999999999", headers=admin["headers"])
|
||||
assert resp.status_code == 404
|
||||
assert action == "删除"
|
||||
|
||||
|
||||
class TestOperationLogQuery:
|
||||
"""操作日志查询。"""
|
||||
|
||||
def test_写操作后可按模块与操作筛选(self, client: TestClient, admin: dict, make_user) -> None:
|
||||
target = make_user()
|
||||
resp = client.delete(f"{_USERS}/{target['id']}", headers=admin["headers"])
|
||||
assert resp.status_code == 200
|
||||
|
||||
resp = client.get(
|
||||
_OPERATION_LOGS,
|
||||
params={"username": admin["username"], "module": "用户管理", "action": "删除"},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
data = resp.json()["data"]
|
||||
assert data["total"] >= 1
|
||||
row = data["list"][0]
|
||||
assert row["success"] == 1
|
||||
assert target["username"] in row["summary"]
|
||||
assert row["module"] == "用户管理"
|
||||
|
||||
def test_按结果筛选出失败记录(self, client: TestClient, admin: dict) -> None:
|
||||
_make_audit(client, admin)
|
||||
resp = client.get(
|
||||
_OPERATION_LOGS,
|
||||
params={"username": admin["username"], "success": 0},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
data = resp.json()["data"]
|
||||
assert data["total"] >= 1
|
||||
assert data["list"][0]["error_message"]
|
||||
|
||||
def test_时间范围筛选(self, client: TestClient, admin: dict) -> None:
|
||||
_make_audit(client, admin)
|
||||
now = datetime.now()
|
||||
fmt = "%Y-%m-%d %H:%M:%S"
|
||||
|
||||
resp = client.get(
|
||||
_OPERATION_LOGS,
|
||||
params={
|
||||
"username": admin["username"],
|
||||
"start_time": (now - timedelta(minutes=5)).strftime(fmt),
|
||||
"end_time": (now + timedelta(minutes=5)).strftime(fmt),
|
||||
},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.json()["data"]["total"] >= 1
|
||||
|
||||
# 已过去的时间窗(排除测试刚产生的记录)
|
||||
resp = client.get(
|
||||
_OPERATION_LOGS,
|
||||
params={
|
||||
"username": admin["username"],
|
||||
"start_time": "2020-01-01 00:00:00",
|
||||
"end_time": "2020-01-02 00:00:00",
|
||||
},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.json()["data"]["total"] == 0
|
||||
|
||||
def test_起始晚于结束返回400(self, client: TestClient, admin: dict) -> None:
|
||||
resp = client.get(
|
||||
_OPERATION_LOGS,
|
||||
params={"start_time": "2026-10-02 00:00:00", "end_time": "2026-10-01 00:00:00"},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_列表响应不含请求参数字段(self, client: TestClient, admin: dict) -> None:
|
||||
"""列表为性能排除 request_data(TEXT 大字段),防止翻页负担随日志表增长。"""
|
||||
resp = client.get(_OPERATION_LOGS, headers=admin["headers"])
|
||||
rows = resp.json()["data"]["list"]
|
||||
if rows:
|
||||
assert "request_data" not in rows[0]
|
||||
|
||||
def test_详情端点返回已脱敏的请求参数(self, client: TestClient, admin: dict, make_user) -> None:
|
||||
"""列表 → 详情 的两段式:详情含 request_data 且已脱敏。"""
|
||||
secret = "S3cret@2026!"
|
||||
target = make_user()
|
||||
resp = client.put(
|
||||
f"{_USERS}/{target['id']}/password",
|
||||
json={"new_password": secret},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
resp = client.get(
|
||||
_OPERATION_LOGS,
|
||||
params={"username": admin["username"], "action": "重置密码"},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
row = resp.json()["data"]["list"][0]
|
||||
assert "request_data" not in row
|
||||
|
||||
detail = client.get(f"{_OPERATION_LOGS}/{row['id']}", headers=admin["headers"])
|
||||
assert detail.status_code == 200
|
||||
request_data = detail.json()["data"]["request_data"] or ""
|
||||
assert secret not in request_data
|
||||
assert "***" in request_data
|
||||
|
||||
def test_详情端点不存在返回404(self, client: TestClient, admin: dict) -> None:
|
||||
resp = client.get(f"{_OPERATION_LOGS}/999999999", headers=admin["headers"])
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_详情端点普通用户被拒(self, client: TestClient, normal: dict) -> None:
|
||||
resp = client.get(f"{_OPERATION_LOGS}/1", headers=normal["headers"])
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_普通用户被拒(self, client: TestClient, normal: dict) -> None:
|
||||
assert client.get(_OPERATION_LOGS, headers=normal["headers"]).status_code == 403
|
||||
|
||||
|
||||
class TestLoginLogQuery:
|
||||
"""登录日志查询。"""
|
||||
|
||||
@pytest.fixture
|
||||
def seeded_logs(self):
|
||||
"""直插两条登录日志(一成功一失败),用后清理。"""
|
||||
username = f"t_login_{uuid.uuid4().hex[:6]}"
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
session.add(
|
||||
LoginLog(
|
||||
username=username,
|
||||
ip="10.1.2.3",
|
||||
user_agent="pytest",
|
||||
success=0,
|
||||
fail_reason="用户名或密码错误",
|
||||
)
|
||||
)
|
||||
session.add(LoginLog(username=username, ip="10.1.2.4", user_agent="pytest", success=1))
|
||||
session.commit()
|
||||
yield username
|
||||
with factory() as session:
|
||||
session.execute(delete(LoginLog).where(LoginLog.username == username))
|
||||
session.commit()
|
||||
|
||||
def test_按用户名查询并返回两端结果(
|
||||
self, client: TestClient, admin: dict, seeded_logs: str
|
||||
) -> None:
|
||||
resp = client.get(_LOGIN_LOGS, params={"username": seeded_logs}, headers=admin["headers"])
|
||||
data = resp.json()["data"]
|
||||
assert data["total"] == 2
|
||||
assert {row["success"] for row in data["list"]} == {0, 1}
|
||||
|
||||
def test_按IP精确筛选(self, client: TestClient, admin: dict, seeded_logs: str) -> None:
|
||||
resp = client.get(_LOGIN_LOGS, params={"ip": "10.1.2.3"}, headers=admin["headers"])
|
||||
data = resp.json()["data"]
|
||||
assert data["total"] == 1
|
||||
assert data["list"][0]["fail_reason"] == "用户名或密码错误"
|
||||
|
||||
def test_按结果筛选(self, client: TestClient, admin: dict, seeded_logs: str) -> None:
|
||||
resp = client.get(
|
||||
_LOGIN_LOGS,
|
||||
params={"username": seeded_logs, "success": 0},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
data = resp.json()["data"]
|
||||
assert data["total"] == 1
|
||||
assert data["list"][0]["success"] == 0
|
||||
|
||||
def test_普通用户被拒(self, client: TestClient, normal: dict) -> None:
|
||||
assert client.get(_LOGIN_LOGS, headers=normal["headers"]).status_code == 403
|
||||
@@ -0,0 +1,209 @@
|
||||
"""角色管理接口集成测试(需要 MySQL 与 Redis)。
|
||||
|
||||
守的是角色管理的**保护规则** —— 它们不是界面体验,而是系统自举与授权
|
||||
安全的最低防线:
|
||||
|
||||
- `admin` 角色禁删禁停(cli 建管理员与 require_admin 判定都依赖该编码)
|
||||
- 有用户绑定的角色禁删(user_roles 外键是 CASCADE,直接删会静默剥离权限)
|
||||
- `role_code` 创建后不可改(授权判定的锚点)
|
||||
|
||||
运行前置:``docker compose -f docker/docker-compose.dev.yml up -d``
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.constants import ROLE_USER
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
_ROLES = "/api/v1/system/roles"
|
||||
|
||||
|
||||
def _new_code() -> str:
|
||||
return f"t_role_{uuid.uuid4().hex[:6]}"
|
||||
|
||||
|
||||
def _admin_role_id(client: TestClient, admin: dict) -> int:
|
||||
"""从管理列表取内置 admin 角色的 id。"""
|
||||
resp = client.get(_ROLES, params={"keyword": "admin"}, headers=admin["headers"])
|
||||
rows = resp.json()["data"]["list"]
|
||||
matches = [row for row in rows if row["role_code"] == "admin"]
|
||||
assert matches, "内置 admin 角色缺失"
|
||||
return matches[0]["id"]
|
||||
|
||||
|
||||
class TestCrudFlow:
|
||||
"""正向链路:建 → 查(列表 + 下拉)→ 改 → 停用 → 删。"""
|
||||
|
||||
def test_新增角色并联动列表与下拉(self, client: TestClient, admin: dict) -> None:
|
||||
code = _new_code()
|
||||
resp = client.post(
|
||||
_ROLES,
|
||||
json={"role_code": code, "role_name": "对外协作岗", "sort_order": 50},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
role_id = resp.json()["data"]["id"]
|
||||
assert resp.json()["data"]["user_count"] == 0
|
||||
|
||||
# 管理列表可查(keyword 同时匹配编码与名称)
|
||||
resp = client.get(_ROLES, params={"keyword": code}, headers=admin["headers"])
|
||||
assert resp.json()["data"]["total"] == 1
|
||||
|
||||
# 用户表单下拉包含(启用状态)
|
||||
resp = client.get("/api/v1/system/users/meta/roles", headers=admin["headers"])
|
||||
assert code in [row["role_code"] for row in resp.json()["data"]]
|
||||
|
||||
assert client.delete(f"{_ROLES}/{role_id}", headers=admin["headers"]).status_code == 200
|
||||
|
||||
def test_重复编码被拒(self, client: TestClient, admin: dict, make_role) -> None:
|
||||
role = make_role()
|
||||
resp = client.post(
|
||||
_ROLES,
|
||||
json={"role_code": role["role_code"], "role_name": "重复"},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "已存在" in resp.json()["message"]
|
||||
|
||||
def test_编码格式非法被拒(self, client: TestClient, admin: dict) -> None:
|
||||
resp = client.post(
|
||||
_ROLES, json={"role_code": "BadCode", "role_name": "大写"}, headers=admin["headers"]
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_编辑角色名称与排序(self, client: TestClient, admin: dict, make_role) -> None:
|
||||
role = make_role()
|
||||
resp = client.put(
|
||||
f"{_ROLES}/{role['id']}",
|
||||
json={"role_name": "改名后", "description": "新的说明", "sort_order": 7},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()["data"]
|
||||
assert data["role_name"] == "改名后"
|
||||
assert data["description"] == "新的说明"
|
||||
assert data["sort_order"] == 7
|
||||
|
||||
def test_编码不可被修改(self, client: TestClient, admin: dict, make_role) -> None:
|
||||
"""请求体携带 role_code 会被忽略(编辑模型不接收该字段),编码保持不变。"""
|
||||
role = make_role()
|
||||
resp = client.put(
|
||||
f"{_ROLES}/{role['id']}",
|
||||
json={"role_name": "改名", "role_code": "hacked"},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["data"]["role_code"] == role["role_code"]
|
||||
|
||||
def test_停用后不在下拉但仍在管理列表(self, client: TestClient, admin: dict, make_role) -> None:
|
||||
role = make_role()
|
||||
resp = client.put(
|
||||
f"{_ROLES}/{role['id']}/status", json={"status": 0}, headers=admin["headers"]
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["data"]["status"] == 0
|
||||
|
||||
resp = client.get("/api/v1/system/users/meta/roles", headers=admin["headers"])
|
||||
assert role["role_code"] not in [row["role_code"] for row in resp.json()["data"]]
|
||||
|
||||
resp = client.get(_ROLES, params={"keyword": role["role_code"]}, headers=admin["headers"])
|
||||
assert resp.json()["data"]["total"] == 1
|
||||
|
||||
|
||||
class TestProtection:
|
||||
"""保护规则:内置角色与有绑定的角色。"""
|
||||
|
||||
def test_admin角色不可删除(self, client: TestClient, admin: dict) -> None:
|
||||
role_id = _admin_role_id(client, admin)
|
||||
resp = client.delete(f"{_ROLES}/{role_id}", headers=admin["headers"])
|
||||
assert resp.status_code == 403
|
||||
assert "不可删除" in resp.json()["message"]
|
||||
|
||||
def test_admin角色不可停用(self, client: TestClient, admin: dict) -> None:
|
||||
role_id = _admin_role_id(client, admin)
|
||||
resp = client.put(
|
||||
f"{_ROLES}/{role_id}/status", json={"status": 0}, headers=admin["headers"]
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
assert "不可停用" in resp.json()["message"]
|
||||
|
||||
def test_有绑定的角色不可删除且解绑后可删除(
|
||||
self, client: TestClient, admin: dict, make_role, make_user
|
||||
) -> None:
|
||||
"""`user_roles` 外键是 CASCADE —— 后端 400 拦截是防止静默剥离权限的
|
||||
唯一防线,前端按钮禁用只是体验。"""
|
||||
role = make_role()
|
||||
bound = make_user([role["role_code"]])
|
||||
|
||||
resp = client.delete(f"{_ROLES}/{role['id']}", headers=admin["headers"])
|
||||
assert resp.status_code == 400
|
||||
assert "1 个用户" in resp.json()["message"]
|
||||
|
||||
# 解绑(换回内置 user 角色)后可删除
|
||||
resp = client.put(
|
||||
f"/api/v1/system/users/{bound['id']}",
|
||||
json={"roles": [ROLE_USER]},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert client.delete(f"{_ROLES}/{role['id']}", headers=admin["headers"]).status_code == 200
|
||||
|
||||
def test_列表返回绑定用户数(
|
||||
self, client: TestClient, admin: dict, make_role, make_user
|
||||
) -> None:
|
||||
role = make_role()
|
||||
make_user([role["role_code"]])
|
||||
resp = client.get(_ROLES, params={"keyword": role["role_code"]}, headers=admin["headers"])
|
||||
assert resp.json()["data"]["list"][0]["user_count"] == 1
|
||||
|
||||
def test_编辑不存在的角色404(self, client: TestClient, admin: dict) -> None:
|
||||
resp = client.put(
|
||||
f"{_ROLES}/999999999", json={"role_name": "不存在"}, headers=admin["headers"]
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_停用角色不可再分配给用户(
|
||||
self, client: TestClient, admin: dict, make_role, make_user
|
||||
) -> None:
|
||||
"""回归:停用只挡住了表单下拉,接口层此前仍可绑定(或使用停用前
|
||||
加载的旧选项提交)—— 与「停用后不可再分配」的语义矛盾。"""
|
||||
role = make_role()
|
||||
resp = client.put(
|
||||
f"{_ROLES}/{role['id']}/status", json={"status": 0}, headers=admin["headers"]
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
username = f"t_{uuid.uuid4().hex[:8]}"
|
||||
resp = client.post(
|
||||
"/api/v1/system/users",
|
||||
json={"username": username, "password": "TestPwd@2026", "roles": [role["role_code"]]},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "停用" in resp.json()["message"]
|
||||
|
||||
# 已绑定该角色的用户(历史数据)改回启用角色后可正常保存
|
||||
bound = make_user([role["role_code"]])
|
||||
resp = client.put(
|
||||
f"/api/v1/system/users/{bound['id']}",
|
||||
json={"roles": [ROLE_USER]},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
class TestAuthorization:
|
||||
"""权限:角色管理为管理员专属。"""
|
||||
|
||||
def test_普通用户访问被拒(self, client: TestClient, normal: dict) -> None:
|
||||
assert client.get(_ROLES, headers=normal["headers"]).status_code == 403
|
||||
resp = client.post(
|
||||
_ROLES,
|
||||
json={"role_code": _new_code(), "role_name": "越权"},
|
||||
headers=normal["headers"],
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
@@ -0,0 +1,364 @@
|
||||
"""用户管理接口集成测试(需要 MySQL 与 Redis)。
|
||||
|
||||
守的是:用户 CRUD 的**串联正确性**与**审计留痕**。
|
||||
|
||||
审计是重点验收项:每个写操作(含失败的)都必须在 operation_logs 留下
|
||||
可读记录 —— 模块、操作、摘要、操作人、请求元信息完整,且 request_data
|
||||
中**绝无明文密码**;账号被删除后审计行通过「user_id 置空 + 冗余 username」
|
||||
保留可追溯性。
|
||||
|
||||
运行前置:``docker compose -f docker/docker-compose.dev.yml up -d``
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.db import get_session_factory
|
||||
from app.models import OperationLog
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
_PASSWORD = "TestPwd@2026"
|
||||
_USERS = "/api/v1/system/users"
|
||||
|
||||
|
||||
def _new_username() -> str:
|
||||
return f"t_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
def _create_via_api(client: TestClient, admin: dict, username: str, **overrides: object) -> dict:
|
||||
"""经接口创建用户并返回响应 data(账号归 autouse 清理兜底)。"""
|
||||
payload: dict = {
|
||||
"username": username,
|
||||
"password": _PASSWORD,
|
||||
"real_name": "接口新用户",
|
||||
"roles": ["user"],
|
||||
}
|
||||
payload.update(overrides)
|
||||
resp = client.post(_USERS, json=payload, headers=admin["headers"])
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()["data"]
|
||||
|
||||
|
||||
def _logs_of(username: str) -> list[OperationLog]:
|
||||
"""取某操作人最近的审计行(按 id 倒序)。"""
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
rows = list(
|
||||
session.execute(
|
||||
select(OperationLog)
|
||||
.where(OperationLog.username == username)
|
||||
.order_by(OperationLog.id.desc())
|
||||
).scalars()
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
class TestCrudFlow:
|
||||
"""正向链路:建 → 查 → 改 → 启停 → 重置密码 → 删。"""
|
||||
|
||||
def test_新增用户并可在列表查到(self, client: TestClient, admin: dict) -> None:
|
||||
username = _new_username()
|
||||
data = _create_via_api(client, admin, username)
|
||||
assert data["username"] == username
|
||||
assert data["roles"] == ["user"]
|
||||
assert data["status"] == 1
|
||||
|
||||
resp = client.get(_USERS, params={"username": username}, headers=admin["headers"])
|
||||
body = resp.json()["data"]
|
||||
assert body["total"] == 1
|
||||
assert body["list"][0]["id"] == data["id"]
|
||||
|
||||
def test_重名用户被拒(self, client: TestClient, admin: dict, make_user) -> None:
|
||||
target = make_user()
|
||||
resp = client.post(
|
||||
_USERS,
|
||||
json={"username": target["username"], "password": _PASSWORD, "roles": ["user"]},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "已存在" in resp.json()["message"]
|
||||
|
||||
def test_分页参数以驼峰命名生效(self, client: TestClient, admin: dict) -> None:
|
||||
"""pageNum/pageSize 是前端契约(PageQuery 的 alias)。
|
||||
|
||||
防回归:若被改成蛇形命名,FastAPI 会忽略这两个参数、恒返回
|
||||
第 1 页 10 条 —— 表现为「翻页没反应」,且没有任何报错。
|
||||
"""
|
||||
resp = client.get(_USERS, params={"pageNum": 2, "pageSize": 5}, headers=admin["headers"])
|
||||
data = resp.json()["data"]
|
||||
assert {"list", "total", "pageNum", "pageSize"} <= set(data)
|
||||
assert data["pageNum"] == 2
|
||||
assert data["pageSize"] == 5
|
||||
assert len(data["list"]) <= 5
|
||||
|
||||
def test_编辑用户信息与角色(self, client: TestClient, admin: dict, make_user) -> None:
|
||||
target = make_user()
|
||||
resp = client.put(
|
||||
f"{_USERS}/{target['id']}",
|
||||
json={"real_name": "改后姓名", "roles": ["admin"]},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()["data"]
|
||||
assert data["real_name"] == "改后姓名"
|
||||
assert data["roles"] == ["admin"]
|
||||
|
||||
def test_编辑用户保留既有角色不触发唯一键冲突(
|
||||
self, client: TestClient, admin: dict, make_user
|
||||
) -> None:
|
||||
"""回归:新旧角色集合有交集时(编辑时未改角色必然如此),
|
||||
`replace_roles` 若在 clear 后不先落库删除,SQLAlchemy 同一 flush
|
||||
内先 INSERT 后 DELETE 会撞 uk_user_roles_user_id_role_id(1062)。
|
||||
该缺陷最初由前端 e2e「编辑用户」步骤暴露 —— 集成测试当时只覆盖
|
||||
了「换掉全部角色」的场景。"""
|
||||
target = make_user(["user"])
|
||||
resp = client.put(
|
||||
f"{_USERS}/{target['id']}",
|
||||
json={"real_name": "保留角色", "roles": ["user", "admin"]},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert sorted(resp.json()["data"]["roles"]) == ["admin", "user"]
|
||||
|
||||
def test_禁用与重新启用(self, client: TestClient, admin: dict, make_user) -> None:
|
||||
target = make_user()
|
||||
resp = client.put(
|
||||
f"{_USERS}/{target['id']}/status", json={"status": 0}, headers=admin["headers"]
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["data"]["status"] == 0
|
||||
|
||||
resp = client.put(
|
||||
f"{_USERS}/{target['id']}/status", json={"status": 1}, headers=admin["headers"]
|
||||
)
|
||||
assert resp.json()["data"]["status"] == 1
|
||||
|
||||
def test_禁用后旧令牌立即失效(
|
||||
self, client: TestClient, admin: dict, make_user, auth_headers
|
||||
) -> None:
|
||||
"""禁用会提 token_version —— 不依赖 Redis 的「离职即失效」。"""
|
||||
target = make_user()
|
||||
old_headers = auth_headers(target)
|
||||
assert client.get("/api/v1/auth/me", headers=old_headers).status_code == 200
|
||||
|
||||
client.put(f"{_USERS}/{target['id']}/status", json={"status": 0}, headers=admin["headers"])
|
||||
assert client.get("/api/v1/auth/me", headers=old_headers).status_code == 401
|
||||
|
||||
def test_重置密码后旧令牌立即失效(
|
||||
self, client: TestClient, admin: dict, make_user, auth_headers
|
||||
) -> None:
|
||||
target = make_user()
|
||||
old_headers = auth_headers(target)
|
||||
assert client.get("/api/v1/auth/me", headers=old_headers).status_code == 200
|
||||
|
||||
resp = client.put(
|
||||
f"{_USERS}/{target['id']}/password",
|
||||
json={"new_password": "NewPwd@2026"},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert client.get("/api/v1/auth/me", headers=old_headers).status_code == 401
|
||||
|
||||
def test_删除用户(self, client: TestClient, admin: dict, make_user) -> None:
|
||||
target = make_user()
|
||||
resp = client.delete(f"{_USERS}/{target['id']}", headers=admin["headers"])
|
||||
assert resp.status_code == 200
|
||||
|
||||
resp = client.get(f"{_USERS}/{target['id']}", headers=admin["headers"])
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestSelfProtection:
|
||||
"""自我保护:不允许对当前登录账号执行危险操作。"""
|
||||
|
||||
def test_不能删除自己(self, client: TestClient, admin: dict) -> None:
|
||||
resp = client.delete(f"{_USERS}/{admin['id']}", headers=admin["headers"])
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_不能禁用自己(self, client: TestClient, admin: dict) -> None:
|
||||
resp = client.put(
|
||||
f"{_USERS}/{admin['id']}/status", json={"status": 0}, headers=admin["headers"]
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_不能移除自己的管理员角色(self, client: TestClient, admin: dict) -> None:
|
||||
resp = client.put(
|
||||
f"{_USERS}/{admin['id']}", json={"roles": ["user"]}, headers=admin["headers"]
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
class TestValidation:
|
||||
"""参数与业务校验。"""
|
||||
|
||||
def test_无效角色编码被拒(self, client: TestClient, admin: dict) -> None:
|
||||
resp = client.post(
|
||||
_USERS,
|
||||
json={"username": _new_username(), "password": _PASSWORD, "roles": ["nonexist"]},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "角色编码无效" in resp.json()["message"]
|
||||
|
||||
def test_空角色列表被拒(self, client: TestClient, admin: dict) -> None:
|
||||
resp = client.post(
|
||||
_USERS,
|
||||
json={"username": _new_username(), "password": _PASSWORD, "roles": []},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_密码25个汉字被拒(self, client: TestClient, admin: dict) -> None:
|
||||
"""75 字节超 bcrypt 上限;按字符数校验拦不住(25 字符很短)。"""
|
||||
resp = client.post(
|
||||
_USERS,
|
||||
json={"username": _new_username(), "password": "中" * 25, "roles": ["user"]},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_查询不存在的用户返回404(self, client: TestClient, admin: dict) -> None:
|
||||
resp = client.get(f"{_USERS}/999999999", headers=admin["headers"])
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_编辑未修改字段不污染空值语义(self, client: TestClient, admin: dict, make_user) -> None:
|
||||
"""回归:编辑表单把未修改字段以空串提交,此前会把库中 NULL 写成 ''——
|
||||
两类空值(未填 vs 空串)语义漂移且不可逆;'' 现归一为 NULL。"""
|
||||
target = make_user() # 直建账号 phone/email/remark 均为 NULL
|
||||
resp = client.put(
|
||||
f"{_USERS}/{target['id']}",
|
||||
json={"real_name": "改名", "phone": "", "email": "", "remark": ""},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()["data"]
|
||||
assert data["real_name"] == "改名"
|
||||
assert data["phone"] is None
|
||||
assert data["email"] is None
|
||||
assert data["remark"] is None
|
||||
|
||||
|
||||
class TestFiltering:
|
||||
"""筛选条件的口径。"""
|
||||
|
||||
def test_含下划线的用户名不误配(self, client: TestClient, admin: dict, make_user) -> None:
|
||||
"""回归:模糊筛选未转义 LIKE 通配符时 `_` 匹配任意单字符,
|
||||
搜 t_xx 会命中 txXx 之类无关账号 —— 用户名普遍含下划线,非理论场景。"""
|
||||
target = make_user()
|
||||
decoy_name = target["username"].replace("_", "x", 1)
|
||||
make_user(username=decoy_name)
|
||||
|
||||
resp = client.get(_USERS, params={"username": target["username"]}, headers=admin["headers"])
|
||||
names = [row["username"] for row in resp.json()["data"]["list"]]
|
||||
assert target["username"] in names
|
||||
assert decoy_name not in names
|
||||
|
||||
|
||||
class TestAuditTrail:
|
||||
"""审计留痕 —— 本模块的核心验收点。"""
|
||||
|
||||
def test_每个写操作都留下审计记录(self, client: TestClient, admin: dict, make_user) -> None:
|
||||
target = make_user()
|
||||
client.put(
|
||||
f"{_USERS}/{target['id']}",
|
||||
json={"real_name": "审计留痕"},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
client.put(f"{_USERS}/{target['id']}/status", json={"status": 0}, headers=admin["headers"])
|
||||
client.put(
|
||||
f"{_USERS}/{target['id']}/password",
|
||||
json={"new_password": "AuditPwd@2026"},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
client.delete(f"{_USERS}/{target['id']}", headers=admin["headers"])
|
||||
|
||||
logs = _logs_of(admin["username"])
|
||||
actions = {(log.module, log.action) for log in logs}
|
||||
assert ("用户管理", "修改") in actions
|
||||
assert ("用户管理", "禁用") in actions
|
||||
assert ("用户管理", "重置密码") in actions
|
||||
assert ("用户管理", "删除") in actions
|
||||
|
||||
# 摘要含目标用户名(供人阅读的追溯锚点)
|
||||
assert any(target["username"] in log.summary for log in logs)
|
||||
|
||||
# 请求元信息与结果完整
|
||||
success_logs = [log for log in logs if log.success == 1]
|
||||
assert success_logs
|
||||
for log in success_logs:
|
||||
assert log.method and log.path
|
||||
assert log.duration_ms is not None and log.duration_ms >= 0
|
||||
|
||||
def test_失败操作记为失败且带中文原因(self, client: TestClient, admin: dict) -> None:
|
||||
resp = client.delete(f"{_USERS}/999999999", headers=admin["headers"])
|
||||
assert resp.status_code == 404
|
||||
|
||||
latest = _logs_of(admin["username"])[0]
|
||||
assert latest.success == 0
|
||||
assert latest.action == "删除"
|
||||
assert latest.error_message == "用户不存在"
|
||||
|
||||
def test_审计请求参数绝不包含明文密码(self, client: TestClient, admin: dict, make_user) -> None:
|
||||
"""密码是审计脱敏的红线:全表扫描该操作人的 request_data 不得命中。"""
|
||||
secret = "S3cret@2026!"
|
||||
_create_via_api(client, admin, _new_username(), password=secret)
|
||||
target = make_user()
|
||||
client.put(
|
||||
f"{_USERS}/{target['id']}/password",
|
||||
json={"new_password": secret},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
rows = list(
|
||||
session.execute(
|
||||
select(OperationLog.request_data).where(
|
||||
OperationLog.username == admin["username"]
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
joined = "\n".join(row for row in rows if row)
|
||||
assert secret not in joined
|
||||
assert "***" in joined # 键名保留、值被抹除
|
||||
|
||||
def test_账号删除后审计行保留用户名(
|
||||
self, client: TestClient, admin: dict, make_user, auth_headers
|
||||
) -> None:
|
||||
"""user_id 外键 SET NULL + username 冗余 —— 删除账号不得让审计断链。"""
|
||||
victim = make_user(["admin"])
|
||||
victim_headers = auth_headers(victim)
|
||||
# victim 作为操作人产生一条审计(失败操作最省事,不留业务数据)
|
||||
client.delete(f"{_USERS}/999999999", headers=victim_headers)
|
||||
|
||||
assert (
|
||||
client.delete(f"{_USERS}/{victim['id']}", headers=admin["headers"]).status_code == 200
|
||||
)
|
||||
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
logs = list(
|
||||
session.execute(
|
||||
select(OperationLog).where(OperationLog.username == victim["username"])
|
||||
).scalars()
|
||||
)
|
||||
assert logs, "审计行不应随账号删除而消失"
|
||||
assert all(log.user_id is None for log in logs)
|
||||
assert all(log.username == victim["username"] for log in logs)
|
||||
|
||||
|
||||
class TestAuthorization:
|
||||
"""权限:系统管理为管理员专属。"""
|
||||
|
||||
def test_普通用户访问用户管理被拒(self, client: TestClient, normal: dict) -> None:
|
||||
assert client.get(_USERS, headers=normal["headers"]).status_code == 403
|
||||
resp = client.post(
|
||||
_USERS,
|
||||
json={"username": _new_username(), "password": _PASSWORD, "roles": ["user"]},
|
||||
headers=normal["headers"],
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
Reference in new issue
Block a user