PayrollManagementSystem/backend/tests/unit/test_audit_serialization.py
liuwenxin fbf044f240 fix(system): code-review 15 项修复(预览基数/维度校验/脱敏穿透/并发版本号等)
- 信息员预览基数改用本人销售额(原误用折算毛利润额,金额算错且口径自相矛盾)
- 产品类别入参与维度值加存在性校验(拼错静默回退默认阈值 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>
2026-10-13 02:28:20 +08:00

142 lines
6.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""操作日志入库前处理单元测试。
守的是入库前的**截断与脱敏**:
- 截断:各列超长在 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