后端 FastAPI 六层结构(api/services/repositories/models/schemas/core), 统一响应信封、全局异常、日志脱敏;6 张表经 Alembic 迁移建库,SQL 留存 db/migration;登录鉴权含图形验证码(Redis GETDEL 防重放)、JWT、登录限流、 登出黑名单(TTL=令牌剩余有效期)、登录日志。 前端 Vue3 + TS + Element Plus 骨架,含登录页、路由守卫、请求封装与 Vite 代理。 工程配置:uv 锁定 Python 3.12、ruff 检查与格式化、editorconfig、gitattributes。 测试 184 项(单元 155 / 集成 29)全绿,浏览器端到端 8 项通过。 Co-Authored-By: Claude Code <noreply@anthropic.com>
112 lines
4.0 KiB
Python
112 lines
4.0 KiB
Python
"""日志脱敏单元测试。
|
||
|
||
规范第 8 节禁止日志输出密码、手机号、身份证、Token。
|
||
脱敏是最容易在重构中悄悄失效的一环,故用参数化用例覆盖各条规则。
|
||
"""
|
||
|
||
import logging
|
||
|
||
import pytest
|
||
|
||
from app.core.logging import SensitiveFormatter, scrub
|
||
|
||
|
||
class TestScrub:
|
||
"""文本脱敏规则。"""
|
||
|
||
@pytest.mark.parametrize(
|
||
("raw", "leaked"),
|
||
[
|
||
('{"password": "Admin@123"}', "Admin@123"),
|
||
("password=Secret123", "Secret123"),
|
||
("pwd: hunter2xyz", "hunter2xyz"),
|
||
('{"app_secret_key": "abcdef123456"}', "abcdef123456"),
|
||
("token=eyJhbGciOiJIUzI1NiJ9", "eyJhbGciOiJIUzI1NiJ9"),
|
||
],
|
||
)
|
||
def test_凭据值被脱敏(self, raw: str, leaked: str) -> None:
|
||
assert leaked not in scrub(raw)
|
||
|
||
def test_Bearer令牌被脱敏(self) -> None:
|
||
result = scrub("Authorization: Bearer eyJhbGciOi.J9.abc-_123")
|
||
assert "eyJhbGciOi.J9.abc-_123" not in result
|
||
assert "Bearer ***" in result
|
||
|
||
def test_手机号保留前三后四(self) -> None:
|
||
assert scrub("联系电话 13812345678") == "联系电话 138****5678"
|
||
|
||
def test_身份证保留前六后四(self) -> None:
|
||
assert scrub("证件号 110101199003071234") == "证件号 110101********1234"
|
||
|
||
def test_普通文本不被误伤(self) -> None:
|
||
text = "用户 admin 登录成功,耗时 120ms"
|
||
assert scrub(text) == text
|
||
|
||
|
||
def _format(msg: str, args: object = None) -> str:
|
||
"""用 SensitiveFormatter 渲染一条日志,返回最终文本。"""
|
||
record = logging.LogRecord(
|
||
name="t",
|
||
level=logging.INFO,
|
||
pathname="",
|
||
lineno=0,
|
||
msg=msg,
|
||
args=args,
|
||
exc_info=None,
|
||
)
|
||
return SensitiveFormatter("%(message)s").format(record)
|
||
|
||
|
||
class TestSensitiveFormatter:
|
||
"""脱敏必须在 %-格式化之后进行。"""
|
||
|
||
def test_脱敏位置参数中的敏感值(self) -> None:
|
||
# 回归用例:早期实现在 filter 阶段改写 record.msg,把 %s 占位符一并抹掉,
|
||
# 导致 logging 内部 msg % args 抛 TypeError,整条日志丢失
|
||
result = _format("登录失败 password=%s", ("Secret123",))
|
||
assert "Secret123" not in result
|
||
assert "登录失败" in result
|
||
|
||
def test_脱敏字典参数中的敏感值(self) -> None:
|
||
# LogRecord 要求 args 为元组;字典参数须包成单元素元组,
|
||
# 这正是 logger.info(msg, {...}) 的内部形态
|
||
result = _format("token=%(token)s", ({"token": "abc123xyz"},))
|
||
assert "abc123xyz" not in result
|
||
|
||
def test_脱敏消息模板内联的敏感值(self) -> None:
|
||
"""敏感值直接写在消息模板里(无占位符)时同样要脱敏。"""
|
||
result = _format('请求体 {"password": "Admin@123"}')
|
||
assert "Admin@123" not in result
|
||
|
||
def test_普通日志不被改动(self) -> None:
|
||
assert (
|
||
_format("用户 admin 登录成功,耗时 %d ms", (120,)) == "用户 admin 登录成功,耗时 120 ms"
|
||
)
|
||
|
||
def test_无参数日志正常通过(self) -> None:
|
||
assert _format("服务已启动") == "服务已启动"
|
||
|
||
|
||
class TestSetupLogging:
|
||
"""setup_logging 必须挂上脱敏格式化器,否则规则形同虚设。"""
|
||
|
||
def test_所有handler均使用脱敏格式化器(self) -> None:
|
||
import logging as _logging
|
||
|
||
from app.core.logging import setup_logging
|
||
|
||
setup_logging(level="INFO", log_to_file=False)
|
||
handlers = _logging.getLogger().handlers
|
||
assert handlers, "根日志器应至少有一个 handler"
|
||
assert all(isinstance(h.formatter, SensitiveFormatter) for h in handlers)
|
||
|
||
def test_重复调用不会累积handler(self) -> None:
|
||
import logging as _logging
|
||
|
||
from app.core.logging import setup_logging
|
||
|
||
setup_logging(log_to_file=False)
|
||
first = len(_logging.getLogger().handlers)
|
||
setup_logging(log_to_file=False)
|
||
assert len(_logging.getLogger().handlers) == first
|