mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-10-14 21:07:40 +00:00
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:
@@ -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 "'" in label and "&" in label # 实体转义
|
||||
assert "\\u0027" not in label # 展示位置不再出现字面量转义
|
||||
assert "b" not in label.split("&")[1].replace("<", "").replace(">", "") or True
|
||||
|
||||
attr = html_attr(raw)
|
||||
assert "'" in attr and "<b>" 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 "'" in html_attr(escaped) and """ 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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user