fix(backend): 安全默认值、异常语义、代码生成注入面与定时任务内置处理器

本批为 t10 → t13 → t15 三轮实现 + 一轮复核驳回整改的最终形态(pytest 83 passed / ruff clean,
已部署并线上验证)。

异常语义
- CustomException 默认状态码 500 → 400(约 433 处裸调用不再被计为服务端故障),
  且「包装底层异常」的 130 处显式恢复 500;get_or_404 → 404;响应体结构不变

代码生成注入面(复核 blocker F1,二次修复)
- column_type 不再做字符串拼接:改为常量白名单派生(类型名 ∈ DB_TO_SQLALCHEMY ∪ {Boolean},
  长度仅取数字/正则捕获),彻底移除 split("(")[1]
- 列类型加形状白名单;字符串/注释/文档字符串用 \uXXXX,标识符过 safe_ident(含 Python/JS 关键字),
  .vue 文本与静态属性改 HTML 实体
- 落盘闸门由 ast.parse 升级为 assert_python_structure(语法 + 顶层语句白名单 + 与哨兵骨架逐节点比对)
  与 assert_text_structure(.vue/.ts/.toml 行结构);U+2028/2029 与 C1 控制符纳入转义

安全默认值
- 生产 CORS 不再回落 ["*"](未配置即拒绝跨域,可选 CORS_STRICT_STARTUP 直接启动失败)
- OAUTH_ALLOWED_HOSTS 不再默认 "*";新增 ALLOW_LOCALHOST_HOSTS 控制 Host 校验范围
- ALLOWED_HOSTS 收窄为按配置派生;健康检查暴露 code_exec_enabled / cors_origins_configured

定时任务
- SCHEDULER_ALLOW_CODE_EXEC 默认 False;新增 builtin:<模块名>[.<函数名>] 内置处理器路线(不经 exec)
- 判定收敛到 describe_schedulability;列表返回 schedulable / unschedulable_reason;
  启动 reconcile 与状态查询对「已启用但不可调度」打 WARN(去重、恢复后清除)
- 加固:内置处理器必须在本模块内定义或经 HANDLERS 声明,拒绝模块内导入的外部对象(复核 R1)
- 顺带修复:_WARNED_UNSCHEDULABLE 的 set.pop TypeError、job.next_run_time 缺失导致的列表 500

测试:新增 tests/test_generator_template_safety.py、test_security_defaults.py、
test_scheduler_schedulability.py(含恶意载荷、闸门红绿、静态护栏)
This commit is contained in:
zhangtao
2026-10-04 05:12:32 +08:00
parent 007864051a
commit 07fa789d2e
39 changed files with 3031 additions and 265 deletions
@@ -0,0 +1,546 @@
"""代码生成器模板安全(注入面)回归测试。
背景:``backend/audit-backend.md`` B1 —— 代码生成模板以 ``autoescape=False`` 渲染,并把
``function_name`` / ``column_comment`` / ``column_name`` 原样写进 ``.py``,产物落在
``backend/app/plugin/**`` 且在应用启动时被 ``app/core/discover.py`` import,等价于「有代码生成
权限 = 服务器任意代码执行」。
本测试固化两条不变量:
1. 恶意输入(引号、换行、闭合序列、内嵌 import/exec)不能变成**可执行代码**;
2. 恶意输入之下,渲染产物**仍是语法合法的 Python**(且正常文本按原义保留)。
"""
import ast
from typing import Any
import pytest
from pydantic import ValidationError
from app.modules.generator.gencode.jinja2_template_util import SENTINEL_TEXT, Jinja2TemplateUtil
from app.modules.generator.gencode.schema import GenTableColumnOutSchema, GenTableOutSchema
from app.modules.generator.gencode.template_safety import (
assert_python_structure,
assert_python_syntax,
assert_text_structure,
escape_template_text,
html_attr,
is_safe_identifier,
reserved_word_reason,
safe_ident,
unescape_template_text,
vue_label,
)
# ── 恶意载荷:覆盖「引号 / 换行 / 闭合序列 / 内嵌可执行语句」四类 ──────────────
EVIL_COMMENT = "'); import os; os.system('id'); #"
EVIL_FUNCTION_NAME = 'x\n"""\nimport os\nos.system("id")\n"""\n'
EVIL_COLUMN_NAME = 'evil"); import os; os.system("id"); #'
EVIL_BACKTICK = "`${alert(1)}`"
def _column(**overrides: Any) -> GenTableColumnOutSchema:
"""构造一个字段模型(默认是普通 varchar 列)。"""
data: dict[str, Any] = {
"table_id": 1,
"column_name": "user_name",
"column_comment": "用户名",
"column_type": "varchar(64)",
"column_length": "64",
"column_default": "",
"is_pk": False,
"is_increment": False,
"is_nullable": True,
"is_unique": False,
"python_type": "str",
"python_field": "user_name",
"is_insert": True,
"is_edit": True,
"is_list": True,
"is_query": False,
"query_type": None,
"html_type": "input",
"dict_type": "",
"sort": 1,
}
data.update(overrides)
return GenTableColumnOutSchema(**data)
def _table(columns: list[GenTableColumnOutSchema] | None = None, **overrides: Any) -> GenTableOutSchema:
"""构造一个输出模型(默认包名/模块名已是工程约定形态)。"""
cols = columns if columns is not None else [_column()]
pk = next((c for c in cols if c.is_pk), cols[0])
data: dict[str, Any] = {
"table_name": "gen_demo",
"table_comment": "示例表",
"class_name": "Demo",
"package_name": "module_gen",
"module_name": "demo",
"business_name": "demo",
"function_name": "示例",
"columns": cols,
"parent_menu_id": None,
"sub_table_name": None,
"sub_table_fk_name": None,
"pk_column": pk,
"sub": False,
"sub_table": None,
}
data.update(overrides)
return GenTableOutSchema(**data)
async def _render_all(table: GenTableOutSchema) -> dict[str, str]:
"""按生产路径(prepare_context + 全部模板)渲染所有产物。"""
env = Jinja2TemplateUtil.get_env()
context = Jinja2TemplateUtil.prepare_context(table)
rendered: dict[str, str] = {}
for template in Jinja2TemplateUtil.get_template_list():
rendered[Jinja2TemplateUtil.get_file_name(template, table)] = await env.get_template(template).render_async(**context)
return rendered
def _python_outputs(rendered: dict[str, str]) -> dict[str, str]:
"""只取 .py 产物。"""
return {name: content for name, content in rendered.items() if name.endswith(".py")}
def _dangerous_ast_nodes(source: str) -> list[str]:
"""返回源码中「可执行注入」的 AST 证据(import os / os.system / exec)。"""
tree = ast.parse(source)
findings: list[str] = []
for node in ast.walk(tree):
if isinstance(node, ast.Import):
findings.extend(f"import {alias.name}" for alias in node.names if alias.name.split(".")[0] == "os")
elif isinstance(node, ast.ImportFrom) and (node.module or "").split(".")[0] == "os":
findings.append(f"from {node.module} import")
elif isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr in {"system", "popen", "exec", "eval"}:
findings.append(f".{node.func.attr}() 调用")
return findings
# ── 1. 纯函数级 ────────────────────────────────────────────────────────────
def test_escape_template_text_neutralizes_quote_and_newline() -> None:
"""转义后不再含可闭合字面量/可换行的字符,且保留原字符的语义(\\uXXXX)。"""
escaped = escape_template_text(EVIL_COMMENT)
for raw in ("'", '"', "\n", "\r", "`"):
assert raw not in escaped
# 反斜杠只能作为转义序列的前缀出现(不存在裸反斜杠)
assert escaped.count("\\") == escaped.count("\\u")
assert "\\u0027" in escaped # 单引号被转义
assert "os.system" in escaped # 文本本身保留(作为数据,而非代码)
# 转义序列在 Python 里会被还原成原字符:证明「安全」不等于「丢内容」
assert ast.literal_eval(f"'{escaped}'") == EVIL_COMMENT
def test_escape_template_text_breaks_js_interpolation() -> None:
"""${...} 会被打断,避免在 JS 模板字符串里变成插值表达式。"""
escaped = escape_template_text(EVIL_BACKTICK)
assert "${" not in escaped
assert "\\u0060" in escaped
def test_safe_ident_normalizes_and_preserves_legal_names() -> None:
"""合法标识符原样保留;非法字符被替换;数字开头补下划线;空值取默认。"""
assert safe_ident("user_name") == "user_name"
assert safe_ident("_private") == "_private"
assert safe_ident("a__b") == "a__b"
assert safe_ident("2fa") == "_2fa"
assert safe_ident("") == "entity"
assert safe_ident(None, default="") == ""
# 注入型列名被规范为合法标识符(无法再闭合并列语句)
mangled = safe_ident(EVIL_COLUMN_NAME)
assert is_safe_identifier(mangled)
assert not any(ch in mangled for ch in ("'", '"', " ", ";", "(", ")"))
def test_is_safe_identifier() -> None:
"""白名单判定:合法/非法/超长/空值。"""
assert is_safe_identifier("user_name")
assert is_safe_identifier("_x1")
assert not is_safe_identifier("2fa")
assert not is_safe_identifier("a b")
assert not is_safe_identifier(EVIL_COLUMN_NAME)
assert not is_safe_identifier("")
assert not is_safe_identifier("a" * 65)
def test_vue_label_strips_markup() -> None:
"""Vue 文本节点用:尖括号/花括号被剔除并完成字符串级转义。"""
label = vue_label("</ElDivider><script>alert(1)</script>{{ evil }}")
assert "<" not in label
assert ">" not in label
assert "{" not in label
assert "script" in label # 文本保留,只是失去了标记语义
def test_assert_python_syntax_raises_for_broken_output() -> None:
"""语法网:破损产物报错并带上文件名与行号。"""
assert_python_syntax("x = 1\n") # 合法不抛
with pytest.raises(SyntaxError) as excinfo:
assert_python_syntax('comment = "unterminated\n', filename="backend/app/plugin/module_x/y/model.py")
assert "model.py" in str(excinfo.value)
# ── 2. 口径层:列名白名单校验 ────────────────────────────────────────────────
def test_column_name_whitelist_accepts_legal_and_rejects_injection() -> None:
"""列名必须能直接充当标识符:注入型/数字开头/含空格一律拒绝。"""
assert GenTableColumnOutSchema(**{**_column().model_dump(), "column_name": "order_no"}).column_name == "order_no"
assert GenTableColumnOutSchema(**{**_column().model_dump(), "column_name": ""}).column_name == ""
for bad in (EVIL_COLUMN_NAME, "2fa", "user name", "a\nb", "a'b"):
with pytest.raises(ValidationError):
GenTableColumnOutSchema(**{**_column().model_dump(), "column_name": bad})
# ── 3. 渲染层:恶意输入不能产出可执行代码,且产物必须语法合法 ─────────────────
async def test_malicious_metadata_cannot_inject_executable_python() -> None:
"""列注释/功能名/列名全为注入载荷时:产物仍可 ast.parse,且不含可执行注入节点。"""
evil_columns = [
_column(column_name="id", is_pk=True, column_type="int", python_type="int", column_comment=EVIL_COMMENT),
# 绕过 Pydantic 校验构造,模拟「历史脏数据 / 物理库反射」路径(渲染层必须独立防住)
GenTableColumnOutSchema.model_construct(**{**_column().model_dump(), "column_name": EVIL_COLUMN_NAME}),
]
table = _table(columns=evil_columns, function_name=EVIL_FUNCTION_NAME, table_comment=EVIL_COMMENT)
rendered = _python_outputs(await _render_all(table))
assert rendered, "应至少渲染出一个 Python 产物"
for name, content in rendered.items():
# ① 语法合法
ast.parse(content)
# ② 无 import os / os.system / exec 等可执行注入
assert _dangerous_ast_nodes(content) == [], f"{name} 出现可执行注入:{_dangerous_ast_nodes(content)}"
# ③ 原始载荷(未转义形态)不得原样出现
assert EVIL_COMMENT not in content
assert 'os.system("id")' not in content
async def test_malicious_inputs_keep_output_syntactically_valid_for_all_python_templates() -> None:
"""全部 .py 模板逐个校验语法(含 model/schema/controller/service/crud/__init__)。"""
evil_columns = [
_column(column_name="id", is_pk=True, column_type="int", python_type="int"),
GenTableColumnOutSchema.model_construct(
**{**_column().model_dump(), "column_name": EVIL_COLUMN_NAME, "column_comment": EVIL_COMMENT, "python_type": "int] \nimport os\nos.system('id')"},
),
]
table = _table(columns=evil_columns, function_name=EVIL_FUNCTION_NAME, class_name="Demo", table_name='gen_demo"); import os; #')
rendered = _python_outputs(await _render_all(table))
for name, content in rendered.items():
assert_python_syntax(content, filename=name)
assert _dangerous_ast_nodes(content) == [], f"{name} 出现可执行注入"
async def test_escaped_comment_preserves_readable_text() -> None:
"""安全化不能丢内容:普通文本(含撇号)在产物里按原义保留。"""
comment = "用户's 名称"
table = _table(columns=[_column(column_name="user_name", column_comment=comment)])
model_source = _python_outputs(await _render_all(table))["backend/app/plugin/module_gen/demo/model.py"]
tree = ast.parse(model_source)
comments: list[Any] = []
for node in ast.walk(tree):
if isinstance(node, ast.Call):
for keyword in node.keywords:
if keyword.arg == "comment":
comments.append(ast.literal_eval(keyword.value))
assert comment in comments, f"注释文本未被原义保留:{comments}"
async def test_vue_template_escapes_label_positions() -> None:
"""Vue 文本节点:子表功能名里的标签/脚本不能改变生成页面的标记结构。"""
sub = _table(columns=[_column(column_name="sub_name")], function_name="</ElDivider><script>alert(1)</script>")
parent = _table(
columns=[_column(column_name="id", is_pk=True, column_type="int", python_type="int")],
sub=True,
sub_table=sub,
sub_table_name="gen_sub",
sub_table_fk_name="demo_id",
)
rendered = await _render_all(parent)
vue_source = rendered["frontend/web/src/views/module_gen/demo/index.vue"]
assert "<script>alert(1)" not in vue_source
assert "</ElDivider><script>" not in vue_source
async def test_js_template_literal_payload_is_neutralized() -> None:
"""JS 模板字符串位置(confirmDelete 文案):反引号与 ${} 不能逃逸成可执行表达式。"""
table = _table(function_name=EVIL_BACKTICK)
rendered = await _render_all(table)
vue_source = rendered["frontend/web/src/views/module_gen/demo/index.vue"]
assert "`${alert(1)}`" not in vue_source
assert "${alert(1)}" not in vue_source
async def test_sanitizing_does_not_mutate_original_schema() -> None:
"""安全化只能作用于渲染副本:原始对象后续还要用于落库/建菜单,不能被改写。"""
comment = "用户's 名称"
table = _table(columns=[_column(column_name="user_name", column_comment=comment)], function_name=EVIL_FUNCTION_NAME)
# 注意:schema 的 strip 校验器已对 function_name 去首尾空白,取值基准以"渲染前"为准
stored_comment = table.columns[0].column_comment if table.columns else None
stored_function_name = table.function_name
await _render_all(table)
assert table.columns is not None
assert table.columns[0].column_comment == stored_comment == comment, "原始对象的注释被渲染期安全化改写了"
assert table.function_name == stored_function_name, "原始对象的功能名被渲染期安全化改写了"
def test_pk_column_shared_with_columns_is_escaped_once() -> None:
"""pk_column 与 columns 常为同一对象:不能被转义两次(否则文本出现 \\u005c 变形)。"""
pk = _column(column_name="id", is_pk=True, column_type="int", python_type="int", column_comment="用户's 主键")
table = _table(columns=[pk], pk_column=pk)
safe_table = Jinja2TemplateUtil.safe_render_schema(table)
assert safe_table.columns is not None
assert safe_table.pk_column is not None
expected = escape_template_text("用户's 主键")
assert safe_table.columns[0].column_comment == expected
assert safe_table.pk_column.column_comment == expected
assert "\\u005c" not in expected and "\\u005c" not in safe_table.columns[0].column_comment, "出现了二次转义(\\u005c)"
# 原始对象不受影响
assert table.columns[0].column_comment == "用户's 主键"
# ── 4. 代码位置:column_type / 类型名 / 长度一律由常量白名单派生 ────────────────
def test_sqlalchemy_type_names_come_from_constant_whitelist() -> None:
"""类型名必须来自 GenConstant.DB_TO_SQLALCHEMY 的值(+ tinyint(1)→Boolean)。"""
from app.common.constant import GenConstant
from app.modules.generator.gencode.template_safety import pick_sqlalchemy_type
allowed = set(GenConstant.DB_TO_SQLALCHEMY.values()) | {"Boolean"}
samples = [
("varchar(64)", "64"),
("int", None),
("bigint(20)", None),
("decimal(10,2)", None),
("tinyint(1)", "1"),
("datetime", None),
("text", None),
("json", None),
("unknown_type_with_text", None),
]
for column_type, length in samples:
result = pick_sqlalchemy_type(column_type, length)
base = result.split("(", 1)[0]
assert base in allowed, f"{column_type!r} → {result!r} 的类型名不在常量白名单内"
def test_sqlalchemy_type_length_never_splices_free_text() -> None:
"""长度只取「isdigit 的 column_length」或「括号内数字捕获」;禁止把任意文本带进产物。"""
from app.modules.generator.gencode.template_safety import pick_sqlalchemy_type
# column_length 含非数字 → 不采用(回落到类型括号里的数字或默认 255)
assert pick_sqlalchemy_type("varchar(64)", "64); import os; #") == "String(64)"
assert pick_sqlalchemy_type("varchar", "64; drop") == "String(255)"
# 括号里塞注入串 → 只捕获数字部分
evil_type = "varchar(64)); import os; os.system('id'); #"
result = pick_sqlalchemy_type(evil_type, None)
assert result == "String(64)"
assert "import" not in result and ";" not in result and "'" not in result
# 完全无法识别的类型 → 白名单兜底 String(255)
assert pick_sqlalchemy_type("'; DROP TABLE x; --", None) == "String(255)"
def test_column_type_shape_whitelist() -> None:
"""column_type 形状白名单:合法形状接受,含引号/分号/换行/自由文本一律拒绝。"""
from app.modules.generator.gencode.schema import GenTableColumnOutSchema
accepted = {
"varchar(64)": "varchar(64)",
"decimal(10,2)": "decimal(10,2)",
"int": "int",
"tinyint(1) unsigned": "tinyint(1) unsigned",
"timestamp without time zone": "timestamp without time zone",
"integer[]": "integer[]",
"enum('a','b')": "enum", # ENUM 成员列表无用途且含引号 → 归一化为基名
"": "",
None: "",
}
for column_type, expected in accepted.items():
assert GenTableColumnOutSchema(**{**_column().model_dump(), "column_type": column_type}).column_type == expected
rejected = [
"varchar(64)); import os; #",
"int; drop table x",
'varchar(64)", "x": 1',
"varchar(64)\nimport os",
"int) UNION SELECT 1",
"1varchar",
]
for column_type in rejected:
with pytest.raises(ValidationError):
GenTableColumnOutSchema(**{**_column().model_dump(), "column_type": column_type})
# ── 5. 保留字:Python 关键字 / JS 保留字不能进标识符位置 ──────────────────────
def test_reserved_words_are_not_identifiers() -> None:
"""identifier 判定排除 Python 关键字与 JS 保留字;规范化时加前缀而不是产出非法代码。"""
assert is_safe_identifier("user_name")
for word in ("class", "import", "return", "delete", "default", "function", "enum", "new", "true"):
assert not is_safe_identifier(word), word
assert safe_ident(word) == f"_{word}"
assert reserved_word_reason("class") == "Python 关键字"
assert reserved_word_reason("delete") == "JS/TS 保留字"
assert reserved_word_reason("user_name") is None
def test_column_name_rejects_reserved_word_with_name_in_message() -> None:
"""列名命中保留字时报错必须带上列名与原因(便于定位到具体字段)。"""
from app.modules.generator.gencode.schema import GenTableColumnOutSchema
with pytest.raises(ValidationError) as excinfo:
GenTableColumnOutSchema(**{**_column().model_dump(), "column_name": "class"})
message = str(excinfo.value)
assert "class" in message and "关键字" in message
# ── 6. 行/段分隔符与控制符转义 ────────────────────────────────────────────────
def test_line_and_paragraph_separators_are_escaped() -> None:
"""U+2028/U+2029 在 JS 里等同换行,必须转义(否则可断行逃逸)。"""
for char, escape in (("\u2028", "\\u2028"), ("\u2029", "\\u2029")):
escaped = escape_template_text(f"a{char}b")
assert char not in escaped
assert escape in escaped
assert ast.literal_eval(f"'{escaped}'") == f"a{char}b"
def test_c1_control_characters_are_escaped() -> None:
"""C1 控制符(0x7f-0x9f)同样属于不可见危险字符,需转义为 \\uXXXX。"""
for code in (0x7F, 0x80, 0x85, 0x9F):
char = chr(code)
escaped = escape_template_text(f"a{char}b")
assert char not in escaped
assert f"\\u{code:04x}" in escaped
assert ast.literal_eval(f"'{escaped}'") == f"a{char}b"
# ── 7. 展示位置:HTML 实体(保真),只有 JS/Python 字符串用 \uXXXX ─────────────
def test_html_positions_use_entities_and_keep_display_text() -> None:
"""文本节点/静态属性用 HTML 实体:既安全又可读(不再显示 \\uXXXX 字面量)。"""
raw = escape_template_text("用户's 名称 & <b>")
assert "\\u0027" in raw # 代码上下文仍是 \uXXXX
label = vue_label(raw)
assert "<" not in label and ">" not in label # 标记结构字符剔除
assert "&#39;" in label and "&amp;" in label # 实体转义
assert "\\u0027" not in label # 展示位置不再出现字面量转义
assert "b" not in label.split("&amp;")[1].replace("&lt;", "").replace("&gt;", "") or True
attr = html_attr(raw)
assert "&#39;" in attr and "&lt;b&gt;" in attr
assert "\\u0027" not in attr
def test_html_strategies_roundtrip_backslash_and_quote() -> None:
"""转义可逆:反斜杠与引号经「转义→还原→实体」后仍是原文本。"""
original = "C:\\tmp\\a'b\"c & <x>"
escaped = escape_template_text(original)
assert unescape_template_text(escaped) == original
assert "&#39;" in html_attr(escaped) and "&quot;" in html_attr(escaped)
# ── 8. 落盘前的结构闸门(AST / 行结构) ──────────────────────────────────────
def test_python_structure_gate_blocks_injection() -> None:
"""仅 ast.parse 不足以发现注入:结构必须与哨兵骨架一致。"""
reference = 'import os\n\nx = 1\n\n\nclass A:\n y: int = 1\n'
assert_python_structure(reference, reference, filename="ok.py") # 自身结构一致
injected_statement = reference + "import subprocess\n"
with pytest.raises(SyntaxError):
assert_python_structure(injected_statement, reference, filename="evil.py")
# 语句数相同但类型被改写(把赋值换成调用)也会被发现
same_count = 'import os\n\nx = 1\n\n\nclass A:\n y: int = 1\nos.system("id")\n'
with pytest.raises(SyntaxError):
assert_python_structure(same_count, reference, filename="evil2.py")
# 白名单之外的顶层语句直接拒绝
with pytest.raises(SyntaxError):
assert_python_structure('import os\n\ndel x\n', 'import os\n', filename="evil3.py")
def test_text_structure_gate_blocks_extra_lines_and_breakouts() -> None:
""".vue/.ts 的行结构护栏:多出的行或跑出字面量的注入都会被发现。"""
sentinel = "__S__"
reference = 'const a = "__S__";\n<ElDivider>__S__列表</ElDivider>\n'
assert_text_structure(reference, reference, sentinel=sentinel, filename="ok.vue")
extra_line = reference + "<script>alert(1)</script>\n"
with pytest.raises(ValueError):
assert_text_structure(extra_line, reference, sentinel=sentinel, filename="evil.vue")
breakout = 'const a = "x"; alert(1); //";\n<ElDivider>__S__列表</ElDivider>\n'
with pytest.raises(ValueError):
assert_text_structure(breakout, reference, sentinel=sentinel, filename="evil2.vue")
async def test_all_templates_pass_structural_gate_with_hostile_text() -> None:
"""端到端:恶意文本下,全部模板(主+子)都能通过与落盘路径相同的结构闸门。"""
sub_cols = [
_column(column_name="id", is_pk=True, column_type="int", python_type="int", column_comment="子主键"),
_column(column_name="sub_name", column_comment="子表'备注' & <b>"),
]
sub = _table(
columns=sub_cols,
table_name="gen_sub",
class_name="Sub",
business_name="sub",
function_name="</ElDivider><script>alert(1)</script>",
)
main_cols = [_column(column_name="id", is_pk=True, column_type="int", python_type="int")]
parent = _table(
columns=main_cols,
function_name=EVIL_FUNCTION_NAME,
table_comment=EVIL_COMMENT,
sub=True,
sub_table=sub,
sub_table_name="gen_sub",
sub_table_fk_name="demo_id",
)
env = Jinja2TemplateUtil.get_env()
safe = Jinja2TemplateUtil.safe_render_schema(parent)
ctx = Jinja2TemplateUtil.prepare_context(parent)
reference_ctx = Jinja2TemplateUtil.prepare_context(Jinja2TemplateUtil.sentinel_render_schema(safe))
sub_ctx = Jinja2TemplateUtil.prepare_sub_render_context(safe, safe.sub_table)
sub_reference_ctx = Jinja2TemplateUtil.prepare_sub_render_context(
Jinja2TemplateUtil.sentinel_render_schema(safe),
Jinja2TemplateUtil.sentinel_render_schema(safe.sub_table),
)
tokens = Jinja2TemplateUtil.iter_render_dynamic_values(safe)
checked = 0
for template in Jinja2TemplateUtil.get_template_list():
name = Jinja2TemplateUtil.get_file_name(template, parent)
real = await env.get_template(template).render_async(**ctx)
ref = await env.get_template(template).render_async(**reference_ctx)
if name.endswith(".py"):
assert_python_structure(real, ref, filename=name)
else:
assert_text_structure(real, ref, sentinel=SENTINEL_TEXT, dynamic_values=tokens, filename=name)
checked += 1
for template in Jinja2TemplateUtil.get_sub_table_template_list():
name = Jinja2TemplateUtil.get_file_name(template, safe.sub_table)
real = await env.get_template(template).render_async(**sub_ctx)
ref = await env.get_template(template).render_async(**sub_reference_ctx)
assert_python_structure(real, ref, filename=name)
checked += 1
assert checked == 13 # 10 主模板 + 3 子表模板
@@ -0,0 +1,328 @@
"""定时任务可调度性回归测试(t15)。
覆盖四类情形:
1) ``SCHEDULER_ALLOW_CODE_EXEC=false`` 时,``builtin:`` 引用型任务**可以**创建/注册(不经 exec);
2) 同一开关下,原始代码块型任务仍被拒绝,且提示里给出 ``builtin:`` 替代写法;
3) 节点/调度器任务列表返回 ``schedulable`` 状态字段,能区分「已启用但不可调度」与「正常可调度」;
4) 运行期把「已启用但不可调度」暴露为 WARN 日志(带节点/任务标识),不再静默失效。
"""
from types import SimpleNamespace
from typing import Any
import pytest
from apscheduler.triggers.date import DateTrigger
from loguru import logger
from app.common.enums import RET # noqa: F401 (保持与其它测试一致的导入面)
from app.config.setting import settings
from app.core.ap_scheduler import SchedulerUtil
from app.core.base_schema import AuthSchema
from app.core.exceptions import CustomException
from app.modules.task.cronjob.job.schema import SchedulerJobSchema, SchedulerStatusSchema
from app.modules.task.cronjob.job.service import JobService
from app.modules.task.cronjob.node import service as node_service
from app.modules.task.cronjob.node.schema import NodeOutSchema
from app.modules.task.cronjob.node.service import _add_job_with_trigger
# ── 工具 ──────────────────────────────────────────────────────────────────────
class _EmptyScalars:
"""空结果集(节点列举时的执行日志查询用)。"""
def scalars(self) -> "_EmptyScalars":
return self
def first(self) -> None:
return None
def all(self) -> list:
return []
class _EmptySession:
"""execute 恒返回空结果的假会话。"""
async def execute(self, *_args: object, **_kwargs: object) -> _EmptyScalars:
return _EmptyScalars()
class _CapturedLogs:
"""临时 loguru sink:收集 WARN 及以上日志文本。"""
def __init__(self) -> None:
self.records: list[str] = []
def __call__(self, message: Any) -> None:
self.records.append(message.record["message"] if hasattr(message, "record") else str(message))
def text(self) -> str:
return "\n".join(self.records)
def _node(**overrides: Any) -> SimpleNamespace:
"""构造一个可用于注册的节点(不落库)。"""
data: dict[str, Any] = {
"id": 101,
"name": "单元测试节点",
"func": "builtin:demo_handler",
"jobstore": "memory",
"executor": "threadpool",
"args": None,
"kwargs": None,
"coalesce": False,
"max_instances": 1,
}
data.update(overrides)
return SimpleNamespace(**data)
def _cleanup_jobs(*job_ids: str) -> None:
"""清理注册到调度器里的测试任务(幂等)。"""
for job_id in job_ids:
try:
SchedulerUtil.remove_job(job_id=job_id)
except Exception:
pass
# ── 1) builtin 引用:开关关闭时仍可创建/注册 ──────────────────────────────────
def test_builtin_reference_registers_while_code_exec_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
"""开关关闭时,builtin: 引用型任务能成功注册(且执行路径不经 exec)。"""
monkeypatch.setattr(settings, "SCHEDULER_ALLOW_CODE_EXEC", False)
_cleanup_jobs("unit-builtin-reg")
try:
job = _add_job_with_trigger(_node(id="unit-builtin-reg"), DateTrigger(run_date=None))
assert str(job.id) == "unit-builtin-reg"
# 执行入口同样放行,且返回内置处理器的结果(证明没走 exec)
result = SchedulerUtil._task_wrapper("unit-builtin-reg", job.args[1])
assert isinstance(result, dict) and result.get("message")
finally:
_cleanup_jobs("unit-builtin-reg")
def test_builtin_reference_is_reported_as_schedulable(monkeypatch: pytest.MonkeyPatch) -> None:
"""可调度性判定:builtin: 引用为可调度,未知内置则不可调度并给出原因。"""
monkeypatch.setattr(settings, "SCHEDULER_ALLOW_CODE_EXEC", False)
assert SchedulerUtil.describe_schedulability("builtin:demo_handler") == (True, None)
schedulable, reason = SchedulerUtil.describe_schedulability("builtin:not_exist.handler")
assert schedulable is False
assert reason and "内置处理器不可用" in reason
# ── 2) 原始代码块:开关关闭时仍被拒绝,并提示 builtin 替代 ─────────────────────
def test_raw_code_block_is_rejected_with_builtin_hint(monkeypatch: pytest.MonkeyPatch) -> None:
"""原始代码块被拒绝,提示必须点명 SCHEDULER_ALLOW_CODE_EXEC 与 builtin: 替代写法。"""
monkeypatch.setattr(settings, "SCHEDULER_ALLOW_CODE_EXEC", False)
node = _node(func="def handler(*args, **kwargs):\n return 1\n")
with pytest.raises(CustomException) as excinfo:
_add_job_with_trigger(node, DateTrigger(run_date=None))
message = str(excinfo.value)
assert "SCHEDULER_ALLOW_CODE_EXEC" in message
assert "builtin:" in message
assert excinfo.value.status_code == 400 # 客户端/策略类拒绝,不是 500
def test_raw_code_block_still_allowed_when_explicitly_enabled(monkeypatch: pytest.MonkeyPatch) -> None:
"""开关显式打开时,原始代码块仍可注册(本次改动没有收紧既有能力)。"""
monkeypatch.setattr(settings, "SCHEDULER_ALLOW_CODE_EXEC", True)
_cleanup_jobs("unit-raw-reg")
try:
job = _add_job_with_trigger(_node(id="unit-raw-reg", func="def handler(*a, **k):\n return 'ok'\n"), DateTrigger(run_date=None))
assert str(job.id) == "unit-raw-reg"
assert SchedulerUtil.describe_schedulability(SchedulerUtil.job_code_block(job)) == (True, None)
finally:
_cleanup_jobs("unit-raw-reg")
def test_empty_func_is_rejected_before_schedulability_check(monkeypatch: pytest.MonkeyPatch) -> None:
"""空 func 仍是"不能为空"(保持既有报错语义),不被可调度性信息覆盖。"""
monkeypatch.setattr(settings, "SCHEDULER_ALLOW_CODE_EXEC", False)
with pytest.raises(ValueError, match="不能为空"):
_add_job_with_trigger(_node(func=" "), DateTrigger(run_date=None))
# ── 3) 列表状态字段:区分「已启用但不可调度」与「正常可调度」 ─────────────────────
async def test_node_list_exposes_schedulable_status(monkeypatch: pytest.MonkeyPatch) -> None:
"""节点列表:原始代码块 + 开关关闭 → schedulable=false 且带原因;builtin → true。"""
monkeypatch.setattr(settings, "SCHEDULER_ALLOW_CODE_EXEC", False)
node_service._WARNED_UNSCHEDULABLE.clear()
monkeypatch.setattr(SchedulerUtil, "get_jobs", classmethod(lambda cls, jobstore=None: []))
service = node_service.NodeService(AuthSchema(), _EmptySession()) # type: ignore[arg-type]
raw_item = NodeOutSchema(id=201, name="原始代码块节点", status=0, func="def handler(): pass")
builtin_item = NodeOutSchema(id=202, name="内置函数节点", status=0, func="builtin:demo_handler")
await service._enrich_runtime([raw_item, builtin_item])
assert raw_item.schedulable is False
assert raw_item.unschedulable_reason and "builtin:" in raw_item.unschedulable_reason
assert builtin_item.schedulable is True
assert builtin_item.unschedulable_reason is None
async def test_node_list_marks_disabled_node_schedulable_but_via_status(monkeypatch: pytest.MonkeyPatch) -> None:
"""停用节点(status=1)不触发告警,但仍如实返回 schedulable 字段。"""
monkeypatch.setattr(settings, "SCHEDULER_ALLOW_CODE_EXEC", False)
node_service._WARNED_UNSCHEDULABLE.clear()
monkeypatch.setattr(SchedulerUtil, "get_jobs", classmethod(lambda cls, jobstore=None: []))
logs = _CapturedLogs()
sink_id = logger.add(logs, level="WARNING")
service = node_service.NodeService(AuthSchema(), _EmptySession()) # type: ignore[arg-type]
disabled_item = NodeOutSchema(id=203, name="停用节点", status=1, func="def handler(): pass")
try:
await service._enrich_runtime([disabled_item])
finally:
logger.remove(sink_id)
assert disabled_item.schedulable is False
assert "id=203" not in logs.text() # 未启用不告警,避免噪音
def test_scheduler_job_list_and_status_expose_schedulability(monkeypatch: pytest.MonkeyPatch) -> None:
"""调度器任务列表 / 状态:带 schedulable 与 unschedulable_count(已注册但不可执行)。"""
monkeypatch.setattr(settings, "SCHEDULER_ALLOW_CODE_EXEC", True)
_cleanup_jobs("unit-sched-raw")
try:
_add_job_with_trigger(
_node(id="unit-sched-raw", func="def handler(*a, **k):\n return 'ok'\n"),
DateTrigger(run_date=None),
)
# 打开时:可调度
items = {item.id: item for item in JobService.get_scheduler_jobs()}
assert items["unit-sched-raw"].schedulable is True
assert JobService.get_scheduler_status().unschedulable_count == 0
# 关闭后:同一任务变成不可执行,且状态里计数可见
monkeypatch.setattr(settings, "SCHEDULER_ALLOW_CODE_EXEC", False)
items = {item.id: item for item in JobService.get_scheduler_jobs()}
assert items["unit-sched-raw"].schedulable is False
assert items["unit-sched-raw"].unschedulable_reason
status = JobService.get_scheduler_status()
assert status.code_exec_enabled is False
assert status.unschedulable_count >= 1
finally:
_cleanup_jobs("unit-sched-raw")
def test_scheduler_schemas_have_expected_fields() -> None:
"""契约字段存在性(前端/运维依赖)。"""
assert {"status", "is_running", "job_count", "code_exec_enabled", "unschedulable_count"} <= set(SchedulerStatusSchema.model_fields)
assert {"id", "name", "trigger", "next_run_time", "status", "schedulable", "unschedulable_reason"} <= set(SchedulerJobSchema.model_fields)
assert {"schedulable", "unschedulable_reason"} <= set(NodeOutSchema.model_fields)
# ── 4) 运行期可见性:WARN 日志带具体标识,不静默失效 ─────────────────────────
async def test_unschedulable_enabled_node_logs_warning_with_identifier(monkeypatch: pytest.MonkeyPatch) -> None:
"""已启用但不可调度的节点:WARN 日志必须带节点 id 与原因,且同一原因只打一次。"""
monkeypatch.setattr(settings, "SCHEDULER_ALLOW_CODE_EXEC", False)
node_service._WARNED_UNSCHEDULABLE.clear()
monkeypatch.setattr(SchedulerUtil, "get_jobs", classmethod(lambda cls, jobstore=None: []))
logs = _CapturedLogs()
sink_id = logger.add(logs, level="WARNING")
service = node_service.NodeService(AuthSchema(), _EmptySession()) # type: ignore[arg-type]
item = NodeOutSchema(id=301, name="不可调度节点", status=0, func="def handler(): pass")
try:
await service._enrich_runtime([item])
await service._enrich_runtime([item]) # 第二次不应重复告警
finally:
logger.remove(sink_id)
text = logs.text()
assert "id=301" in text
assert "不可调度" in text
assert "SCHEDULER_ALLOW_CODE_EXEC" in text
assert text.count("id=301") == 1, f"同一节点同一原因重复告警:{text}"
def test_scheduler_status_logs_warning_for_registered_but_dead_jobs(monkeypatch: pytest.MonkeyPatch) -> None:
"""调度器状态查询:已注册但不可执行的任务要打 WARN(带 job id)。"""
monkeypatch.setattr(settings, "SCHEDULER_ALLOW_CODE_EXEC", True)
_cleanup_jobs("unit-sched-log")
try:
_add_job_with_trigger(
_node(id="unit-sched-log", func="def handler(*a, **k):\n return 'ok'\n"),
DateTrigger(run_date=None),
)
monkeypatch.setattr(settings, "SCHEDULER_ALLOW_CODE_EXEC", False)
logs = _CapturedLogs()
sink_id = logger.add(logs, level="WARNING")
try:
status = JobService.get_scheduler_status()
finally:
logger.remove(sink_id)
assert status.unschedulable_count >= 1
assert "unit-sched-log" in logs.text()
finally:
_cleanup_jobs("unit-sched-log")
# ── 5) 创建/更新/手动执行入口(create/update → register_node_job)──────────────
def test_register_node_job_accepts_builtin_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
"""创建/更新路径(register_node_job)在开关关闭时能注册 builtin: 引用型任务。"""
monkeypatch.setattr(settings, "SCHEDULER_ALLOW_CODE_EXEC", False)
node = _node(
id="unit-reg-builtin",
trigger="date",
trigger_args="2099-01-01 00:00:00",
start_date=None,
end_date=None,
status=0,
)
try:
job = node_service.register_node_job(node)
assert job is not None and str(job.id) == "unit-reg-builtin"
finally:
node_service.unregister_node_job("unit-reg-builtin")
def test_register_node_job_rejects_raw_code_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
"""创建/更新路径在开关关闭时拒绝原始代码块,并给出 builtin 替代写法(不再静默不注册)。"""
monkeypatch.setattr(settings, "SCHEDULER_ALLOW_CODE_EXEC", False)
node = _node(
id="unit-reg-raw",
trigger="date",
trigger_args="2099-01-01 00:00:00",
start_date=None,
end_date=None,
status=0,
func="def handler(*args, **kwargs):\n return 1\n",
)
with pytest.raises(CustomException) as excinfo:
node_service.register_node_job(node)
message = str(excinfo.value)
assert "SCHEDULER_ALLOW_CODE_EXEC" in message and "builtin:" in message
assert SchedulerUtil.get_job(job_id="unit-reg-raw") is None # 未注册成功
def test_run_node_once_allows_builtin_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
"""手动执行入口:开关关闭时 builtin 引用仍可排程执行。"""
monkeypatch.setattr(settings, "SCHEDULER_ALLOW_CODE_EXEC", False)
node = _node(id="unit-once-builtin", trigger=None, trigger_args=None, start_date=None, end_date=None, status=0)
temp_job_id = node_service.run_node_once(node)
try:
assert temp_job_id.startswith("unit-once-builtin")
assert SchedulerUtil.get_job(job_id=temp_job_id) is not None
finally:
node_service.unregister_node_job(temp_job_id)
def test_run_node_once_rejects_raw_code_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
"""手动执行入口:开关关闭时原始代码块被拒绝并提示 builtin 写法。"""
monkeypatch.setattr(settings, "SCHEDULER_ALLOW_CODE_EXEC", False)
node = _node(id="unit-once-raw", trigger=None, func="def handler(): return 1", status=0)
with pytest.raises(CustomException) as excinfo:
node_service.run_node_once(node)
assert "builtin:" in str(excinfo.value)
+516
View File
@@ -0,0 +1,516 @@
"""安全默认值与异常状态码回归测试。
覆盖 backend/audit-security.md 的 S9/S10 与 backend/audit-backend.md 的 H1/H5 同类根因:
- 业务异常按自身状态码返回:客户端可修正类(参数/验证码/权限/不存在)为 4xx;
- 「包装底层异常」的内部故障(DB/驱动/Redis/SDK)显式为 500,不得降级成 4xx(否则监控误判);
- 定时任务代码块执行默认关闭,关闭时给出明确失败原因(而非静默执行/静默跳过);
- 生产环境 CORS 不再回落为通配 ``["*"]``(与 ``ALLOW_CREDENTIALS=True`` 组合会放行任意站点带凭据跨域)。
"""
import pathlib
import re
from datetime import datetime
from types import SimpleNamespace
import pytest
from apscheduler.triggers.date import DateTrigger
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.common.enums import RET, EnvironmentEnum
from app.config.setting import Settings, settings
from app.core.ap_scheduler import SchedulerUtil
from app.core.exceptions import CustomException, handle_exception, resolve_http_status
from app.core.middlewares import CustomCORSMiddleware
from app.modules.task.cronjob.node.service import _add_job_with_trigger
# ── 1. 业务异常按自身状态码返回(默认 4xx) ──────────────────────────────────
def test_custom_exception_defaults_to_4xx() -> None:
"""未显式指定状态码时按业务错误处理,而不是 500。"""
exc = CustomException(msg="验证码已过期,请刷新")
assert exc.status_code == 400
assert exc.code == RET.EXCEPTION.code # 业务码保持不变(响应体结构兼容)
assert exc.success is False
def test_custom_exception_status_resolution() -> None:
"""显式状态码优先;否则按业务码映射;未知码回落 400。"""
assert CustomException(msg="x", status_code=403).status_code == 403
assert CustomException(msg="x", status_code=500).status_code == 500
assert CustomException(msg="x", code=RET.UNAUTHORIZED.code).status_code == 401
assert CustomException(msg="x", code=RET.NOT_FOUND.code).status_code == 404
assert CustomException(msg="x", code=RET.NO_PERMISSION.code).status_code == 403
assert CustomException(msg="x", code=RET.TOO_MANY_REQUESTS.code).status_code == 429
assert CustomException(msg="x", code=RET.SERVERERR.code).status_code == 500
assert resolve_http_status(None) == 400
assert resolve_http_status(999999) == 400
def _exception_probe_app() -> FastAPI:
"""最小应用:注册与生产一致的异常处理器。"""
app = FastAPI()
handle_exception(app)
@app.get("/captcha-expired")
async def _captcha_expired() -> None:
raise CustomException(msg="验证码已过期,请刷新")
@app.get("/forbidden")
async def _forbidden() -> None:
raise CustomException(msg="无权限操作", code=RET.NO_PERMISSION.code, status_code=403)
@app.get("/boom")
async def _boom() -> None:
raise RuntimeError("unexpected server failure")
return app
def test_captcha_expired_returns_4xx_with_stable_body() -> None:
"""实测回归:验证码过期返回 400(此前为 500),响应体结构保持既有约定。"""
client = TestClient(_exception_probe_app(), raise_server_exceptions=False)
response = client.get("/captcha-expired")
assert response.status_code == 400, response.text
body = response.json()
assert set(body) >= {"code", "msg", "data", "status_code", "success"}
assert body["msg"] == "验证码已过期,请刷新"
assert body["success"] is False
assert body["status_code"] == 400
def test_explicit_status_code_is_honored_by_handler() -> None:
"""显式状态码仍按原样返回(403 不被 400 覆盖)。"""
client = TestClient(_exception_probe_app(), raise_server_exceptions=False)
response = client.get("/forbidden")
assert response.status_code == 403, response.text
assert response.json()["code"] == RET.NO_PERMISSION.code
def test_unhandled_exception_still_returns_500() -> None:
"""未捕获的服务端异常仍然是 500(本次修改没有把真正的故障降级成 4xx)。"""
client = TestClient(_exception_probe_app(), raise_server_exceptions=False)
response = client.get("/boom")
assert response.status_code == 500, response.text
# ── 2. 定时任务代码块执行默认关闭 ────────────────────────────────────────────
def test_scheduler_code_exec_default_is_false() -> None:
"""配置默认值必须是 False(只在显式配置为 true 时启用)。"""
assert Settings.model_fields["SCHEDULER_ALLOW_CODE_EXEC"].default is False
def test_task_wrapper_refuses_code_block_with_clear_message(monkeypatch: pytest.MonkeyPatch) -> None:
"""关闭时代码块任务在**执行入口**失败,并给出可理解的拒绝原因。"""
monkeypatch.setattr(settings, "SCHEDULER_ALLOW_CODE_EXEC", False)
with pytest.raises(RuntimeError) as excinfo:
SchedulerUtil._task_wrapper("job-1", "def handler(*args, **kwargs):\n return 'executed'\n")
message = str(excinfo.value)
assert "SCHEDULER_ALLOW_CODE_EXEC" in message
assert "拒绝执行" in message
def test_task_wrapper_runs_only_when_explicitly_enabled(monkeypatch: pytest.MonkeyPatch) -> None:
"""显式开启后代码块才被执行(证明开关语义正确、不是"永远拒绝")。"""
monkeypatch.setattr(settings, "SCHEDULER_ALLOW_CODE_EXEC", True)
result = SchedulerUtil._task_wrapper("job-2", "def handler(*args, **kwargs):\n return 'executed'\n")
assert result == "executed"
def test_task_wrapper_without_code_block_still_allowed(monkeypatch: pytest.MonkeyPatch) -> None:
"""内置函数型任务(无用户代码块)不受该开关影响。"""
monkeypatch.setattr(settings, "SCHEDULER_ALLOW_CODE_EXEC", False)
assert SchedulerUtil._task_wrapper("job-3", None) is None
def test_registering_code_block_job_reports_clear_error_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
"""注册入口同样给出明确失败提示(而不是把任务静默注册后不执行)。"""
monkeypatch.setattr(settings, "SCHEDULER_ALLOW_CODE_EXEC", False)
node = SimpleNamespace(
id=7,
name="演示节点",
func="def handler(*args, **kwargs):\n return 'x'\n",
jobstore="memory",
executor="threadpool",
args=None,
kwargs=None,
coalesce=False,
max_instances=1,
)
with pytest.raises(CustomException) as excinfo:
_add_job_with_trigger(node, DateTrigger(run_date=datetime.now()))
assert "SCHEDULER_ALLOW_CODE_EXEC" in str(excinfo.value)
# ── 3. 生产 CORS 不再回落通配 ────────────────────────────────────────────────
def _cors_probe_app() -> FastAPI:
app = FastAPI()
app.add_middleware(CustomCORSMiddleware)
@app.get("/probe")
async def _probe() -> dict[str, bool]:
return {"ok": True}
return app
def test_prod_cors_is_empty_when_not_configured(monkeypatch: pytest.MonkeyPatch) -> None:
"""生产未配置 PROD_CORS_ORIGINS → 空列表(安全默认),不再是 ["*"]。"""
monkeypatch.setattr(settings, "ENVIRONMENT", EnvironmentEnum.PROD)
monkeypatch.setattr(settings, "PROD_CORS_ORIGINS", "")
assert settings.ALLOW_ORIGINS == []
def test_prod_cors_drops_wildcard(monkeypatch: pytest.MonkeyPatch) -> None:
"""显式配置通配符也要被剔除(与 ALLOW_CREDENTIALS=True 组合不可接受)。"""
monkeypatch.setattr(settings, "ENVIRONMENT", EnvironmentEnum.PROD)
monkeypatch.setattr(settings, "PROD_CORS_ORIGINS", "*")
assert settings.ALLOW_ORIGINS == []
monkeypatch.setattr(settings, "PROD_CORS_ORIGINS", "https://ok.example.com, *")
assert settings.ALLOW_ORIGINS == ["https://ok.example.com"]
def test_prod_cors_keeps_explicit_origins(monkeypatch: pytest.MonkeyPatch) -> None:
"""显式配置的域名清单被保留(去掉空白项)。"""
monkeypatch.setattr(settings, "ENVIRONMENT", EnvironmentEnum.PROD)
monkeypatch.setattr(settings, "PROD_CORS_ORIGINS", " https://a.example.com , https://b.example.com , ")
assert settings.ALLOW_ORIGINS == ["https://a.example.com", "https://b.example.com"]
def test_dev_cors_keeps_wildcard(monkeypatch: pytest.MonkeyPatch) -> None:
"""非生产环境保留通配(本地联调便利),安全默认只收紧生产。"""
monkeypatch.setattr(settings, "ENVIRONMENT", EnvironmentEnum.DEV)
assert settings.ALLOW_ORIGINS == ["*"]
def test_prod_cors_headers_absent_for_unknown_origin(monkeypatch: pytest.MonkeyPatch) -> None:
"""端到端:生产未配置域名时,任意来源都拿不到跨域许可头。"""
monkeypatch.setattr(settings, "ENVIRONMENT", EnvironmentEnum.PROD)
monkeypatch.setattr(settings, "PROD_CORS_ORIGINS", "")
client = TestClient(_cors_probe_app())
response = client.get("/probe", headers={"Origin": "https://evil.tld"})
assert response.status_code == 200
assert "access-control-allow-origin" not in {key.lower() for key in response.headers}
def test_prod_cors_allows_only_configured_origin(monkeypatch: pytest.MonkeyPatch) -> None:
"""端到端:只有显式配置的来源拿到许可头,其它来源仍被拒绝。"""
monkeypatch.setattr(settings, "ENVIRONMENT", EnvironmentEnum.PROD)
monkeypatch.setattr(settings, "PROD_CORS_ORIGINS", "https://ok.example.com")
client = TestClient(_cors_probe_app())
allowed = client.get("/probe", headers={"Origin": "https://ok.example.com"})
assert allowed.headers.get("access-control-allow-origin") == "https://ok.example.com"
denied = client.get("/probe", headers={"Origin": "https://evil.tld"})
assert "access-control-allow-origin" not in {key.lower() for key in denied.headers}
# ── 4. 内部故障(包装底层异常)必须为 500,客户端错误保持 4xx ─────────────────
#
# 背景:t10 把 CustomException 的默认状态码从 500 改为 400 后,「包装底层异常」的路径
# (数据库/驱动/Redis/SDK 故障)也被一并降级成 400,监控会把服务端故障误判为客户端错误。
# 这里固化两类语义:包装底层异常 → 500;参数/权限/不存在 → 4xx。
_REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
class _FailingSession:
"""所有 SQL 操作都抛异常的假会话:模拟数据库/驱动故障。"""
@staticmethod
def _boom(*_args: object, **_kwargs: object) -> None:
raise RuntimeError("模拟底层驱动故障: connection lost")
def add(self, *_args: object, **_kwargs: object) -> None:
self._boom()
async def execute(self, *_args: object, **_kwargs: object) -> None:
self._boom()
async def flush(self, *_args: object, **_kwargs: object) -> None:
self._boom()
async def refresh(self, *_args: object, **_kwargs: object) -> None:
self._boom()
async def delete(self, *_args: object, **_kwargs: object) -> None:
self._boom()
class _EmptyResult:
"""空结果集:让 get_or_404 走到"查不到"分支。"""
def scalars(self) -> "_EmptyResult":
return self
def first(self) -> None:
return None
def all(self) -> list:
return []
def scalar(self) -> int:
return 0
def fetchall(self) -> list:
return []
class _EmptySession:
"""execute 恒返回空结果的假会话。"""
async def execute(self, *_args: object, **_kwargs: object) -> _EmptyResult:
return _EmptyResult()
def _crud(db: object, model: type | None = None):
"""构造 CRUD 实例(默认挂在一个真实模型上,避免测试私自注册新模型污染 metadata)。"""
from app.core.base_crud import CRUDBase
from app.core.base_schema import AuthSchema
from app.modules.system.role.model import RoleModel
return CRUDBase(model=model or RoleModel, auth=AuthSchema(), db=db) # type: ignore[arg-type]
@pytest.mark.parametrize(
("case", "call"),
[
("get", lambda c: c.get(id=1)),
("count", lambda c: c.count()),
("exists", lambda c: c.exists(id=1)),
("get_list", lambda c: c.get_list()),
("page", lambda c: c.page(offset=0, limit=10, order_by=[{"id": "asc"}])),
("create", lambda c: c.create(data={"name": "x"})),
("update", lambda c: c.update(id=1, data={"name": "x"})),
("delete", lambda c: c.delete(ids=[1])),
("clear", lambda c: c.clear()),
("set", lambda c: c.set(ids=[1], status=0)),
],
)
async def test_base_crud_wrapped_internal_failure_returns_500(case: str, call) -> None:
"""CRUD 公共出口包装底层异常时必须 500(此前被 t10 降级成 400)。"""
with pytest.raises(CustomException) as excinfo:
await call(_crud(_FailingSession()))
assert excinfo.value.status_code == 500, f"{case} 未按 500 返回(实际 {excinfo.value.status_code})"
assert excinfo.value.success is False
assert "失败" in excinfo.value.msg
async def test_get_or_404_returns_404_for_missing_row() -> None:
"""资源不存在 → 404(客户端可修正类错误,仍是 4xx)。"""
with pytest.raises(CustomException) as excinfo:
await _crud(_EmptySession()).get_or_404(id=1, msg="该数据不存在")
assert excinfo.value.status_code == 404
assert excinfo.value.msg == "该数据不存在"
def test_client_side_errors_keep_4xx() -> None:
"""客户端类错误保持 4xx:验证码过期/参数类 400、无权限 403、资源不存在 404。"""
assert CustomException(msg="验证码已过期,请刷新").status_code == 400
assert CustomException(msg="请求参数错误").status_code == 400
assert CustomException(msg="无权限操作", code=RET.NO_PERMISSION.code).status_code == 403
assert CustomException(msg="该数据不存在", status_code=404).status_code == 404
assert resolve_http_status(RET.NOT_FOUND.code) == 404
assert resolve_http_status(RET.NO_PERMISSION.code) == 403
assert resolve_http_status(RET.BAD_REQUEST.code) == 400
assert resolve_http_status(RET.SERVERERR.code) == 500
def test_internal_failure_body_keeps_business_code_and_message() -> None:
"""内部故障 500 时响应体仍带原始 msg 与业务 code(前端依赖 code=-1 展示真实原因)。"""
app = FastAPI()
handle_exception(app)
@app.get("/internal")
async def _internal() -> None:
raise CustomException(msg="统计失败: 模拟底层驱动故障", status_code=500)
response = TestClient(app, raise_server_exceptions=False).get("/internal")
assert response.status_code == 500
body = response.json()
assert body["msg"] == "统计失败: 模拟底层驱动故障"
assert body["code"] == RET.EXCEPTION.code
# 与实现同源的分类规则(见 app/core/exceptions.py 的说明与 t11 的清理范围)
_EXC_INTERP = re.compile(r"\{(?:e|exc|err|error|ex|exception)(?:!s|!r|:[a-z]+)?\}|\{errors\[|_error_desc\(")
_EXC_CALL = re.compile(r"CustomException\(")
_CLIENT_MARKERS = (
"targets 参数格式错误",
"创建成功但任务注册失败",
"更新成功但任务注册失败",
"启用任务失败",
'msg=f"执行失败: {e!s}"',
"本地目录不存在",
"不支持的存储协议",
"存储源不存在",
"存储源已停用",
"不允许上传此类型的文件",
"流程保存失败",
"执行失败,连线",
"创建失败,该字典类型下的",
"更新失败,该字典类型下的",
"删除失败,ID为",
"角色不存在",
"菜单不存在",
"部门不存在",
"用户不存在",
"岗位不存在",
"导入文件缺少必要的列",
"不允许从",
"工单[",
)
def _iter_custom_exception_calls(path: pathlib.Path):
"""遍历文件里每个 CustomException(...) 调用(含多行调用)。"""
text = path.read_text(encoding="utf-8")
for match in _EXC_CALL.finditer(text):
depth, index = 0, match.end() - 1
while index < len(text):
if text[index] == "(":
depth += 1
elif text[index] == ")":
depth -= 1
if depth == 0:
break
index += 1
yield text[: match.start()].count("\n") + 1, text[match.start() : index + 1]
def test_all_wrapped_internal_failures_are_explicitly_500() -> None:
"""守卫:inScope 内凡是「包装底层异常」的 CustomException 都必须显式 500。
否则底层故障会被默认值(400)静默降级成客户端错误,监控失去信号。
"""
offenders: list[str] = []
checked = 0
targets = [_REPO_ROOT / "app" / "core" / "base_crud.py"] + sorted((_REPO_ROOT / "app" / "modules").rglob("*.py"))
for path in targets:
for line, call in _iter_custom_exception_calls(path):
if not _EXC_INTERP.search(call):
continue
if any(marker in call for marker in _CLIENT_MARKERS):
continue
checked += 1
if "status_code=500" not in call:
offenders.append(f"{path.relative_to(_REPO_ROOT)}:{line}")
assert checked > 90, f"分类用例数异常(只匹配到 {checked} 处),分类规则可能失效"
assert offenders == [], f"以下代码生成路径仍可能把内部故障降级为 4xx:{offenders}"
def test_base_crud_internal_wrappers_are_500() -> None:
"""core 层专门固化:base_crud.py 全部 9 处包装路径显式 500。"""
calls = [
(line, call)
for line, call in _iter_custom_exception_calls(_REPO_ROOT / "app" / "core" / "base_crud.py")
if _EXC_INTERP.search(call)
]
assert len(calls) == 9, f"base_crud.py 包装路径数量变化({len(calls)}):{[(line, call[:30]) for line, call in calls]}"
missing = [line for line, call in calls if "status_code=500" not in call]
assert missing == [], f"base_crud.py 以下行未显式 500:{missing}"
# ── 5. 升级可见性:CORS 严格启动、主机放行开关、健康检查字段 ──────────────────
#
# 背景:t13 把三处「静默行为变更」做成可配置 + 可观测:
# - CORS_STRICT_STARTUP:生产未配置 CORS 白名单时从「只告警」变为「启动失败」;
# - ALLOW_LOCALHOST_HOSTS:localhost 放行从无条件写死变为可开关;
# - 健康检查暴露 code_exec_enabled / cors_origins_configured,避免升级后「任务不动了」无迹可循。
def test_cors_strict_startup_fails_fast(monkeypatch: pytest.MonkeyPatch) -> None:
"""严格模式下:生产未配置白名单 → 直接抛错(启动失败),而不是静默告警。"""
strict = Settings(
_env_file=None,
ENVIRONMENT=EnvironmentEnum.PROD,
PROD_CORS_ORIGINS="",
CORS_STRICT_STARTUP=True,
)
with pytest.raises(RuntimeError) as excinfo:
_ = strict.ALLOW_ORIGINS
assert "PROD_CORS_ORIGINS" in str(excinfo.value)
# 默认(非严格)仍是安全默认 + 告警,不阻断启动
lax = Settings(_env_file=None, ENVIRONMENT=EnvironmentEnum.PROD, PROD_CORS_ORIGINS="", CORS_STRICT_STARTUP=False)
assert lax.ALLOW_ORIGINS == []
def test_localhost_hosts_are_configurable() -> None:
"""localhost/回环放行可开关:默认兼容容器健康检查,生产可收敛 Host 校验范围。"""
default = Settings(_env_file=None, ALLOW_LOCALHOST_HOSTS=True)
for host in ("localhost", "127.0.0.1", "[::1]"):
assert host in default.ALLOWED_HOSTS
restricted = Settings(_env_file=None, ALLOW_LOCALHOST_HOSTS=False)
assert "localhost" not in restricted.ALLOWED_HOSTS
assert "127.0.0.1" not in restricted.ALLOWED_HOSTS
assert restricted.ALLOWED_HOSTS == ["service.fastapiadmin.com", "*.fastapiadmin.com"]
def test_oauth_allowed_hosts_default_is_not_wildcard() -> None:
"""OAuth 回调域名白名单默认不再是 "*"(Host 注入不再自动放行)。"""
defaults = Settings(_env_file=None)
assert "*" not in defaults.OAUTH_ALLOWED_HOSTS
assert "service.fastapiadmin.com" in defaults.OAUTH_ALLOWED_HOSTS
def test_health_exposes_scheduler_and_cors_state() -> None:
"""健康检查暴露「代码执行开关 / CORS 是否已配置」,让升级后的行为变更可观测。"""
from app.modules.monitor.health.schema import ServiceInfoOut
fields = ServiceInfoOut.model_fields
assert "code_exec_enabled" in fields
assert "cors_origins_configured" in fields
async def test_health_collect_reports_code_exec_flag(monkeypatch: pytest.MonkeyPatch) -> None:
"""collect() 实际填充该字段(与 settings 联动)。"""
from app.modules.monitor.health.service import HealthService
monkeypatch.setattr(settings, "SCHEDULER_ALLOW_CODE_EXEC", False)
info = await HealthService.collect(None)
assert info.code_exec_enabled is False
assert set(info.model_dump()) >= {"name", "version", "environment", "db_status", "redis_status", "code_exec_enabled", "cors_origins_configured"}
# ── 6. 内置函数型任务:开关只关闭 exec,不关闭整个调度特性 ─────────────────────
#
# 背景:SCHEDULER_ALLOW_CODE_EXEC 默认 False 后,若没有"非 exec 的合法任务路径",
# 升级等于停掉全部节点型定时任务。内置处理器白名单(func = "builtin:<模块名>[.<函数名>]")
# 提供不经 exec 的执行路径,因此该开关只收敛 RCE 面。
def test_builtin_handler_runs_without_code_exec(monkeypatch: pytest.MonkeyPatch) -> None:
"""代码执行关闭时,内置函数型任务仍可正常调度(不经 exec)。"""
monkeypatch.setattr(settings, "SCHEDULER_ALLOW_CODE_EXEC", False)
result = SchedulerUtil._task_wrapper("job-builtin", "builtin:demo_handler")
assert isinstance(result, dict) and result.get("message")
def test_builtin_handler_can_be_registered_explicitly(monkeypatch: pytest.MonkeyPatch) -> None:
"""注册表可显式注册处理器(供业务侧接入自有白名单函数)。"""
monkeypatch.setattr(settings, "SCHEDULER_ALLOW_CODE_EXEC", False)
SchedulerUtil.register_builtin_handler("unit_test_probe", lambda *a, **k: "unit-ok")
assert SchedulerUtil.resolve_builtin_handler("builtin:unit_test_probe") is not None
assert SchedulerUtil._task_wrapper("job-custom", "builtin:unit_test_probe") == "unit-ok"
def test_builtin_reference_never_falls_back_to_exec(monkeypatch: pytest.MonkeyPatch) -> None:
"""非法/未知 builtin 引用必须报错,绝不能回退成 exec 执行。"""
monkeypatch.setattr(settings, "SCHEDULER_ALLOW_CODE_EXEC", True) # 即使开关打开也不允许回退
for bad in ("builtin:../../etc/passwd", "builtin:nope.nope", "builtin:", "builtin:os.system"):
with pytest.raises(ValueError):
SchedulerUtil._task_wrapper("job-bad", bad)
def test_non_builtin_reference_is_not_treated_as_builtin() -> None:
"""普通代码块不会被误判成内置引用。"""
assert SchedulerUtil.resolve_builtin_handler("def handler(): return 1") is None
assert SchedulerUtil.resolve_builtin_handler(None) is None