mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-08 14:56:03 +00:00
后端没修完版
This commit is contained in:
0
tests/unit_tests/workflow/__init__.py
Normal file
0
tests/unit_tests/workflow/__init__.py
Normal file
351
tests/unit_tests/workflow/test_executor.py
Normal file
351
tests/unit_tests/workflow/test_executor.py
Normal file
@@ -0,0 +1,351 @@
|
||||
"""Tests for the workflow execution engine."""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..', 'src'))
|
||||
|
||||
from langbot.pkg.workflow.entities import (
|
||||
WorkflowDefinition,
|
||||
NodeDefinition,
|
||||
EdgeDefinition,
|
||||
ExecutionContext,
|
||||
ExecutionStatus,
|
||||
NodeStatus,
|
||||
MessageContext,
|
||||
)
|
||||
from langbot.pkg.workflow.executor import WorkflowExecutor, LoopExecutor
|
||||
from langbot.pkg.workflow.node import WorkflowNode, NodePort
|
||||
from langbot.pkg.workflow.registry import NodeTypeRegistry
|
||||
|
||||
|
||||
# ── Test helpers ─────────────────────────────────────────────────────
|
||||
|
||||
class PassthroughNode(WorkflowNode):
|
||||
"""Simple node that passes input to output."""
|
||||
type_name = "passthrough"
|
||||
category = "process"
|
||||
|
||||
async def execute(self, inputs, context):
|
||||
return {"output": inputs.get("input", "default")}
|
||||
|
||||
|
||||
class FailingNode(WorkflowNode):
|
||||
"""Node that always fails."""
|
||||
type_name = "failing"
|
||||
category = "process"
|
||||
|
||||
async def execute(self, inputs, context):
|
||||
raise RuntimeError("intentional failure")
|
||||
|
||||
|
||||
class AccumulatorNode(WorkflowNode):
|
||||
"""Node that appends its id to a context variable for tracking execution order."""
|
||||
type_name = "accumulator"
|
||||
category = "process"
|
||||
|
||||
async def execute(self, inputs, context):
|
||||
order = context.variables.get("_exec_order", [])
|
||||
order.append(self.node_id)
|
||||
context.variables["_exec_order"] = order
|
||||
return {"output": self.node_id}
|
||||
|
||||
|
||||
class ConditionTrueNode(WorkflowNode):
|
||||
"""Node that outputs a truthy value."""
|
||||
type_name = "cond_true"
|
||||
category = "control"
|
||||
|
||||
async def execute(self, inputs, context):
|
||||
return {"result": True}
|
||||
|
||||
|
||||
def _make_registry(*node_classes) -> NodeTypeRegistry:
|
||||
"""Create a fresh registry with given node classes."""
|
||||
reg = NodeTypeRegistry()
|
||||
for cls in node_classes:
|
||||
cat = getattr(cls, 'category', 'process')
|
||||
reg.register(f"{cat}.{cls.type_name}", cls)
|
||||
return reg
|
||||
|
||||
|
||||
def _make_context(workflow_id="wf-test") -> ExecutionContext:
|
||||
return ExecutionContext(
|
||||
execution_id="exec-test",
|
||||
workflow_id=workflow_id,
|
||||
)
|
||||
|
||||
|
||||
def _node(id: str, type: str, config=None) -> NodeDefinition:
|
||||
return NodeDefinition(id=id, type=type, config=config or {})
|
||||
|
||||
|
||||
def _edge(id: str, src: str, tgt: str, condition=None) -> EdgeDefinition:
|
||||
return EdgeDefinition(
|
||||
id=id, source_node=src, target_node=tgt, condition=condition
|
||||
)
|
||||
|
||||
|
||||
# ── Tests ────────────────────────────────────────────────────────────
|
||||
|
||||
class TestLinearWorkflow:
|
||||
"""Test simple linear A → B → C workflows."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_single_node(self):
|
||||
reg = _make_registry(PassthroughNode)
|
||||
executor = WorkflowExecutor()
|
||||
executor.registry = reg
|
||||
|
||||
wf = WorkflowDefinition(
|
||||
uuid="wf-1", name="test",
|
||||
nodes=[_node("n1", "process.passthrough")],
|
||||
edges=[],
|
||||
)
|
||||
ctx = _make_context()
|
||||
result = await executor.execute(wf, ctx)
|
||||
|
||||
assert result.status == ExecutionStatus.COMPLETED
|
||||
assert result.node_states["n1"].status == NodeStatus.COMPLETED
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_two_node_chain(self):
|
||||
reg = _make_registry(AccumulatorNode)
|
||||
executor = WorkflowExecutor()
|
||||
executor.registry = reg
|
||||
|
||||
wf = WorkflowDefinition(
|
||||
uuid="wf-2", name="test",
|
||||
nodes=[
|
||||
_node("a", "process.accumulator"),
|
||||
_node("b", "process.accumulator"),
|
||||
],
|
||||
edges=[_edge("e1", "a", "b")],
|
||||
)
|
||||
ctx = _make_context()
|
||||
result = await executor.execute(wf, ctx)
|
||||
|
||||
assert result.status == ExecutionStatus.COMPLETED
|
||||
assert result.variables["_exec_order"] == ["a", "b"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_three_node_chain(self):
|
||||
reg = _make_registry(AccumulatorNode)
|
||||
executor = WorkflowExecutor()
|
||||
executor.registry = reg
|
||||
|
||||
wf = WorkflowDefinition(
|
||||
uuid="wf-3", name="test",
|
||||
nodes=[
|
||||
_node("a", "process.accumulator"),
|
||||
_node("b", "process.accumulator"),
|
||||
_node("c", "process.accumulator"),
|
||||
],
|
||||
edges=[
|
||||
_edge("e1", "a", "b"),
|
||||
_edge("e2", "b", "c"),
|
||||
],
|
||||
)
|
||||
ctx = _make_context()
|
||||
result = await executor.execute(wf, ctx)
|
||||
|
||||
assert result.status == ExecutionStatus.COMPLETED
|
||||
assert result.variables["_exec_order"] == ["a", "b", "c"]
|
||||
|
||||
|
||||
class TestFailureHandling:
|
||||
"""Test node failure and retry behavior."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failing_node_marks_failed(self):
|
||||
reg = _make_registry(FailingNode)
|
||||
executor = WorkflowExecutor()
|
||||
executor.registry = reg
|
||||
|
||||
wf = WorkflowDefinition(
|
||||
uuid="wf-fail", name="test",
|
||||
nodes=[_node("n1", "process.failing")],
|
||||
edges=[],
|
||||
settings={"max_retries": 0},
|
||||
)
|
||||
ctx = _make_context()
|
||||
result = await executor.execute(wf, ctx)
|
||||
|
||||
assert result.status == ExecutionStatus.FAILED
|
||||
assert result.node_states["n1"].status == NodeStatus.FAILED
|
||||
assert "intentional failure" in result.node_states["n1"].error
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failure_stops_downstream(self):
|
||||
reg = _make_registry(FailingNode, AccumulatorNode)
|
||||
executor = WorkflowExecutor()
|
||||
executor.registry = reg
|
||||
|
||||
wf = WorkflowDefinition(
|
||||
uuid="wf-stop", name="test",
|
||||
nodes=[
|
||||
_node("a", "process.failing"),
|
||||
_node("b", "process.accumulator"),
|
||||
],
|
||||
edges=[_edge("e1", "a", "b")],
|
||||
settings={"max_retries": 0},
|
||||
)
|
||||
ctx = _make_context()
|
||||
result = await executor.execute(wf, ctx)
|
||||
|
||||
assert result.node_states["a"].status == NodeStatus.FAILED
|
||||
# b should not have been executed
|
||||
assert result.node_states["b"].status == NodeStatus.PENDING
|
||||
|
||||
|
||||
class TestConditionalEdges:
|
||||
"""Test edge condition evaluation."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_true_condition_passes(self):
|
||||
reg = _make_registry(AccumulatorNode)
|
||||
executor = WorkflowExecutor()
|
||||
executor.registry = reg
|
||||
|
||||
wf = WorkflowDefinition(
|
||||
uuid="wf-cond", name="test",
|
||||
nodes=[
|
||||
_node("a", "process.accumulator"),
|
||||
_node("b", "process.accumulator"),
|
||||
],
|
||||
edges=[_edge("e1", "a", "b", condition="1 == 1")],
|
||||
)
|
||||
ctx = _make_context()
|
||||
result = await executor.execute(wf, ctx)
|
||||
|
||||
assert result.variables["_exec_order"] == ["a", "b"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_false_condition_skips(self):
|
||||
reg = _make_registry(AccumulatorNode)
|
||||
executor = WorkflowExecutor()
|
||||
executor.registry = reg
|
||||
|
||||
wf = WorkflowDefinition(
|
||||
uuid="wf-cond2", name="test",
|
||||
nodes=[
|
||||
_node("a", "process.accumulator"),
|
||||
_node("b", "process.accumulator"),
|
||||
],
|
||||
edges=[_edge("e1", "a", "b", condition="1 == 2")],
|
||||
)
|
||||
ctx = _make_context()
|
||||
result = await executor.execute(wf, ctx)
|
||||
|
||||
# Only a should execute; b is skipped because condition is false
|
||||
assert result.variables["_exec_order"] == ["a"]
|
||||
|
||||
|
||||
class TestDiamondGraph:
|
||||
"""Test diamond-shaped DAG: A → B, A → C, B → D, C → D."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_diamond_executes_all(self):
|
||||
"""D should execute once (not be skipped as circular)."""
|
||||
reg = _make_registry(AccumulatorNode)
|
||||
executor = WorkflowExecutor()
|
||||
executor.registry = reg
|
||||
|
||||
wf = WorkflowDefinition(
|
||||
uuid="wf-diamond", name="test",
|
||||
nodes=[
|
||||
_node("a", "process.accumulator"),
|
||||
_node("b", "process.accumulator"),
|
||||
_node("c", "process.accumulator"),
|
||||
_node("d", "process.accumulator"),
|
||||
],
|
||||
edges=[
|
||||
_edge("e1", "a", "b"),
|
||||
_edge("e2", "a", "c"),
|
||||
_edge("e3", "b", "d"),
|
||||
_edge("e4", "c", "d"),
|
||||
],
|
||||
)
|
||||
ctx = _make_context()
|
||||
result = await executor.execute(wf, ctx)
|
||||
|
||||
assert result.status == ExecutionStatus.COMPLETED
|
||||
# All four nodes should complete
|
||||
for nid in ["a", "b", "c", "d"]:
|
||||
assert result.node_states[nid].status == NodeStatus.COMPLETED
|
||||
|
||||
|
||||
class TestUnknownNodeType:
|
||||
"""Test handling of unregistered node types."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_type_fails(self):
|
||||
reg = _make_registry() # empty registry
|
||||
executor = WorkflowExecutor()
|
||||
executor.registry = reg
|
||||
|
||||
wf = WorkflowDefinition(
|
||||
uuid="wf-unk", name="test",
|
||||
nodes=[_node("n1", "process.nonexistent")],
|
||||
edges=[],
|
||||
)
|
||||
ctx = _make_context()
|
||||
result = await executor.execute(wf, ctx)
|
||||
|
||||
assert result.node_states["n1"].status == NodeStatus.FAILED
|
||||
assert "Unknown node type" in result.node_states["n1"].error
|
||||
|
||||
|
||||
class TestMessageContext:
|
||||
"""Test that message context is available to nodes."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_context_in_inputs(self):
|
||||
reg = _make_registry(PassthroughNode)
|
||||
executor = WorkflowExecutor()
|
||||
executor.registry = reg
|
||||
|
||||
wf = WorkflowDefinition(
|
||||
uuid="wf-msg", name="test",
|
||||
nodes=[_node("n1", "process.passthrough")],
|
||||
edges=[],
|
||||
)
|
||||
ctx = _make_context()
|
||||
ctx.message_context = MessageContext(
|
||||
message_id="msg-1",
|
||||
message_content="hello world",
|
||||
sender_id="user-1",
|
||||
)
|
||||
result = await executor.execute(wf, ctx)
|
||||
|
||||
assert result.status == ExecutionStatus.COMPLETED
|
||||
# message_content should be in the resolved inputs
|
||||
n1_inputs = result.node_states["n1"].inputs
|
||||
assert n1_inputs.get("message_content") == "hello world"
|
||||
|
||||
|
||||
class TestExecutionHistory:
|
||||
"""Test that execution steps are recorded."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_history_recorded(self):
|
||||
reg = _make_registry(AccumulatorNode)
|
||||
executor = WorkflowExecutor()
|
||||
executor.registry = reg
|
||||
|
||||
wf = WorkflowDefinition(
|
||||
uuid="wf-hist", name="test",
|
||||
nodes=[
|
||||
_node("a", "process.accumulator"),
|
||||
_node("b", "process.accumulator"),
|
||||
],
|
||||
edges=[_edge("e1", "a", "b")],
|
||||
)
|
||||
ctx = _make_context()
|
||||
result = await executor.execute(wf, ctx)
|
||||
|
||||
assert len(result.history) == 2
|
||||
assert result.history[0].node_id == "a"
|
||||
assert result.history[1].node_id == "b"
|
||||
assert result.history[0].status == "completed"
|
||||
133
tests/unit_tests/workflow/test_registry.py
Normal file
133
tests/unit_tests/workflow/test_registry.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""Tests for the node type registry."""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..', 'src'))
|
||||
|
||||
from langbot.pkg.workflow.node import WorkflowNode, NodePort
|
||||
from langbot.pkg.workflow.registry import NodeTypeRegistry
|
||||
|
||||
|
||||
class DummyNode(WorkflowNode):
|
||||
type_name = "dummy"
|
||||
category = "process"
|
||||
name = "dummy"
|
||||
description = "A dummy node"
|
||||
|
||||
async def execute(self, inputs, context):
|
||||
return {"output": "ok"}
|
||||
|
||||
|
||||
class TriggerNode(WorkflowNode):
|
||||
type_name = "my_trigger"
|
||||
category = "trigger"
|
||||
name = "my_trigger"
|
||||
|
||||
async def execute(self, inputs, context):
|
||||
return {}
|
||||
|
||||
|
||||
class TestRegistryBasics:
|
||||
def setup_method(self):
|
||||
self.reg = NodeTypeRegistry()
|
||||
|
||||
def test_register_and_get(self):
|
||||
self.reg.register("process.dummy", DummyNode)
|
||||
assert self.reg.get("process.dummy") is DummyNode
|
||||
|
||||
def test_get_missing_returns_none(self):
|
||||
assert self.reg.get("process.nonexistent") is None
|
||||
|
||||
def test_has_type(self):
|
||||
self.reg.register("process.dummy", DummyNode)
|
||||
assert self.reg.has_type("process.dummy") is True
|
||||
assert self.reg.has_type("process.missing") is False
|
||||
|
||||
def test_count(self):
|
||||
assert self.reg.count() == 0
|
||||
self.reg.register("process.dummy", DummyNode)
|
||||
assert self.reg.count() == 1
|
||||
|
||||
def test_clear(self):
|
||||
self.reg.register("process.dummy", DummyNode)
|
||||
self.reg.clear()
|
||||
assert self.reg.count() == 0
|
||||
|
||||
def test_unregister(self):
|
||||
self.reg.register("process.dummy", DummyNode)
|
||||
self.reg.unregister("process.dummy")
|
||||
assert self.reg.get("process.dummy") is None
|
||||
assert self.reg.count() == 0
|
||||
|
||||
|
||||
class TestRegistryLookupFormats:
|
||||
"""Test that both full and short name lookups work."""
|
||||
|
||||
def setup_method(self):
|
||||
self.reg = NodeTypeRegistry()
|
||||
self.reg.register("process.dummy", DummyNode)
|
||||
|
||||
def test_full_name_lookup(self):
|
||||
assert self.reg.get("process.dummy") is DummyNode
|
||||
|
||||
def test_short_name_lookup(self):
|
||||
"""Short name (type_name only) should also resolve."""
|
||||
assert self.reg.get("dummy") is DummyNode
|
||||
|
||||
|
||||
class TestRegistryCategories:
|
||||
def setup_method(self):
|
||||
self.reg = NodeTypeRegistry()
|
||||
self.reg.register("process.dummy", DummyNode)
|
||||
self.reg.register("trigger.my_trigger", TriggerNode)
|
||||
|
||||
def test_list_by_category(self):
|
||||
process_nodes = self.reg.list_by_category("process")
|
||||
assert len(process_nodes) == 1
|
||||
assert process_nodes[0]["type"] == "process.dummy"
|
||||
|
||||
def test_list_by_category_empty(self):
|
||||
assert self.reg.list_by_category("action") == []
|
||||
|
||||
def test_get_categories(self):
|
||||
cats = self.reg.get_categories()
|
||||
assert "process" in cats
|
||||
assert "trigger" in cats
|
||||
assert len(cats["process"]) == 1
|
||||
assert len(cats["trigger"]) == 1
|
||||
|
||||
|
||||
class TestCreateInstance:
|
||||
def setup_method(self):
|
||||
self.reg = NodeTypeRegistry()
|
||||
self.reg.register("process.dummy", DummyNode)
|
||||
|
||||
def test_create_instance(self):
|
||||
inst = self.reg.create_instance("process.dummy", "node-1", {"key": "val"})
|
||||
assert inst is not None
|
||||
assert inst.node_id == "node-1"
|
||||
assert inst.config == {"key": "val"}
|
||||
|
||||
def test_create_instance_short_name(self):
|
||||
inst = self.reg.create_instance("dummy", "node-2", {})
|
||||
assert inst is not None
|
||||
|
||||
def test_create_instance_missing(self):
|
||||
inst = self.reg.create_instance("process.nonexistent", "node-3", {})
|
||||
assert inst is None
|
||||
|
||||
|
||||
class TestNodeSchema:
|
||||
"""Test to_schema() output."""
|
||||
|
||||
def test_schema_has_required_fields(self):
|
||||
schema = DummyNode.to_schema()
|
||||
assert schema["type"] == "process.dummy"
|
||||
assert schema["category"] == "process"
|
||||
assert "label" in schema
|
||||
assert "description" in schema
|
||||
assert "inputs" in schema
|
||||
assert "outputs" in schema
|
||||
assert "config_schema" in schema
|
||||
191
tests/unit_tests/workflow/test_safe_eval.py
Normal file
191
tests/unit_tests/workflow/test_safe_eval.py
Normal file
@@ -0,0 +1,191 @@
|
||||
"""Tests for the safe expression evaluator that replaced eval()."""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import pytest
|
||||
|
||||
# Add project root to path
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..', 'src'))
|
||||
|
||||
from langbot.pkg.workflow.executor import _safe_eval
|
||||
|
||||
|
||||
class TestSafeEvalLiterals:
|
||||
"""Test literal value evaluation."""
|
||||
|
||||
def test_integer(self):
|
||||
assert _safe_eval("42") == 42
|
||||
|
||||
def test_negative_integer(self):
|
||||
assert _safe_eval("-5") == -5
|
||||
|
||||
def test_float(self):
|
||||
assert _safe_eval("3.14") == pytest.approx(3.14)
|
||||
|
||||
def test_string(self):
|
||||
assert _safe_eval('"hello"') == "hello"
|
||||
|
||||
def test_single_quoted_string(self):
|
||||
assert _safe_eval("'world'") == "world"
|
||||
|
||||
def test_true(self):
|
||||
assert _safe_eval("True") is True
|
||||
|
||||
def test_false(self):
|
||||
assert _safe_eval("False") is False
|
||||
|
||||
def test_none(self):
|
||||
assert _safe_eval("None") is None
|
||||
|
||||
|
||||
class TestSafeEvalComparisons:
|
||||
"""Test comparison operators."""
|
||||
|
||||
def test_eq_true(self):
|
||||
assert _safe_eval("1 == 1") is True
|
||||
|
||||
def test_eq_false(self):
|
||||
assert _safe_eval("1 == 2") is False
|
||||
|
||||
def test_neq(self):
|
||||
assert _safe_eval("1 != 2") is True
|
||||
|
||||
def test_gt(self):
|
||||
assert _safe_eval("3 > 2") is True
|
||||
|
||||
def test_gte(self):
|
||||
assert _safe_eval("3 >= 3") is True
|
||||
|
||||
def test_lt(self):
|
||||
assert _safe_eval("1 < 2") is True
|
||||
|
||||
def test_lte(self):
|
||||
assert _safe_eval("2 <= 2") is True
|
||||
|
||||
def test_string_eq(self):
|
||||
assert _safe_eval('"hello" == "hello"') is True
|
||||
|
||||
def test_string_neq(self):
|
||||
assert _safe_eval('"a" != "b"') is True
|
||||
|
||||
def test_chained_comparison(self):
|
||||
assert _safe_eval("1 < 2 < 3") is True
|
||||
|
||||
def test_chained_comparison_false(self):
|
||||
assert _safe_eval("1 < 2 > 3") is False
|
||||
|
||||
def test_is_none(self):
|
||||
assert _safe_eval("None is None") is True
|
||||
|
||||
def test_is_not_none(self):
|
||||
assert _safe_eval("1 is not None") is True
|
||||
|
||||
|
||||
class TestSafeEvalIn:
|
||||
"""Test 'in' / 'not in' operators."""
|
||||
|
||||
def test_in_list(self):
|
||||
assert _safe_eval('"abc" in ["abc", "def"]') is True
|
||||
|
||||
def test_not_in_list(self):
|
||||
assert _safe_eval('"x" not in ["a", "b"]') is True
|
||||
|
||||
def test_int_in_list(self):
|
||||
assert _safe_eval("2 in [1, 2, 3]") is True
|
||||
|
||||
def test_in_string(self):
|
||||
assert _safe_eval('"lo" in "hello"') is True
|
||||
|
||||
|
||||
class TestSafeEvalBooleanLogic:
|
||||
"""Test and / or / not operators."""
|
||||
|
||||
def test_and_true(self):
|
||||
assert _safe_eval("True and True") is True
|
||||
|
||||
def test_and_false(self):
|
||||
assert _safe_eval("True and False") is False
|
||||
|
||||
def test_or_true(self):
|
||||
assert _safe_eval("False or True") is True
|
||||
|
||||
def test_or_false(self):
|
||||
assert _safe_eval("False or False") is False
|
||||
|
||||
def test_not_true(self):
|
||||
assert _safe_eval("not False") is True
|
||||
|
||||
def test_not_false(self):
|
||||
assert _safe_eval("not True") is False
|
||||
|
||||
def test_complex_boolean(self):
|
||||
assert _safe_eval("(1 == 1) and (2 > 1) or False") is True
|
||||
|
||||
|
||||
class TestSafeEvalArithmetic:
|
||||
"""Test arithmetic operators."""
|
||||
|
||||
def test_add(self):
|
||||
assert _safe_eval("1 + 2") == 3
|
||||
|
||||
def test_sub(self):
|
||||
assert _safe_eval("5 - 3") == 2
|
||||
|
||||
def test_mul(self):
|
||||
assert _safe_eval("3 * 4") == 12
|
||||
|
||||
def test_div(self):
|
||||
assert _safe_eval("10 / 3") == pytest.approx(3.333, abs=0.01)
|
||||
|
||||
def test_floor_div(self):
|
||||
assert _safe_eval("10 // 3") == 3
|
||||
|
||||
def test_mod(self):
|
||||
assert _safe_eval("10 % 3") == 1
|
||||
|
||||
def test_combined_arithmetic_comparison(self):
|
||||
assert _safe_eval("1 + 2 == 3") is True
|
||||
|
||||
|
||||
class TestSafeEvalSecurity:
|
||||
"""Ensure dangerous constructs are rejected."""
|
||||
|
||||
def test_import_blocked(self):
|
||||
with pytest.raises((ValueError, SyntaxError)):
|
||||
_safe_eval('__import__("os")')
|
||||
|
||||
def test_function_call_blocked(self):
|
||||
with pytest.raises(ValueError):
|
||||
_safe_eval('print("hello")')
|
||||
|
||||
def test_open_blocked(self):
|
||||
with pytest.raises(ValueError):
|
||||
_safe_eval('open("/etc/passwd")')
|
||||
|
||||
def test_attribute_access_blocked(self):
|
||||
with pytest.raises(ValueError):
|
||||
_safe_eval('"hello".__class__')
|
||||
|
||||
def test_subscript_blocked(self):
|
||||
with pytest.raises(ValueError):
|
||||
_safe_eval('[1,2,3][0]')
|
||||
|
||||
def test_class_subclasses_blocked(self):
|
||||
with pytest.raises((ValueError, SyntaxError)):
|
||||
_safe_eval('().__class__.__subclasses__()')
|
||||
|
||||
def test_exec_blocked(self):
|
||||
with pytest.raises(ValueError):
|
||||
_safe_eval('exec("import os")')
|
||||
|
||||
def test_eval_blocked(self):
|
||||
with pytest.raises(ValueError):
|
||||
_safe_eval('eval("1+1")')
|
||||
|
||||
def test_lambda_blocked(self):
|
||||
with pytest.raises((ValueError, SyntaxError)):
|
||||
_safe_eval('lambda: 1')
|
||||
|
||||
def test_variable_reference_blocked(self):
|
||||
with pytest.raises(ValueError):
|
||||
_safe_eval('x + 1')
|
||||
Reference in New Issue
Block a user