- 信息员预览基数改用本人销售额(原误用折算毛利润额,金额算错且口径自相矛盾) - 产品类别入参与维度值加存在性校验(拼错静默回退默认阈值 30% 会算错轨交工资) - submit 校验顺序前置查库(未注册 param_code 由 500 修正为 400) - 金额拒绝 NaN/Infinity(比较抛 InvalidOperation、计算抛溢出均穿透为 500) - version_no 并发冲突翻译为 400(对齐 user/role service 的 IntegrityError 处理) - 旧值留痕改取条目生效日时点的值(多未来批次下留痕链不再错位) - 脱敏红线:phone/mobile/id_card 键名整值抹除 + 手机号正则兼容分隔符写法 - 前端:撤回按钮与导出文件名改本地日期(UTC/本地混算)、维度行内精确「改」入口、 detailOf 补 catch、抽屉分页 size 生效、blob 错误读出真实 message - e2e 密码移除硬编码 fallback(缺失即报错退出);按钮文案同步「改」 - 全量回归:后端 406 + 集成 104 + 前端 type-check/build + e2e 7 步 全绿 Co-Authored-By: Claude Code <noreply@anthropic.com>
142 lines
6.2 KiB
Python
142 lines
6.2 KiB
Python
"""操作日志入库前处理单元测试。
|
||
|
||
守的是入库前的**截断与脱敏**:
|
||
|
||
- 截断:各列超长在 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
|
||
# phone 键已加入结构化抹除清单(整值 ***)—— 带空格/横线分隔的号码
|
||
# (如 138 0013 8000)会穿过分值文本正则,键名整值抹除是更强的防线
|
||
assert '"phone": "***"' in text
|
||
|
||
def test_带分隔符的手机号被脱敏(self) -> None:
|
||
"""修复回归:'138 0013 8000' 类分隔符写法曾同时穿过分值正则与键名清单。"""
|
||
for payload in (
|
||
{"phone": "138 0013 8000"},
|
||
{"phone": "138-0013-8000"},
|
||
{"mobile": "13800138000"},
|
||
):
|
||
text = _serialize_request_data(payload) or ""
|
||
assert "13800138000" not in text.replace(" ", "").replace("-", "")
|
||
assert "***" 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 "<EFBFBD>" 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
|