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:
liuwenxinandClaude Code committed 2026-10-10 16:28:04 +08:00
1 parent e22c4bcdc9
commit c83287819a
32 files changed
+3158 -137

No files matched your search

+27 -3
View File
@@ -1,6 +1,6 @@
# 后端测试
> AI摘要:本项目后端的测试用例清单与运行记录。按「基础设施层随实现即时测、业务模块整模块完成时统一测」分层组织。当前 184 个用例全绿(单元 155 / 集成 29)。
> AI摘要:本项目后端的测试用例清单与运行记录。按「基础设施层随实现即时测、业务模块整模块完成时统一测」分层组织。当前 301 个用例全绿(单元 223 / 集成 78)。
## 一、测试约定
@@ -57,6 +57,23 @@ uv run pytest tests/integration -q # 仅集成
| `unit/test_auth_schemas.py` | 密码**按字节**校验 | 9 | 24 个汉字 = 72 字节正好、25 个被拒 —— 按字符数校验(如 `max_length=20`)根本拦不住,会让登录接口 500 |
| `integration/test_auth_flow.py` | 整条链路**串联正确性** | 16 | 验证码→登录→持令牌访问→登出失效;验证码重放被拒;失败原因如实落库且**日志表无任何密码字段**;账号不存在与密码错误文案完全相同(防枚举);禁用账号与 `token_version` 提升均使旧令牌立即失效 |
### 系统管理 · 用户与角色管理
| 测试文件 | 守什么 | 用例数 | 关键覆盖点 |
| --- | --- | :---: | --- |
| `unit/test_audit_service.py` | 审计上下文的语义 | 16 | 正常退出记成功、抛异常记失败;**失败时先 rollback 再写日志**且原异常原样重抛(顺序颠倒时数据库异常会让失败记录丢成 PendingRollbackError);**操作人标量在进入时固化**(失败路径不再触碰会过期的 ORM 对象);进入时脏会话告警(防嵌套半提交);审计自身失败不外抛、不替换业务异常 |
| `unit/test_audit_serialization.py` | 入库前截断与脱敏不丢数据、不泄密 | 16 | 各列按模型常量截断;60KB 请求参数按 **UTF-8 字节边界**截断后是原串的干净前缀(中文不切半);**结构化抹除**(按敏感键名整值替换):前导空白/空格/逗号/引号密码全被抹除且 JSON 结构完好、嵌套 dict/list 与键名大小写、非敏感键不误伤;手机号仍由 scrub 文本兜底;孤立代理字符不再抛错丢审计 |
| `unit/test_role_schemas.py` | 角色编码格式红线 | 16 | `^[a-z][a-z0-9_]{1,31}$` 全边界(大写/中文/数字或下划线开头/单字符/超长被拒);编辑模型**不接受 role_code**(多余字段忽略);状态只能 0/1 |
| `integration/test_user_management.py` | 用户 CRUD 串联正确性 + **审计留痕** | 23 | 分页以 `pageNum/pageSize` 生效(钉死别名契约,改蛇形会静默失效);自删/自禁/移除自己 admin 角色 403;禁用与重置密码使旧令牌立即失效;**每个写操作(含失败的)在 operation_logs 有对应记录**、request_data 全文扫不到明文密码;账号删除后审计行 `user_id` 置空而 username 保留;**编辑时保留既有角色不撞唯一键**(1062 回归);**编辑未修改字段不把 NULL 污染成空串**;**含下划线用户名不误配**(LIKE 转义回归) |
| `integration/test_role_management.py` | 角色保护规则(系统自举与授权安全的最低防线) | 13 | admin 角色禁删禁停 403;有绑定用户的角色禁删 400 且消息含用户数、解绑后可删;编码创建后不可改;**停用角色不可再分配**(接口层拦截);停用后不在 `/users/meta/roles` 但仍在管理列表;`user_count` 正确;普通用户 403 |
### 系统管理 · 日志查询
| 测试文件 | 守什么 | 用例数 | 关键覆盖点 |
| --- | --- | :---: | --- |
| `unit/test_log_schemas.py` | 时间范围校验与时区归一 | 13 | 起始晚于结束拒绝(两类日志共用基类,放过去则 SQL 条件互斥、查询恒空且无提示);**带时区输入转本地 naive**(Z 后缀 → 东八区 08:00;混用 aware/naive 不再抛 TypeError 穿透 500);边界相等通过;仅传一端不校验 |
| `integration/test_log_query.py` | 写入与查询的**口径一致** | 13 | 写操作产生的审计可按 username/module/action/success/时间范围检索到,摘要含目标用户名;失败记录带中文 `error_message`;**列表不含 request_data、详情端点返回已脱敏参数**(两段式,含 404/403);起始晚于结束 400;登录日志直插造数后可按 ip 精确筛选;两类日志普通用户 403 |
### 应用骨架与交付产物
| 测试文件 | 守什么 | 用例数 | 关键覆盖点 |
@@ -71,6 +88,8 @@ uv run pytest tests/integration -q # 仅集成
| 2026-10-09 | 全部 | 120 | ✅ 通过(4.99s) | 阶段 1 骨架 + 阶段 2 数据库完成后首次全量运行 |
| 2026-10-09 | 全部 | 119 | ✅ 通过(5.01s) | ORM 由异步改回同步后回归;净减 1 例(`sync_database_url` 独立配置已合并) |
| 2026-10-09 | 全部 | 184 | ✅ 通过(25.46s) | **登录鉴权模块完成**后统一编写并执行该模块测试套件(新增 65 例)。集成用例需容器在跑 |
| 2026-10-10 | 全部 | 282 | ✅ 通过(103.81s) | **系统管理模块(用户/角色/日志)完成**后统一编写该模块套件(新增 91 例:单元 48 / 集成 43)。含操作日志审计机制专项;**e2e 发现并修复 `replace_roles` 唯一键缺陷**(见四、缺陷表);集成用例需容器在跑 |
| 2026-10-10 | 全部 | 301 | ✅ 通过(87.40s) | **code-review 缺陷修复后全量回归**(新增/改造 19 例)。密码结构化脱敏、时区归一、日志详情两段式各有专项;集成套件因密码哈希模块级预计算提速(63s→42s) |
## 四、测试发现并修复的缺陷
@@ -85,14 +104,19 @@ uv run pytest tests/integration -q # 仅集成
| 2026-10-09 | 验证码沿用 `captcha` 库默认渲染 | 随机彩色字符 + 重噪点,与界面视觉体系冲突;该库最后更新于 2025-03,内部还引用了 Pillow 的旧别名 | 改为自绘(`core/captcha_image.py`):配色与界面统一,难度由逐字旋转、抖动、干扰曲线与噪点保证;仅沿用其内置字体文件 |
| 2026-10-09 | 噪点用 `ellipse` 绘制 | 3×3 包围盒画出的椭圆恰好填充中心与四个边中点,形状就是「+」,形成规则的十字纹理——既显眼又容易被整体滤波抹掉,起不到干扰作用 | 改用单像素点与 2×2 方块 |
| 2026-10-09 | `tests/unit/test_jwt.py` 两处赋值了未使用的 `settings`(ruff F841) | 变量未被引用,属无效代码 | 删除赋值;该文件的 `get_settings` 导入仍被其他用例使用 |
| 2026-10-10 | `replace_roles` 先清后建但中间未落库 | SQLAlchemy 在同一 flush 中**先处理 INSERT 再处理 DELETE**,编辑用户保留既有角色时重插同 `(user_id, role_id)` 撞唯一键(1062)——编辑用户接口 500。**由前端 e2e「编辑用户」步骤暴露**:集成测试当时只覆盖了「换掉全部角色」(无交集)的路径 | `clear()` 后立即 `session.flush()` 把删除落库,再追加新集合;补集成回归用例(编辑时保留既有角色) |
| 2026-10-10 | 审计脱敏依赖文本正则 | 三种逃逸形态:密码**以空白开头时完全不脱敏**、含空格/逗号只抹首段、含引号既残留明文又破坏 JSON——明文/残段写入 `operation_logs.request_data` 且日志页可直接展开查看(**由 code-review 发现并逐条实证复现**;既有测试所用密码均不含空格,恰好掩盖该路径) | 改为按键名的**结构化抹除**(递归 dict/list 整值替换 `***`),scrub 降级为文本兜底;补前导空白/空格/引号/嵌套/大小写五类边界用例 |
| 2026-10-10 | `.gitignore` 的 `logs/` 规则吞掉日志查询页目录 | 规则无前导斜杠、匹配任意层级:`frontend/src/views/system/logs/` 整体被忽略,文件**进不了版本库**——干净克隆构建失败(本地因文件已在磁盘上而一切"正常",隐蔽性极强) | 收窄为 `/backend/logs/`(该规则本意即后端运行日志);以 `git check-ignore` 实证不再命中 |
| 2026-10-10 | 时间筛选未做时区归一 | 混用 aware/naive 抛 TypeError 穿透成 **500**;带时区输入被驱动静默丢弃偏移(查询窗口偏 8 小时且无提示) | `LogQueryBase` 加 field_validator:aware → `astimezone()` 本地 naive、naive 原样;补 4 例专项 |
| 2026-10-10 | 审计时序两处缺陷 | ① 失败路径 rollback 后读 operator 触发额外查询,账号并发被删时 ObjectDeletedError 被兜底吞掉、失败审计静默丢失;② 成功/失败两分支 8 个实参重复展开,且 role/user service 在 commit 之后仍做查询与响应构造——后置查询一旦抛错,「已提交成功」会被记成失败 | operator 标量进入时固化;审计链 DTO 化(`audit` 4 参 / `_safe_write` 2 参 / `add` 2 参);service 的 commit 后查询与响应构造全部前置 |
## 五、待补用例
| 模块 | 状态 | 说明 |
| --- | --- | --- |
| 系统管理 · 用户与角色管理 | 待编写 | 需求 2.5.1 的用户增删改查、密码重置、启用/禁用、角色分配 — 该模块功能尚未实现 |
| 系统管理 · 操作日志 | 待编写 | 除登录日志外的操作审计(当前仅登录日志落库) |
| 基础数据管理 | 未开始 | 依赖 T+ 对接方式确认 |
| 业务数据管理 | 未开始 | 依赖需求澄清(回款天数口径等 10 项) |
| 绩效工资核算 | 未开始 | 依赖需求澄清;金额精度需专项用例(`Fraction` 与 1/90 日斜率) |
| 报表统计 | 未开始 | 依赖报表口径确认 |
> 系统管理模块(用户/角色/日志查询)已于 2026-10-10 完成并补齐用例,见上表。
+169
View File
@@ -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
+198
View File
@@ -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
@@ -0,0 +1,128 @@
"""操作日志入库前处理单元测试。
守的是入库前的**截断与脱敏**:
- 截断:各列超长在 MySQL 严格模式下抛 1406,而「审计失败不影响业务」的约定
会把该异常吞掉 —— 整条日志静默消失;request_data 尤甚,中文一个字符
占 3 字节,必须按 **UTF-8 字节边界**切,否则会切出乱码残字
- 脱敏:密码是最容易从日志泄漏的凭据,request_data 入库前必经 scrub
"""
import json
from datetime import datetime
from app.models.operation_log import (
ERROR_MESSAGE_MAX_LEN,
REQUEST_DATA_MAX_BYTES,
SUMMARY_MAX_LEN,
)
from app.repositories.operation_log_repository import _clip, _serialize_request_data
class TestClip:
"""按字符数截断(长度常量与数据库列宽均按字符定义)。"""
def test_超长截断至上限(self) -> None:
result = _clip("x" * (SUMMARY_MAX_LEN + 50), SUMMARY_MAX_LEN)
assert result is not None
assert len(result) == SUMMARY_MAX_LEN
def test_未超长原样返回(self) -> None:
assert _clip("正常长度", SUMMARY_MAX_LEN) == "正常长度"
def test_空值返回None(self) -> None:
assert _clip(None, ERROR_MESSAGE_MAX_LEN) is None
assert _clip("", ERROR_MESSAGE_MAX_LEN) is None
def test_中文按字符数截断不切字(self) -> None:
"""str 切片天然按字符,不会切出半个汉字。"""
result = _clip("中" * 100, 10)
assert result == "中" * 10
class TestSerializeRequestData:
"""序列化:JSON 化 → 脱敏 → 字节边界截断。"""
def test_常规参数原样序列化(self) -> None:
text = _serialize_request_data({"status": 0, "roles": ["user"]})
assert text is not None
assert json.loads(text) == {"status": 0, "roles": ["user"]}
def test_空值返回None(self) -> None:
assert _serialize_request_data(None) is None
assert _serialize_request_data({}) is None
def test_密码值被抹除而键名保留(self) -> None:
"""键名保留便于审计阅读"提交过密码字段",值必须抹掉。"""
for payload in ({"password": "S3cret@2026"}, {"new_password": "S3cret@2026"}):
text = _serialize_request_data(payload) or ""
assert "S3cret@2026" not in text
assert "***" in text
def test_手机号被脱敏(self) -> None:
text = _serialize_request_data({"phone": "13812345678"}) or ""
assert "13812345678" not in text
assert "138****5678" in text
def test_超长中文按字节边界截断且无残字(self) -> None:
"""75000 字节(25000 个汉字)必然超限:
若按字符截断则字节数三倍超标;若按字节硬切可能切出半个字符。
正确结果应是原始 JSON 串的**干净前缀**。"""
payload = {"remark": "中" * 25000}
text = _serialize_request_data(payload)
assert text is not None
assert len(text.encode("utf-8")) <= REQUEST_DATA_MAX_BYTES
assert "�" not in text # 无替换字符
raw = json.dumps(payload, ensure_ascii=False)
assert raw.startswith(text)
def test_未超长的中文完整保留(self) -> None:
payload = {"remark": "中" * 100}
text = _serialize_request_data(payload)
assert text is not None
assert json.loads(text) == payload
def test_不可JSON序列化的值降级为字符串(self) -> None:
text = _serialize_request_data({"at": datetime(2026, 10, 10, 12, 0, 0)})
assert text is not None
assert "2026-10-10" in text
class TestStructuredSanitization:
"""结构化抹除 —— 按键名整值替换,不依赖文本正则。
回归背景:此前用文本正则脱敏,有三种逃逸形态 —— 值以空白开头时
整体匹配失败(**完全不脱敏**);值含空格/逗号时只抹首段;值含引号时
既残留明文又破坏 JSON 结构。密码校验只查字节数、前后端都不 trim,
这些形态都是正常可用密码。
"""
def test_前导空白密码被完全抹除(self) -> None:
"""回归:此前 `" abc12345"` 原样明文入库。"""
text = _serialize_request_data({"new_password": " abc12345"}) or ""
assert "abc12345" not in text
assert json.loads(text) == {"new_password": "***"}
def test_含空格逗号引号均整体抹除且JSON完整(self) -> None:
for password in ("abc 12345", "abc,def", 'abc"def', "abc&def"):
text = _serialize_request_data({"password": password}) or ""
assert password not in text
assert json.loads(text) == {"password": "***"}
def test_嵌套结构与键名大小写(self) -> None:
text = (
_serialize_request_data({"user": {"New_Password": "s3"}, "list": [{"TOKEN": "t"}]})
or ""
)
assert "s3" not in text
assert json.loads(text) == {"user": {"New_Password": "***"}, "list": [{"TOKEN": "***"}]}
def test_非敏感键不受影响(self) -> None:
text = _serialize_request_data({"keyword": "a_b", "remark": "正常备注"}) or ""
assert json.loads(text) == {"keyword": "a_b", "remark": "正常备注"}
def test_孤立代理字符不再抛错(self) -> None:
"""回归:直接 encode('utf-8') 遇 \\ud800 抛 UnicodeEncodeError,
该请求的审计记录会被兜底逻辑吞掉、静默丢失。"""
text = _serialize_request_data({"username": "a\ud800b"})
assert text is not None
+268
View File
@@ -0,0 +1,268 @@
"""操作日志审计服务单元测试。
守的是 `audit()` 上下文管理器的语义:
- 正常退出记成功、抛异常记失败,**失败记录必须先生成再让异常继续传播**
- 失败路径必须先 `rollback` 再写日志 —— 业务异常若来自 IntegrityError 等
数据库错误,不回滚时 session 处于待回滚状态,写日志会抛 PendingRollbackError,
最该留下的失败记录反而被静默丢掉
- 操作人信息在进入上下文时**固化为标量** —— 失败路径 rollback 会过期 ORM
对象,再读属性会触发额外查询,账号并发被删时甚至 ObjectDeletedError
令失败审计静默丢失
- 审计自身的任何失败都不得外抛("审计可丢,业务不可断")
"""
import logging
from datetime import datetime
from types import SimpleNamespace
import pytest
from app.core.exceptions import BadRequestError
from app.models import OperationLog
from app.services.audit_service import AuditRecord, RequestMeta, audit
class _StubSession:
"""最小会话桩:记录调用顺序与写入的日志对象,可注入失败。"""
def __init__(self) -> None:
self.events: list[str] = []
self.logs: list[OperationLog] = []
self.fail_add = False
self.fail_commit = False
# audit 入口会巡检未提交变更(嵌套调用防护)
self.new: list = []
self.dirty: list = []
self.deleted: list = []
def add(self, obj: object) -> None:
if self.fail_add:
raise RuntimeError("模拟写入失败")
self.events.append("add")
self.logs.append(obj) # type: ignore[arg-type]
def flush(self) -> None:
self.events.append("flush")
def commit(self) -> None:
if self.fail_commit:
raise RuntimeError("模拟提交失败")
self.events.append("commit")
def rollback(self) -> None:
self.events.append("rollback")
class _FrozenOperator:
"""只允许读取固定轮数属性的操作人桩。
模拟「rollback 后 ORM 对象过期」:若失败路径仍读取 operator 属性,
真实环境会触发额外 SELECT(且账号并发被删时抛 ObjectDeletedError),
此处直接以异常暴露该访问。audit 入口读 id + username 恰为 2 次。
"""
def __init__(self, max_reads: int = 2) -> None:
self._max_reads = max_reads
self._reads = 0
def _touch(self) -> None:
self._reads += 1
if self._reads > self._max_reads:
raise RuntimeError("进入审计后不应再访问操作人 ORM 属性")
@property
def id(self) -> int:
self._touch()
return 7
@property
def username(self) -> str:
self._touch()
return "admin"
def _operator() -> SimpleNamespace:
"""模拟已认证用户(audit 只读取 id 与 username 两个属性)。"""
return SimpleNamespace(id=7, username="admin")
def _record(**overrides: object) -> AuditRecord:
base: dict = {"module": "用户管理", "action": "新增", "summary": "新增用户 zhangsan"}
base.update(overrides)
return AuditRecord(**base)
def _audit(
stub: _StubSession,
*,
request_data: dict | None = None,
meta: RequestMeta | None = None,
):
return audit(
stub, # type: ignore[arg-type]
operator=_operator(),
record=_record(request_data=request_data),
meta=meta,
)
class TestSuccessPath:
"""正常退出:业务完成后记成功并独立提交。"""
def test_成功路径写入一条成功日志(self) -> None:
stub = _StubSession()
with _audit(stub):
pass
assert len(stub.logs) == 1
log = stub.logs[0]
assert log.success == 1
assert log.error_message is None
assert log.module == "用户管理"
assert log.action == "新增"
assert log.username == "admin"
assert log.user_id == 7
def test_日志提交发生在业务逻辑之后(self) -> None:
"""审计是业务的"后置动作":业务先 commit,日志再独立 commit。"""
stub = _StubSession()
with _audit(stub):
stub.events.append("business")
assert stub.events == ["business", "add", "flush", "commit"]
def test_请求元信息与耗时被写入(self) -> None:
stub = _StubSession()
meta = RequestMeta(
ip="10.0.0.8", user_agent="pytest-agent", method="POST", path="/api/v1/system/users"
)
with _audit(stub, meta=meta):
pass
log = stub.logs[0]
assert log.method == "POST"
assert log.path == "/api/v1/system/users"
assert log.ip == "10.0.0.8"
assert log.user_agent == "pytest-agent"
assert log.duration_ms is not None
assert log.duration_ms >= 0
def test_请求参数经脱敏后写入(self) -> None:
stub = _StubSession()
with _audit(stub, request_data={"username": "zhangsan", "password": "Secret@2026"}):
pass
request_data = stub.logs[0].request_data or ""
assert "Secret@2026" not in request_data
assert "zhangsan" in request_data
class TestFailurePath:
"""业务异常:先回滚、记失败、异常原样重抛。"""
def test_失败时原异常原样重抛(self) -> None:
stub = _StubSession()
with pytest.raises(BadRequestError, match="角色编码无效"), _audit(stub):
raise BadRequestError("角色编码无效")
def test_失败记录在回滚之后写入(self) -> None:
"""顺序断言:rollback 必须早于 add。颠倒时,来自数据库异常的失败
记录会因 session 待回滚而抛 PendingRollbackError,整条丢失。"""
stub = _StubSession()
with pytest.raises(BadRequestError), _audit(stub):
stub.events.append("business")
raise BadRequestError("用户名重复")
assert stub.events == ["business", "rollback", "add", "flush", "commit"]
log = stub.logs[0]
assert log.success == 0
assert log.error_message == "用户名重复"
def test_未预期异常不泄漏内部信息(self) -> None:
"""未预期异常的堆栈由全局处理器记入应用日志;审计表只落通用文案。"""
stub = _StubSession()
with pytest.raises(ValueError, match="内部实现细节"), _audit(stub):
raise ValueError("内部实现细节")
assert stub.logs[0].error_message == "服务端异常,请稍后重试"
class TestOperatorFrozen:
"""操作人标量在进入上下文时固化,之后(尤其 rollback 后)不得再触碰 ORM 对象。"""
def test_成功路径只读取一轮操作人属性(self) -> None:
stub = _StubSession()
with audit(stub, operator=_FrozenOperator(), record=_record()): # type: ignore[arg-type]
pass
assert stub.logs[0].username == "admin"
def test_失败路径不再触碰操作人对象(self) -> None:
"""回归:此前失败路径在 rollback 之后读 operator.id/username ——
真实环境会触发额外 SELECT,账号被并发删除时 ObjectDeletedError
还会被兜底吞掉,最该留痕的失败审计静默丢失。"""
stub = _StubSession()
with (
pytest.raises(BadRequestError, match="用户不存在"),
audit(
stub, # type: ignore[arg-type]
operator=_FrozenOperator(),
record=_record(action="删除", summary="删除用户 x"),
),
):
raise BadRequestError("用户不存在")
assert stub.logs[0].user_id == 7
assert stub.logs[0].username == "admin"
class TestDirtySessionGuard:
"""进入审计时存在未提交变更 → 记 warning(防嵌套调用造成半提交)。"""
def test_脏会话触发警告(self, caplog: pytest.LogCaptureFixture) -> None:
stub = _StubSession()
stub.dirty.append(object())
with caplog.at_level(logging.WARNING), _audit(stub):
pass
assert any("未提交的变更" in message for message in caplog.messages)
def test_干净会话不告警(self, caplog: pytest.LogCaptureFixture) -> None:
stub = _StubSession()
with caplog.at_level(logging.WARNING), _audit(stub):
pass
assert not caplog.messages
class TestResilience:
"""审计失败不得影响业务。"""
def test_写入失败不外抛且业务继续(self) -> None:
stub = _StubSession()
stub.fail_add = True
reached = False
with _audit(stub):
reached = True
assert reached is True # 审计写入失败不打断业务流
def test_提交失败不外抛(self) -> None:
stub = _StubSession()
stub.fail_commit = True
with _audit(stub):
pass # 正常退出,不抛异常
def test_业务失败时审计失败不以审计异常替换原异常(self) -> None:
"""业务异常是用户看到的原因,不能因审计自身失败而被替换。"""
stub = _StubSession()
stub.fail_add = True
with pytest.raises(BadRequestError, match="业务错误"), _audit(stub):
raise BadRequestError("业务错误")
class TestSerializationEdge:
"""审计参数的序列化边界(细节覆盖见 test_audit_serialization)。"""
def test_请求参数可为None(self) -> None:
stub = _StubSession()
with _audit(stub, request_data=None):
pass
assert stub.logs[0].request_data is None
def test_不可JSON序列化的值被降级为字符串(self) -> None:
"""如 datetime 等对象经 default=str 降级,序列化不应抛异常。"""
stub = _StubSession()
with _audit(stub, request_data={"at": datetime(2026, 10, 10, 12, 0, 0)}):
pass
assert "2026-10-10" in (stub.logs[0].request_data or "")
+92
View File
@@ -0,0 +1,92 @@
"""日志查询模型单元测试。
守的是**时间范围校验**与**时区归一**:起始晚于结束时必须拒绝 —— 放过去
的话 SQL 条件互斥、查询恒为空,使用者会以为是"没有数据"而不是"条件写反了";
带时区的输入必须转换为本地 naive 时间 —— 不转换时 aware/naive 混用比较
抛 TypeError(穿透成 500),或偏移被驱动静默丢弃(查询窗口偏 8 小时)。
"""
from datetime import UTC, datetime
import pytest
from pydantic import ValidationError
from app.schemas.log import LoginLogQuery, OperationLogQuery
class TestTimeRange:
"""start_time ≤ end_time 校验(两类日志共用基类)。"""
def test_合法范围通过(self) -> None:
query = OperationLogQuery(
start_time=datetime(2026, 10, 1, 0, 0, 0), end_time=datetime(2026, 10, 10, 0, 0, 0)
)
assert query.start_time is not None
def test_起始晚于结束被拒(self) -> None:
with pytest.raises(ValidationError):
OperationLogQuery(start_time=datetime(2026, 10, 2), end_time=datetime(2026, 10, 1))
def test_登录日志同样校验(self) -> None:
with pytest.raises(ValidationError):
LoginLogQuery(start_time=datetime(2026, 10, 2), end_time=datetime(2026, 10, 1))
def test_边界相等通过(self) -> None:
moment = datetime(2026, 10, 1, 12, 0, 0)
assert OperationLogQuery(start_time=moment, end_time=moment).start_time == moment
def test_仅传一端不校验(self) -> None:
assert OperationLogQuery(start_time=datetime(2026, 10, 1)).end_time is None
assert OperationLogQuery(end_time=datetime(2026, 10, 1)).start_time is None
class TestQueryFields:
"""筛选字段边界。"""
def test_成功标记只能为0或1(self) -> None:
assert OperationLogQuery(success=0).success == 0
with pytest.raises(ValidationError):
OperationLogQuery(success=2)
def test_分页别名与偏移(self) -> None:
query = OperationLogQuery(pageNum=2, pageSize=50)
assert query.offset == 50
def test_操作日志筛选字段(self) -> None:
query = OperationLogQuery(module="用户管理", action="删除", username="adm")
assert (query.module, query.action, query.username) == ("用户管理", "删除", "adm")
def test_登录日志按IP精确筛选(self) -> None:
assert LoginLogQuery(ip="10.0.0.1").ip == "10.0.0.1"
class TestTimezoneNormalization:
"""时区归一。
回归背景:混合 aware/naive 比较曾抛 TypeError 穿透成 500;aware 直传
曾被驱动静默丢弃偏移(查询窗口整体偏 8 小时且无提示)。
"""
def test_utc后缀被转换为本地时间(self) -> None:
"""Z 后缀 = UTC 零点 = 东八区本地 08:00(与库列的本地时间口径对齐)。"""
query = OperationLogQuery(start_time="2026-10-01T00:00:00Z")
assert query.start_time == datetime(2026, 10, 1, 8, 0, 0)
def test_混合aware与naive不再抛比较错误(self) -> None:
query = OperationLogQuery(
start_time=datetime(2026, 10, 1, 0, 0, 0),
end_time=datetime(2026, 10, 2, 0, 0, 0, tzinfo=UTC),
)
assert query.end_time is not None
assert query.end_time.tzinfo is None # 已摘除
def test_归一后范围校验仍按本地口径生效(self) -> None:
with pytest.raises(ValidationError):
OperationLogQuery(
start_time="2026-10-02T00:00:00Z", # 本地 10-02 08:00
end_time="2026-10-01 12:00:00", # 本地 10-01 12:00,早于起始
)
def test_naive时间视为本地原样保留(self) -> None:
moment = datetime(2026, 10, 1, 12, 0, 0)
assert OperationLogQuery(start_time=moment).start_time == moment
+98
View File
@@ -0,0 +1,98 @@
"""角色请求模型单元测试。
守的是**角色编码的格式红线**:编码会进入授权判断(`ROLE_ADMIN` 按编码
比对)与操作日志,必须限定为小写字母开头的小写/数字/下划线组合 ——
放开大写或中文会让对照与检索困难,超长则与列宽(32)不符。
"""
import pytest
from pydantic import ValidationError
from app.schemas.role import RoleCreateIn, RoleQuery, RoleStatusIn, RoleUpdateIn
def _create(**overrides: object) -> RoleCreateIn:
base: dict[str, object] = {"role_code": "finance", "role_name": "财务"}
base.update(overrides)
return RoleCreateIn(**base)
class TestRoleCodePattern:
"""编码规则:^[a-z][a-z0-9_]{1,31}$"""
def test_合法编码通过(self) -> None:
assert _create().role_code == "finance"
def test_下划线与数字通过(self) -> None:
assert _create(role_code="sales_01").role_code == "sales_01"
def test_大写字母被拒(self) -> None:
with pytest.raises(ValidationError):
_create(role_code="Finance")
def test_中文被拒(self) -> None:
with pytest.raises(ValidationError):
_create(role_code="财务")
def test_数字开头被拒(self) -> None:
with pytest.raises(ValidationError):
_create(role_code="1abc")
def test_下划线开头被拒(self) -> None:
with pytest.raises(ValidationError):
_create(role_code="_abc")
def test_单字符被拒(self) -> None:
with pytest.raises(ValidationError):
_create(role_code="a")
def test_超长被拒(self) -> None:
with pytest.raises(ValidationError):
_create(role_code="a" * 33)
def test_首尾空白被自动去除(self) -> None:
assert _create(role_code=" finance ").role_code == "finance"
class TestRoleUpdate:
"""编辑模型:不含 role_code。"""
def test_编辑不接受role_code(self) -> None:
"""编码是授权判定锚点,编辑接口有意不接收;多余字段被忽略而非报错
(前端表单可能整份回填提交)。"""
payload = RoleUpdateIn(role_name="财务二组", role_code="hacked")
assert not hasattr(payload, "role_code")
def test_名称为空被拒(self) -> None:
with pytest.raises(ValidationError):
RoleUpdateIn(role_name="")
def test_排序负数被拒(self) -> None:
with pytest.raises(ValidationError):
RoleUpdateIn(role_name="财务", sort_order=-1)
class TestRoleStatusAndQuery:
"""状态与查询模型。"""
def test_状态只能为0或1(self) -> None:
assert RoleStatusIn(status=0).status == 0
assert RoleStatusIn(status=1).status == 1
with pytest.raises(ValidationError):
RoleStatusIn(status=2)
def test_查询默认分页(self) -> None:
query = RoleQuery()
assert query.page_num == 1
assert query.page_size == 10
assert query.keyword is None
assert query.status is None
def test_分页别名照常工作(self) -> None:
"""pageNum/pageSize 是前端契约(PageQuery 的 alias)。"""
query = RoleQuery(pageNum=3, pageSize=5)
assert query.offset == 10
def test_关键词超长被拒(self) -> None:
with pytest.raises(ValidationError):
RoleQuery(keyword="a" * 65)