Files
FastapiAdmin/backend/tests/test_scheduler_schedulability.py
T
zhangtao 07fa789d2e 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(含恶意载荷、闸门红绿、静态护栏)
2026-10-04 05:12:32 +08:00

329 lines
15 KiB
Python

"""定时任务可调度性回归测试(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)