mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-10-15 21:35:34 +00:00
本批为 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(含恶意载荷、闸门红绿、静态护栏)
329 lines
15 KiB
Python
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)
|