first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
import asyncio
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web, web_ws
|
||||
from src.nanoid import nanoid
|
||||
|
||||
from tests.fixtures.test_constants import (
|
||||
TASK_RESPONSE_WAIT,
|
||||
LOCAL_TASK_BROKER_WS_PATH,
|
||||
)
|
||||
|
||||
TaskId = str
|
||||
TaskSettings = dict[str, Any]
|
||||
WebsocketMessage = dict[str, Any]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActiveTask:
|
||||
settings: TaskSettings
|
||||
|
||||
|
||||
class LocalTaskBroker:
|
||||
def __init__(self):
|
||||
self.port: int | None = None
|
||||
self.app = web.Application()
|
||||
self.runner: web.AppRunner | None = None
|
||||
self.site: web.TCPSite | None = None
|
||||
self.connections: dict[str, web_ws.WebSocketResponse] = {}
|
||||
self.pending_messages: dict[str, asyncio.Queue[WebsocketMessage]] = {}
|
||||
self.received_messages: list[WebsocketMessage] = []
|
||||
self.active_tasks: dict[TaskId, ActiveTask] = {}
|
||||
self.task_settings: dict[TaskId, TaskSettings] = {}
|
||||
self.rpc_messages: dict[TaskId, list[dict]] = {}
|
||||
self.app.router.add_get(LOCAL_TASK_BROKER_WS_PATH, self.websocket_handler)
|
||||
|
||||
async def start(self) -> None:
|
||||
self.runner = web.AppRunner(self.app)
|
||||
await self.runner.setup()
|
||||
self.site = web.TCPSite(self.runner, "localhost", 0)
|
||||
await self.site.start()
|
||||
assert self.site._server is not None
|
||||
self.port = self.site._server.sockets[0].getsockname()[1]
|
||||
print(f"Local task broker started on port {self.port}")
|
||||
|
||||
def get_url(self) -> str:
|
||||
return f"http://localhost:{self.port}"
|
||||
|
||||
async def stop(self) -> None:
|
||||
for ws in self.connections.values():
|
||||
await ws.close()
|
||||
self.connections.clear()
|
||||
|
||||
if self.site:
|
||||
await self.site.stop()
|
||||
|
||||
if self.runner:
|
||||
await self.runner.cleanup()
|
||||
|
||||
async def websocket_handler(self, request: web.Request) -> web_ws.WebSocketResponse:
|
||||
print(f"WebSocket connection request from {request.remote}")
|
||||
ws = web_ws.WebSocketResponse()
|
||||
await ws.prepare(request)
|
||||
connection_id = nanoid()
|
||||
self.connections[connection_id] = ws
|
||||
self.pending_messages[connection_id] = asyncio.Queue()
|
||||
|
||||
sender_coroutine = asyncio.create_task(self._message_sender(connection_id, ws))
|
||||
|
||||
try:
|
||||
await self.send_to_connection(connection_id, {"type": "broker:inforequest"})
|
||||
|
||||
async for message in ws:
|
||||
if message.type == web_ws.WSMsgType.TEXT:
|
||||
json_message = json.loads(message.data)
|
||||
self.received_messages.append(json_message)
|
||||
await self._handle_message(connection_id, json_message)
|
||||
finally:
|
||||
sender_coroutine.cancel()
|
||||
try:
|
||||
await sender_coroutine
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
del self.connections[connection_id]
|
||||
del self.pending_messages[connection_id]
|
||||
|
||||
return ws
|
||||
|
||||
async def _message_sender(self, connection_id: str, ws: web_ws.WebSocketResponse):
|
||||
while True:
|
||||
message = await self.pending_messages[connection_id].get()
|
||||
await ws.send_str(json.dumps(message))
|
||||
|
||||
async def _handle_message(self, connection_id: str, message: WebsocketMessage):
|
||||
match message.get("type"):
|
||||
case "runner:info":
|
||||
await self.send_to_connection(
|
||||
connection_id, {"type": "broker:runnerregistered"}
|
||||
)
|
||||
|
||||
case "runner:taskoffer":
|
||||
pass # Handled by send_task() which waits for them
|
||||
|
||||
case "runner:taskaccepted":
|
||||
task_id = message.get("taskId")
|
||||
if task_id in self.task_settings:
|
||||
await self.send_to_connection(
|
||||
connection_id,
|
||||
{
|
||||
"type": "broker:tasksettings",
|
||||
"taskId": task_id,
|
||||
"settings": self.task_settings[task_id],
|
||||
},
|
||||
)
|
||||
|
||||
case "runner:taskdone" | "runner:taskerror":
|
||||
task_id = message.get("taskId")
|
||||
if task_id in self.active_tasks:
|
||||
del self.active_tasks[task_id]
|
||||
|
||||
case "runner:rpc":
|
||||
task_id = message.get("taskId")
|
||||
if task_id:
|
||||
if task_id not in self.rpc_messages:
|
||||
self.rpc_messages[task_id] = []
|
||||
self.rpc_messages[task_id].append(
|
||||
{"method": message.get("name"), "params": message.get("params")}
|
||||
)
|
||||
|
||||
async def send_to_connection(self, connection_id: str, message: WebsocketMessage):
|
||||
if connection_id in self.pending_messages:
|
||||
await self.pending_messages[connection_id].put(message)
|
||||
|
||||
async def send_task(
|
||||
self,
|
||||
task_id: TaskId,
|
||||
task_settings: TaskSettings,
|
||||
):
|
||||
self.active_tasks[task_id] = ActiveTask(task_settings)
|
||||
self.task_settings[task_id] = task_settings
|
||||
|
||||
offer = await self.wait_for_msg("runner:taskoffer", timeout=2.0)
|
||||
|
||||
if offer:
|
||||
accept = {
|
||||
"type": "broker:taskofferaccept",
|
||||
"taskId": task_id,
|
||||
"offerId": offer.get("offerId"),
|
||||
}
|
||||
|
||||
if self.connections:
|
||||
connection = next(iter(self.connections.keys()))
|
||||
await self.send_to_connection(connection, accept)
|
||||
|
||||
async def cancel_task(self, task_id: TaskId, reason: str):
|
||||
cancel_message = {
|
||||
"type": "broker:taskcancel",
|
||||
"taskId": task_id,
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
for connection_id in self.connections:
|
||||
await self.send_to_connection(connection_id, cancel_message)
|
||||
|
||||
async def wait_for_msg(
|
||||
self,
|
||||
msg_type: str,
|
||||
timeout: float = TASK_RESPONSE_WAIT,
|
||||
predicate: Callable[[WebsocketMessage], bool] | None = None,
|
||||
) -> WebsocketMessage | None:
|
||||
start_time = asyncio.get_running_loop().time()
|
||||
|
||||
while asyncio.get_running_loop().time() - start_time < timeout:
|
||||
for msg in self.received_messages:
|
||||
if msg.get("type") == msg_type:
|
||||
if predicate is None or predicate(msg):
|
||||
return msg
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
return None
|
||||
|
||||
def get_messages_of_type(self, msg_type: str) -> list[WebsocketMessage]:
|
||||
return [msg for msg in self.received_messages if msg.get("type") == msg_type]
|
||||
|
||||
def get_task_rpc_messages(self, task_id: TaskId) -> list[dict]:
|
||||
return self.rpc_messages.get(task_id, [])
|
||||
@@ -0,0 +1,127 @@
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from src.constants import (
|
||||
ENV_GRACEFUL_SHUTDOWN_TIMEOUT,
|
||||
ENV_GRANT_TOKEN,
|
||||
ENV_HEALTH_CHECK_SERVER_ENABLED,
|
||||
ENV_HEALTH_CHECK_SERVER_PORT,
|
||||
ENV_LAUNCHER_LOG_LEVEL,
|
||||
ENV_TASK_BROKER_URI,
|
||||
ENV_TASK_TIMEOUT,
|
||||
)
|
||||
|
||||
from tests.fixtures.test_constants import (
|
||||
GRACEFUL_SHUTDOWN_TIMEOUT,
|
||||
TASK_TIMEOUT,
|
||||
)
|
||||
|
||||
|
||||
class TaskRunnerManager:
|
||||
"""Responsible for managing the lifecycle of a task runner subprocess."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
task_broker_url: str | None = None,
|
||||
graceful_shutdown_timeout: float | None = None,
|
||||
custom_env: dict[str, str] | None = None,
|
||||
):
|
||||
self.task_broker_url = task_broker_url
|
||||
self.graceful_shutdown_timeout = graceful_shutdown_timeout
|
||||
self.custom_env = custom_env or {}
|
||||
self.subprocess: asyncio.subprocess.Process | None = None
|
||||
self.stdout_buffer: list[str] = []
|
||||
self.stderr_buffer: list[str] = []
|
||||
self.health_check_port: int | None = None
|
||||
|
||||
async def start(self):
|
||||
project_root = Path(__file__).parent.parent.parent
|
||||
runner_path = project_root / "src" / "main.py"
|
||||
|
||||
env_vars = os.environ.copy()
|
||||
env_vars[ENV_GRANT_TOKEN] = "test_token"
|
||||
env_vars[ENV_TASK_BROKER_URI] = self.task_broker_url
|
||||
env_vars[ENV_TASK_TIMEOUT] = str(TASK_TIMEOUT)
|
||||
env_vars[ENV_HEALTH_CHECK_SERVER_ENABLED] = "true"
|
||||
env_vars[ENV_HEALTH_CHECK_SERVER_PORT] = "0"
|
||||
env_vars[ENV_LAUNCHER_LOG_LEVEL] = "INFO"
|
||||
if self.graceful_shutdown_timeout is not None:
|
||||
env_vars[ENV_GRACEFUL_SHUTDOWN_TIMEOUT] = str(
|
||||
self.graceful_shutdown_timeout
|
||||
)
|
||||
env_vars["PYTHONPATH"] = str(project_root)
|
||||
env_vars.update(self.custom_env)
|
||||
|
||||
self.subprocess = await asyncio.create_subprocess_exec(
|
||||
sys.executable,
|
||||
str(runner_path),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=env_vars,
|
||||
cwd=str(project_root),
|
||||
)
|
||||
|
||||
asyncio.create_task(self._read_stdout())
|
||||
asyncio.create_task(self._read_stderr())
|
||||
|
||||
await self._wait_for_health_check_port()
|
||||
|
||||
def is_running(self) -> bool:
|
||||
return self.subprocess is not None and self.subprocess.returncode is None
|
||||
|
||||
def get_health_check_url(self) -> str:
|
||||
return f"http://localhost:{self.health_check_port}"
|
||||
|
||||
async def stop(self) -> None:
|
||||
if not self.subprocess or self.subprocess.returncode is not None:
|
||||
return
|
||||
|
||||
self.subprocess.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
self.subprocess.wait(), timeout=GRACEFUL_SHUTDOWN_TIMEOUT
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
self.subprocess.kill()
|
||||
await self.subprocess.wait()
|
||||
|
||||
async def _read_stdout(self):
|
||||
if not self.subprocess or not self.subprocess.stdout:
|
||||
return
|
||||
|
||||
while True:
|
||||
line = await self.subprocess.stdout.readline()
|
||||
if not line:
|
||||
break
|
||||
self.stdout_buffer.append(line.decode().strip())
|
||||
|
||||
async def _read_stderr(self):
|
||||
if not self.subprocess or not self.subprocess.stderr:
|
||||
return
|
||||
|
||||
while True:
|
||||
line = await self.subprocess.stderr.readline()
|
||||
if not line:
|
||||
break
|
||||
self.stderr_buffer.append(line.decode().strip())
|
||||
|
||||
async def _wait_for_health_check_port(self, timeout: float = 5.0):
|
||||
pattern = re.compile(r"Health check server listening on .+, port (\d+)")
|
||||
start_time = asyncio.get_running_loop().time()
|
||||
|
||||
while asyncio.get_running_loop().time() - start_time < timeout:
|
||||
for line in self.stdout_buffer:
|
||||
match = pattern.search(line)
|
||||
if match:
|
||||
self.health_check_port = int(match.group(1))
|
||||
return
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
raise TimeoutError(
|
||||
f"Failed to detect health check port within {timeout}s. "
|
||||
f"Stdout: {self.stdout_buffer}"
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
# Local task broker
|
||||
LOCAL_TASK_BROKER_WS_PATH = "/runners/_ws"
|
||||
|
||||
# Timing
|
||||
TASK_RESPONSE_WAIT = 3
|
||||
TASK_TIMEOUT = 2
|
||||
GRACEFUL_SHUTDOWN_TIMEOUT = 1
|
||||
@@ -0,0 +1,108 @@
|
||||
import pytest_asyncio
|
||||
from src.message_types.broker import Items
|
||||
from src.message_serde import NODE_MODE_MAP
|
||||
|
||||
from tests.fixtures.local_task_broker import LocalTaskBroker
|
||||
from tests.fixtures.task_runner_manager import TaskRunnerManager
|
||||
from tests.fixtures.test_constants import (
|
||||
TASK_RESPONSE_WAIT,
|
||||
)
|
||||
|
||||
NODE_MODE_TO_BROKER_STYLE = {v: k for k, v in NODE_MODE_MAP.items()}
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def broker():
|
||||
broker = LocalTaskBroker()
|
||||
await broker.start()
|
||||
yield broker
|
||||
await broker.stop()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def manager(broker):
|
||||
manager = TaskRunnerManager(task_broker_url=broker.get_url())
|
||||
await manager.start()
|
||||
yield manager
|
||||
await manager.stop()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def manager_with_stdlib_wildcard(broker):
|
||||
manager = TaskRunnerManager(
|
||||
task_broker_url=broker.get_url(),
|
||||
custom_env={
|
||||
"N8N_RUNNERS_STDLIB_ALLOW": "*",
|
||||
},
|
||||
)
|
||||
await manager.start()
|
||||
yield manager
|
||||
await manager.stop()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def manager_with_env_access_blocked(broker):
|
||||
manager = TaskRunnerManager(
|
||||
task_broker_url=broker.get_url(),
|
||||
custom_env={
|
||||
"N8N_RUNNERS_STDLIB_ALLOW": "os",
|
||||
"N8N_BLOCK_RUNNER_ENV_ACCESS": "true",
|
||||
},
|
||||
)
|
||||
await manager.start()
|
||||
yield manager
|
||||
await manager.stop()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def manager_with_env_access_allowed(broker):
|
||||
manager = TaskRunnerManager(
|
||||
task_broker_url=broker.get_url(),
|
||||
custom_env={
|
||||
"N8N_RUNNERS_STDLIB_ALLOW": "os",
|
||||
"N8N_BLOCK_RUNNER_ENV_ACCESS": "false",
|
||||
},
|
||||
)
|
||||
await manager.start()
|
||||
yield manager
|
||||
await manager.stop()
|
||||
|
||||
|
||||
def create_task_settings(
|
||||
code: str,
|
||||
node_mode: str,
|
||||
items: Items | None = None,
|
||||
continue_on_fail: bool = False,
|
||||
):
|
||||
return {
|
||||
"code": code,
|
||||
"nodeMode": NODE_MODE_TO_BROKER_STYLE[node_mode],
|
||||
"items": items if items is not None else [],
|
||||
"continueOnFail": continue_on_fail,
|
||||
}
|
||||
|
||||
|
||||
async def wait_for_task_done(broker, task_id: str, timeout: float = TASK_RESPONSE_WAIT):
|
||||
return await broker.wait_for_msg(
|
||||
"runner:taskdone",
|
||||
timeout=timeout,
|
||||
predicate=lambda msg: msg.get("taskId") == task_id,
|
||||
)
|
||||
|
||||
|
||||
async def wait_for_task_error(
|
||||
broker, task_id: str, timeout: float = TASK_RESPONSE_WAIT
|
||||
):
|
||||
return await broker.wait_for_msg(
|
||||
"runner:taskerror",
|
||||
timeout=timeout,
|
||||
predicate=lambda msg: msg.get("taskId") == task_id,
|
||||
)
|
||||
|
||||
|
||||
def get_browser_console_msgs(broker: LocalTaskBroker, task_id: str) -> list[list[str]]:
|
||||
console_msgs = []
|
||||
for msg in broker.get_task_rpc_messages(task_id):
|
||||
if msg.get("method") == "logNodeOutput":
|
||||
console_msgs.append(msg.get("params", []))
|
||||
return console_msgs
|
||||
@@ -0,0 +1,458 @@
|
||||
import asyncio
|
||||
import textwrap
|
||||
|
||||
import pytest
|
||||
from src.nanoid import nanoid
|
||||
|
||||
from tests.integration.conftest import (
|
||||
create_task_settings,
|
||||
wait_for_task_done,
|
||||
wait_for_task_error,
|
||||
)
|
||||
from tests.fixtures.test_constants import TASK_TIMEOUT
|
||||
|
||||
|
||||
# ========== all_items mode ==========
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_items_with_success(broker, manager):
|
||||
task_id = nanoid()
|
||||
items = [
|
||||
{"json": {"name": "Alice", "age": 30}},
|
||||
{"json": {"name": "Bob", "age": 16}},
|
||||
{"json": {"name": "Charlie", "age": 35}},
|
||||
]
|
||||
code = textwrap.dedent("""
|
||||
result = []
|
||||
for item in _items:
|
||||
person = item['json']
|
||||
result.append({
|
||||
'name': person['name'],
|
||||
'age': person['age'],
|
||||
'adult': person['age'] >= 18
|
||||
})
|
||||
return result
|
||||
""")
|
||||
task_settings = create_task_settings(code=code, node_mode="all_items", items=items)
|
||||
await broker.send_task(task_id=task_id, task_settings=task_settings)
|
||||
|
||||
result = await wait_for_task_done(broker, task_id)
|
||||
|
||||
assert result["data"]["result"] == [
|
||||
{"name": "Alice", "age": 30, "adult": True},
|
||||
{"name": "Bob", "age": 16, "adult": False},
|
||||
{"name": "Charlie", "age": 35, "adult": True},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_items_with_error(broker, manager):
|
||||
task_id = nanoid()
|
||||
code = "raise ValueError('Intentional error')"
|
||||
task_settings = create_task_settings(code=code, node_mode="all_items")
|
||||
await broker.send_task(task_id=task_id, task_settings=task_settings)
|
||||
|
||||
error_msg = await wait_for_task_error(broker, task_id)
|
||||
|
||||
assert error_msg["taskId"] == task_id
|
||||
assert "Intentional error" in str(error_msg["error"]["message"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_items_with_continue_on_fail(broker, manager):
|
||||
task_id = nanoid()
|
||||
code = "raise ValueError('Intentional error')"
|
||||
task_settings = create_task_settings(
|
||||
code=code, node_mode="all_items", continue_on_fail=True
|
||||
)
|
||||
await broker.send_task(task_id=task_id, task_settings=task_settings)
|
||||
|
||||
done_msg = await wait_for_task_done(broker, task_id)
|
||||
|
||||
assert len(done_msg["data"]["result"]) == 1
|
||||
assert "error" in done_msg["data"]["result"][0]["json"]
|
||||
assert "Intentional error" in str(done_msg["data"]["result"][0]["json"]["error"])
|
||||
|
||||
|
||||
# ========== per_item mode ==========
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_item_with_success(broker, manager):
|
||||
task_id = nanoid()
|
||||
items = [
|
||||
{"json": {"value": 10}},
|
||||
{"json": {"value": 20}},
|
||||
{"json": {"value": 30}},
|
||||
]
|
||||
code = "return {'doubled': _item['json']['value'] * 2}"
|
||||
task_settings = create_task_settings(code=code, node_mode="per_item", items=items)
|
||||
await broker.send_task(task_id=task_id, task_settings=task_settings)
|
||||
|
||||
done_msg = await wait_for_task_done(broker, task_id)
|
||||
|
||||
assert done_msg["taskId"] == task_id
|
||||
assert done_msg["data"]["result"] == [
|
||||
{"json": {"doubled": 20}, "pairedItem": {"item": 0}},
|
||||
{"json": {"doubled": 40}, "pairedItem": {"item": 1}},
|
||||
{"json": {"doubled": 60}, "pairedItem": {"item": 2}},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_item_with_explicit_json_and_binary(broker, manager):
|
||||
task_id = nanoid()
|
||||
items = [{"json": {"value": 10}}]
|
||||
code = "return {'json': {'custom': 'data'}, 'binary': {'file': 'data'}}"
|
||||
task_settings = create_task_settings(code=code, node_mode="per_item", items=items)
|
||||
await broker.send_task(task_id=task_id, task_settings=task_settings)
|
||||
|
||||
result = await wait_for_task_done(broker, task_id)
|
||||
|
||||
assert result["data"]["result"] == [
|
||||
{
|
||||
"json": {"custom": "data"},
|
||||
"binary": {"file": "data"},
|
||||
"pairedItem": {"item": 0},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_item_with_binary_only(broker, manager):
|
||||
task_id = nanoid()
|
||||
items = [{"json": {"value": 10}}]
|
||||
code = "return {'binary': {'file': 'data'}}"
|
||||
task_settings = create_task_settings(code=code, node_mode="per_item", items=items)
|
||||
await broker.send_task(task_id=task_id, task_settings=task_settings)
|
||||
|
||||
result = await wait_for_task_done(broker, task_id)
|
||||
|
||||
assert result["data"]["result"] == [
|
||||
{"json": {}, "binary": {"file": "data"}, "pairedItem": {"item": 0}}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_item_with_filtering(broker, manager):
|
||||
task_id = nanoid()
|
||||
items = [
|
||||
{"json": {"value": 5}},
|
||||
{"json": {"value": 15}},
|
||||
{"json": {"value": 25}},
|
||||
{"json": {"value": 8}},
|
||||
]
|
||||
code = textwrap.dedent("""
|
||||
value = _item['json']['value']
|
||||
if value > 10:
|
||||
return {'value': value, 'passed': True}
|
||||
else:
|
||||
return None # Filter out this item
|
||||
""")
|
||||
task_settings = create_task_settings(code=code, node_mode="per_item", items=items)
|
||||
await broker.send_task(task_id=task_id, task_settings=task_settings)
|
||||
|
||||
result = await wait_for_task_done(broker, task_id)
|
||||
|
||||
assert result["data"]["result"] == [
|
||||
{"json": {"value": 15, "passed": True}, "pairedItem": {"item": 1}},
|
||||
{"json": {"value": 25, "passed": True}, "pairedItem": {"item": 2}},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_item_with_continue_on_fail(broker, manager):
|
||||
task_id = nanoid()
|
||||
items = [
|
||||
{"json": {"value": 10}},
|
||||
{"json": {"value": 0}}, # Will cause division by zero
|
||||
{"json": {"value": 20}},
|
||||
]
|
||||
code = "return {'result': 100 / _item['json']['value']}"
|
||||
task_settings = create_task_settings(
|
||||
code=code,
|
||||
node_mode="per_item",
|
||||
items=items,
|
||||
continue_on_fail=True,
|
||||
)
|
||||
await broker.send_task(task_id=task_id, task_settings=task_settings)
|
||||
|
||||
done_msg = await wait_for_task_done(broker, task_id)
|
||||
|
||||
assert len(done_msg["data"]["result"]) == 1
|
||||
assert "error" in done_msg["data"]["result"][0]["json"]
|
||||
assert "division by zero" in done_msg["data"]["result"][0]["json"]["error"]
|
||||
|
||||
|
||||
# ========== Security ===========
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cannot_access_builtins_via_globals(broker, manager):
|
||||
task_id = nanoid()
|
||||
code = textwrap.dedent("""
|
||||
b = globals()['__builtins__']
|
||||
imp = b['__import__']
|
||||
os = imp('os')
|
||||
return [{"json": {"pid": os.getpid()}}]
|
||||
""")
|
||||
task_settings = create_task_settings(code=code, node_mode="all_items")
|
||||
await broker.send_task(task_id=task_id, task_settings=task_settings)
|
||||
|
||||
error_msg = await wait_for_task_error(broker, task_id)
|
||||
|
||||
assert error_msg["taskId"] == task_id
|
||||
assert "globals" in str(error_msg["error"]["message"]).lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cannot_access_builtins_via_locals(broker, manager):
|
||||
task_id = nanoid()
|
||||
code = textwrap.dedent("""
|
||||
b = locals()['__builtins__']
|
||||
imp = b['__import__']
|
||||
os = imp('os')
|
||||
return [{"json": {"pid": os.getpid()}}]
|
||||
""")
|
||||
task_settings = create_task_settings(code=code, node_mode="all_items")
|
||||
await broker.send_task(task_id=task_id, task_settings=task_settings)
|
||||
|
||||
error_msg = await wait_for_task_error(broker, task_id)
|
||||
|
||||
assert error_msg["taskId"] == task_id
|
||||
assert "locals" in str(error_msg["error"]["message"]).lower()
|
||||
|
||||
|
||||
# ========== edge cases ===========
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_during_execution(broker, manager):
|
||||
task_id = nanoid()
|
||||
code = textwrap.dedent("""
|
||||
import time
|
||||
for i in range(20):
|
||||
time.sleep(0.05)
|
||||
if i == 10:
|
||||
# Should be cancelled around here
|
||||
pass
|
||||
return [{"completed": "should not reach here"}]
|
||||
""")
|
||||
task_settings = create_task_settings(code=code, node_mode="all_items")
|
||||
await broker.send_task(task_id=task_id, task_settings=task_settings)
|
||||
await asyncio.sleep(0.3)
|
||||
await broker.cancel_task(task_id, reason="Cancelled during execution")
|
||||
|
||||
error_msg = await wait_for_task_error(broker, task_id)
|
||||
|
||||
assert error_msg["taskId"] == task_id
|
||||
assert "error" in error_msg
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_during_execution(broker, manager):
|
||||
task_id = nanoid()
|
||||
code = textwrap.dedent("""
|
||||
while True:
|
||||
pass
|
||||
""")
|
||||
task_settings = create_task_settings(code=code, node_mode="all_items")
|
||||
await broker.send_task(task_id=task_id, task_settings=task_settings)
|
||||
|
||||
error_msg = await wait_for_task_error(broker, task_id, timeout=TASK_TIMEOUT + 1.5)
|
||||
|
||||
assert error_msg["taskId"] == task_id
|
||||
assert "timed out" in error_msg["error"]["message"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stdlib_submodules_with_wildcard(broker, manager_with_stdlib_wildcard):
|
||||
task_id = nanoid()
|
||||
code = textwrap.dedent("""
|
||||
from collections.abc import Iterable
|
||||
result = isinstance([1, 2, 3], Iterable)
|
||||
return [{"json": {"is_iterable": result}}]
|
||||
""")
|
||||
task_settings = create_task_settings(code=code, node_mode="all_items")
|
||||
await broker.send_task(task_id=task_id, task_settings=task_settings)
|
||||
|
||||
result = await wait_for_task_done(broker, task_id)
|
||||
|
||||
assert result["data"]["result"] == [{"json": {"is_iterable": True}}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cannot_bypass_import_restrictions_via_builtins_dict(broker, manager):
|
||||
task_id = nanoid()
|
||||
code = textwrap.dedent("""
|
||||
os = __builtins__['__import__']('os')
|
||||
print(os.getpid())
|
||||
return []
|
||||
""")
|
||||
task_settings = create_task_settings(code=code, node_mode="all_items")
|
||||
await broker.send_task(task_id=task_id, task_settings=task_settings)
|
||||
|
||||
error_msg = await wait_for_task_error(broker, task_id)
|
||||
|
||||
assert error_msg["taskId"] == task_id
|
||||
assert "error" in error_msg
|
||||
assert "__import__" in str(error_msg["error"]["description"]).lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cannot_bypass_import_restrictions_via_builtins_spec_loader(
|
||||
broker, manager
|
||||
):
|
||||
task_id = nanoid()
|
||||
code = textwrap.dedent("""
|
||||
sys = __builtins__['__spec__'].loader.load_module('sys')
|
||||
os = sys.meta_path[-1].find_spec("os").loader.load_module('os')
|
||||
return [{"json": {"pid": os.getpid()}}]
|
||||
""")
|
||||
task_settings = create_task_settings(code=code, node_mode="all_items")
|
||||
await broker.send_task(task_id=task_id, task_settings=task_settings)
|
||||
|
||||
error_msg = await wait_for_task_error(broker, task_id)
|
||||
|
||||
assert error_msg["taskId"] == task_id
|
||||
assert "error" in error_msg
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cannot_bypass_import_restrictions_via_sys_builtins_spec_leader(
|
||||
broker, manager_with_stdlib_wildcard
|
||||
):
|
||||
task_id = nanoid()
|
||||
code = textwrap.dedent("""
|
||||
import sys
|
||||
os = sys.__builtins__['__spec__'].loader.load_module('os')
|
||||
return [{"json": {"pid": os.getpid()}}]
|
||||
""")
|
||||
task_settings = create_task_settings(code=code, node_mode="all_items")
|
||||
await broker.send_task(task_id=task_id, task_settings=task_settings)
|
||||
error_msg = await wait_for_task_error(broker, task_id)
|
||||
|
||||
assert error_msg["taskId"] == task_id
|
||||
assert "error" in error_msg
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cannot_bypass_import_restrictions_via_format_string(broker, manager):
|
||||
task_id = nanoid()
|
||||
code = textwrap.dedent("""
|
||||
ex = None
|
||||
try:
|
||||
"{.__builtins__[__import__].__call__.a}".format(print)
|
||||
except Exception as e:
|
||||
ex = e
|
||||
return [{"json": {"error": str(ex)}}]
|
||||
""")
|
||||
task_settings = create_task_settings(code=code, node_mode="all_items")
|
||||
await broker.send_task(task_id=task_id, task_settings=task_settings)
|
||||
|
||||
error_msg = await wait_for_task_error(broker, task_id)
|
||||
|
||||
assert error_msg["taskId"] == task_id
|
||||
assert "error" in error_msg
|
||||
assert "__builtins__" in str(error_msg["error"]["description"]).lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_env_blocked_by_default_all_items(
|
||||
broker, manager_with_env_access_blocked
|
||||
):
|
||||
task_id = nanoid()
|
||||
code = textwrap.dedent("""
|
||||
import os
|
||||
path = os.environ.get('PATH', 'NOT_FOUND')
|
||||
home = os.environ.get('HOME', 'NOT_FOUND')
|
||||
env_dict = dict(os.environ)
|
||||
return [{"json": {
|
||||
"path": path,
|
||||
"home": home,
|
||||
"env_count": len(env_dict)
|
||||
}}]
|
||||
""")
|
||||
task_settings = create_task_settings(code=code, node_mode="all_items")
|
||||
await broker.send_task(task_id=task_id, task_settings=task_settings)
|
||||
|
||||
result = await wait_for_task_done(broker, task_id)
|
||||
|
||||
assert result["data"]["result"][0]["json"]["path"] == "NOT_FOUND"
|
||||
assert result["data"]["result"][0]["json"]["home"] == "NOT_FOUND"
|
||||
assert result["data"]["result"][0]["json"]["env_count"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_env_blocked_by_default_per_item(broker, manager_with_env_access_blocked):
|
||||
task_id = nanoid()
|
||||
items = [
|
||||
{"json": {"index": 0}},
|
||||
{"json": {"index": 1}},
|
||||
]
|
||||
code = textwrap.dedent("""
|
||||
import os
|
||||
path = os.environ.get('PATH', 'NOT_FOUND')
|
||||
return {"path": path, "env_count": len(dict(os.environ))}
|
||||
""")
|
||||
task_settings = create_task_settings(code=code, node_mode="per_item", items=items)
|
||||
await broker.send_task(task_id=task_id, task_settings=task_settings)
|
||||
|
||||
result = await wait_for_task_done(broker, task_id)
|
||||
|
||||
assert len(result["data"]["result"]) == 2
|
||||
for item in result["data"]["result"]:
|
||||
assert item["json"]["path"] == "NOT_FOUND"
|
||||
assert item["json"]["env_count"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_env_accessible_when_allowed_all_items(
|
||||
broker, manager_with_env_access_allowed
|
||||
):
|
||||
task_id = nanoid()
|
||||
code = textwrap.dedent("""
|
||||
import os
|
||||
path = os.environ.get('PATH', 'NOT_FOUND')
|
||||
env_dict = dict(os.environ)
|
||||
return [{"json": {
|
||||
"has_path": path != 'NOT_FOUND',
|
||||
"env_count": len(env_dict)
|
||||
}}]
|
||||
""")
|
||||
task_settings = create_task_settings(code=code, node_mode="all_items")
|
||||
await broker.send_task(task_id=task_id, task_settings=task_settings)
|
||||
|
||||
result = await wait_for_task_done(broker, task_id)
|
||||
|
||||
assert result["data"]["result"][0]["json"]["has_path"] is True
|
||||
assert result["data"]["result"][0]["json"]["env_count"] > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_env_accessible_when_allowed_per_item(
|
||||
broker, manager_with_env_access_allowed
|
||||
):
|
||||
task_id = nanoid()
|
||||
items = [
|
||||
{"json": {"index": 0}},
|
||||
{"json": {"index": 1}},
|
||||
]
|
||||
code = textwrap.dedent("""
|
||||
import os
|
||||
path = os.environ.get('PATH', 'NOT_FOUND')
|
||||
return {
|
||||
"has_path": path != 'NOT_FOUND',
|
||||
"env_count": len(dict(os.environ))
|
||||
}
|
||||
""")
|
||||
task_settings = create_task_settings(code=code, node_mode="per_item", items=items)
|
||||
await broker.send_task(task_id=task_id, task_settings=task_settings)
|
||||
|
||||
result = await wait_for_task_done(broker, task_id)
|
||||
|
||||
assert len(result["data"]["result"]) == 2
|
||||
for item in result["data"]["result"]:
|
||||
assert item["json"]["has_path"] is True
|
||||
assert item["json"]["env_count"] > 0
|
||||
@@ -0,0 +1,40 @@
|
||||
import asyncio
|
||||
import textwrap
|
||||
|
||||
import aiohttp
|
||||
import pytest
|
||||
from src.nanoid import nanoid
|
||||
|
||||
from tests.integration.conftest import create_task_settings
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_check_server_responds(broker, manager):
|
||||
health_check_url = manager.get_health_check_url()
|
||||
async with aiohttp.ClientSession() as session:
|
||||
for _ in range(10):
|
||||
try:
|
||||
response = await session.get(health_check_url)
|
||||
if response.status == 200:
|
||||
assert await response.text() == "OK"
|
||||
return
|
||||
except aiohttp.ClientConnectionError:
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_check_server_ressponds_mid_execution(broker, manager):
|
||||
task_id = nanoid()
|
||||
code = textwrap.dedent("""
|
||||
for _ in range(10_000_000):
|
||||
pass
|
||||
return [{"result": "completed"}]
|
||||
""")
|
||||
task_settings = create_task_settings(code=code, node_mode="all_items")
|
||||
await broker.send_task(task_id=task_id, task_settings=task_settings)
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
response = await session.get(manager.get_health_check_url())
|
||||
assert response.status == 200
|
||||
assert await response.text() == "OK"
|
||||
@@ -0,0 +1,116 @@
|
||||
import textwrap
|
||||
|
||||
import pytest
|
||||
from src.nanoid import nanoid
|
||||
|
||||
from tests.integration.conftest import (
|
||||
create_task_settings,
|
||||
get_browser_console_msgs,
|
||||
wait_for_task_done,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_print_basic_types(broker, manager):
|
||||
task_id = nanoid()
|
||||
code = textwrap.dedent("""
|
||||
print("Hello, World!")
|
||||
print(42)
|
||||
print(3.14)
|
||||
print(True)
|
||||
print(None)
|
||||
print("Multiple", "args", 123, False)
|
||||
return [{"printed": "ok"}]
|
||||
""")
|
||||
task_settings = create_task_settings(code=code, node_mode="all_items")
|
||||
await broker.send_task(task_id=task_id, task_settings=task_settings)
|
||||
|
||||
done_msg = await wait_for_task_done(broker, task_id, timeout=5.0)
|
||||
|
||||
assert done_msg["taskId"] == task_id
|
||||
assert done_msg["data"]["result"] == [{"printed": "ok"}]
|
||||
|
||||
msgs = get_browser_console_msgs(broker, task_id)
|
||||
|
||||
assert len(msgs) > 0, "Should have captured console messages"
|
||||
|
||||
all_args = []
|
||||
for msg in msgs:
|
||||
all_args.extend(msg)
|
||||
|
||||
expected = [
|
||||
"'Hello, World!'",
|
||||
"42",
|
||||
"3.14",
|
||||
"True",
|
||||
"None",
|
||||
"'Multiple'",
|
||||
"'args'",
|
||||
"123",
|
||||
"False",
|
||||
]
|
||||
for item in expected:
|
||||
assert item in all_args, f"Expected '{item}' not found in console output"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_print_complex_types(broker, manager):
|
||||
task_id = nanoid()
|
||||
code = textwrap.dedent("""
|
||||
print({"name": "John", "age": 30, "active": True})
|
||||
print([1, 2, "three", {"four": 4}])
|
||||
print({"users": [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]})
|
||||
return [{"result": "success"}]
|
||||
""")
|
||||
task_settings = create_task_settings(code=code, node_mode="all_items")
|
||||
await broker.send_task(task_id=task_id, task_settings=task_settings)
|
||||
|
||||
result_msg = await wait_for_task_done(broker, task_id, timeout=5.0)
|
||||
|
||||
assert result_msg["data"]["result"] == [{"result": "success"}]
|
||||
|
||||
msgs = get_browser_console_msgs(broker, task_id)
|
||||
assert len(msgs) > 0, "Should have captured console messages"
|
||||
|
||||
all_output = " ".join(["".join(msg) for msg in msgs]).replace(" ", "")
|
||||
expected = [
|
||||
'{"name":"John","age":30,"active":true}',
|
||||
'[1,2,"three",{"four":4}]',
|
||||
]
|
||||
for item in expected:
|
||||
assert item in all_output, f"Expected '{item}' not found in console output"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_print_edge_cases(broker, manager):
|
||||
task_id = nanoid()
|
||||
code = textwrap.dedent("""
|
||||
print("Hello 世界 🌍")
|
||||
print({"emoji": "🚀", "chinese": "你好", "arabic": "مرحبا"})
|
||||
print("Line\\nbreak")
|
||||
print("Tab\\tseparated")
|
||||
print('Quote "test" here')
|
||||
print()
|
||||
print("")
|
||||
print(" ")
|
||||
print([])
|
||||
print({})
|
||||
print(())
|
||||
print("x" * 1_000)
|
||||
return [{"test": "complete"}]
|
||||
""")
|
||||
task_settings = create_task_settings(code=code, node_mode="all_items")
|
||||
|
||||
await broker.send_task(task_id=task_id, task_settings=task_settings)
|
||||
|
||||
done_msg = await wait_for_task_done(broker, task_id, timeout=5.0)
|
||||
|
||||
assert done_msg["data"]["result"] == [{"test": "complete"}]
|
||||
|
||||
msgs = get_browser_console_msgs(broker, task_id)
|
||||
assert len(msgs) > 0, "Should have captured console messages"
|
||||
|
||||
all_output = " ".join(["".join(msg) for msg in msgs])
|
||||
expected = ["世界", "🌍", "🚀", "你好", "[]", "{}"]
|
||||
for item in expected:
|
||||
assert item in all_output, f"Expected '{item}' not found in console output"
|
||||
@@ -0,0 +1,223 @@
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
from src.env import read_env, read_int_env, read_bool_env, read_str_env, read_float_env
|
||||
|
||||
|
||||
class TestReadEnv:
|
||||
def test_returns_direct_env_var_when_exists(self):
|
||||
with patch.dict(os.environ, {"TEST_VAR": "direct_value"}):
|
||||
result = read_env("TEST_VAR")
|
||||
assert result == "direct_value"
|
||||
|
||||
def test_returns_none_when_no_env_var(self):
|
||||
with patch.dict(os.environ, clear=True):
|
||||
result = read_env("NONEXISTENT_VAR")
|
||||
assert result is None
|
||||
|
||||
def test_reads_from_file_when_file_env_var_exists(self):
|
||||
with tempfile.NamedTemporaryFile(mode="w", delete=True) as f:
|
||||
f.write("file_value\n")
|
||||
f.flush()
|
||||
|
||||
with patch.dict(os.environ, {"TEST_VAR_FILE": f.name}):
|
||||
result = read_env("TEST_VAR")
|
||||
assert result == "file_value"
|
||||
|
||||
def test_strips_whitespace_from_file_content(self):
|
||||
with tempfile.NamedTemporaryFile(mode="w", delete=True) as f:
|
||||
f.write(" value_with_spaces \n\n")
|
||||
f.flush()
|
||||
|
||||
with patch.dict(os.environ, {"TEST_VAR_FILE": f.name}):
|
||||
result = read_env("TEST_VAR")
|
||||
assert result == "value_with_spaces"
|
||||
|
||||
def test_direct_env_var_takes_precedence_over_file(self):
|
||||
with tempfile.NamedTemporaryFile(mode="w", delete=True) as f:
|
||||
f.write("file_value")
|
||||
f.flush()
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"TEST_VAR": "direct_value", "TEST_VAR_FILE": f.name},
|
||||
):
|
||||
result = read_env("TEST_VAR")
|
||||
assert result == "direct_value"
|
||||
|
||||
def test_raises_error_when_file_not_found(self):
|
||||
with patch.dict(os.environ, {"TEST_VAR_FILE": "/nonexistent/file.txt"}):
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
read_env("TEST_VAR")
|
||||
assert "Failed to read TEST_VAR_FILE from file" in str(exc_info.value)
|
||||
|
||||
def test_handles_empty_file(self):
|
||||
with tempfile.NamedTemporaryFile(mode="w", delete=True) as f:
|
||||
f.write("")
|
||||
f.flush()
|
||||
|
||||
with patch.dict(os.environ, {"TEST_VAR_FILE": f.name}):
|
||||
result = read_env("TEST_VAR")
|
||||
assert result == ""
|
||||
|
||||
def test_handles_multiline_file_content(self):
|
||||
with tempfile.NamedTemporaryFile(mode="w", delete=True) as f:
|
||||
f.write("line1\nline2\nline3")
|
||||
f.flush()
|
||||
|
||||
with patch.dict(os.environ, {"TEST_VAR_FILE": f.name}):
|
||||
result = read_env("TEST_VAR")
|
||||
assert result == "line1\nline2\nline3"
|
||||
|
||||
def test_handles_unicode_content(self):
|
||||
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", delete=True) as f:
|
||||
f.write("unicode: 你好世界 🌍")
|
||||
f.flush()
|
||||
|
||||
with patch.dict(os.environ, {"TEST_VAR_FILE": f.name}):
|
||||
result = read_env("TEST_VAR")
|
||||
assert result == "unicode: 你好世界 🌍"
|
||||
|
||||
def test_raises_error_with_permission_denied(self):
|
||||
with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
|
||||
f.write("secret_value")
|
||||
temp_file_path = f.name
|
||||
|
||||
try:
|
||||
Path(temp_file_path).chmod(0o000) # Make file unreadable
|
||||
with patch.dict(os.environ, {"TEST_VAR_FILE": temp_file_path}):
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
read_env("TEST_VAR")
|
||||
assert "Failed to read TEST_VAR_FILE from file" in str(exc_info.value)
|
||||
finally:
|
||||
Path(temp_file_path).chmod(0o644)
|
||||
Path(temp_file_path).unlink()
|
||||
|
||||
|
||||
class TestReadStrEnv:
|
||||
def test_returns_string_from_direct_env(self):
|
||||
with patch.dict(os.environ, {"TEST_STR": "hello world"}):
|
||||
result = read_str_env("TEST_STR", default="default")
|
||||
assert result == "hello world"
|
||||
|
||||
def test_returns_string_from_file(self):
|
||||
with tempfile.NamedTemporaryFile(mode="w", delete=True) as f:
|
||||
f.write("file content")
|
||||
f.flush()
|
||||
|
||||
with patch.dict(os.environ, {"TEST_STR_FILE": f.name}):
|
||||
result = read_str_env("TEST_STR", default="default")
|
||||
assert result == "file content"
|
||||
|
||||
def test_returns_default_when_not_set(self):
|
||||
with patch.dict(os.environ, clear=True):
|
||||
result = read_str_env("TEST_STR", default="fallback")
|
||||
assert result == "fallback"
|
||||
|
||||
def test_handles_empty_string_from_env(self):
|
||||
with patch.dict(os.environ, {"TEST_STR": ""}):
|
||||
result = read_str_env("TEST_STR", default="default")
|
||||
assert result == ""
|
||||
|
||||
|
||||
class TestReadIntEnv:
|
||||
def test_returns_int_from_direct_env(self):
|
||||
with patch.dict(os.environ, {"TEST_INT": "42"}):
|
||||
result = read_int_env("TEST_INT", default=0)
|
||||
assert result == 42
|
||||
|
||||
def test_returns_int_from_file(self):
|
||||
with tempfile.NamedTemporaryFile(mode="w", delete=True) as f:
|
||||
f.write("123")
|
||||
f.flush()
|
||||
|
||||
with patch.dict(os.environ, {"TEST_INT_FILE": f.name}):
|
||||
result = read_int_env("TEST_INT", default=0)
|
||||
assert result == 123
|
||||
|
||||
def test_returns_default_when_not_set(self):
|
||||
with patch.dict(os.environ, clear=True):
|
||||
result = read_int_env("TEST_INT", default=999)
|
||||
assert result == 999
|
||||
|
||||
def test_raises_error_for_invalid_int(self):
|
||||
with patch.dict(os.environ, {"TEST_INT": "not_a_number"}):
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
read_int_env("TEST_INT", default=0)
|
||||
assert "must be an integer" in str(exc_info.value)
|
||||
|
||||
def test_handles_negative_numbers(self):
|
||||
with patch.dict(os.environ, {"TEST_INT": "-42"}):
|
||||
result = read_int_env("TEST_INT", default=0)
|
||||
assert result == -42
|
||||
|
||||
|
||||
class TestReadBoolEnv:
|
||||
def test_returns_true_for_true_string(self):
|
||||
with patch.dict(os.environ, {"TEST_BOOL": "true"}):
|
||||
result = read_bool_env("TEST_BOOL", default=False)
|
||||
assert result is True
|
||||
|
||||
def test_returns_false_for_false_string(self):
|
||||
with patch.dict(os.environ, {"TEST_BOOL": "false"}):
|
||||
result = read_bool_env("TEST_BOOL", default=True)
|
||||
assert result is False
|
||||
|
||||
def test_returns_true_from_file(self):
|
||||
with tempfile.NamedTemporaryFile(mode="w", delete=True) as f:
|
||||
f.write("true")
|
||||
f.flush()
|
||||
|
||||
with patch.dict(os.environ, {"TEST_BOOL_FILE": f.name}):
|
||||
result = read_bool_env("TEST_BOOL", default=False)
|
||||
assert result is True
|
||||
|
||||
def test_returns_default_when_not_set(self):
|
||||
with patch.dict(os.environ, clear=True):
|
||||
result = read_bool_env("TEST_BOOL", default=True)
|
||||
assert result is True
|
||||
|
||||
|
||||
class TestReadFloatEnv:
|
||||
def test_returns_float_from_direct_env(self):
|
||||
with patch.dict(os.environ, {"TEST_FLOAT": "3.14"}):
|
||||
result = read_float_env("TEST_FLOAT", default=0.0)
|
||||
assert result == 3.14
|
||||
|
||||
def test_returns_float_from_file(self):
|
||||
with tempfile.NamedTemporaryFile(mode="w", delete=True) as f:
|
||||
f.write("2.718")
|
||||
f.flush()
|
||||
|
||||
with patch.dict(os.environ, {"TEST_FLOAT_FILE": f.name}):
|
||||
result = read_float_env("TEST_FLOAT", default=0.0)
|
||||
assert result == 2.718
|
||||
|
||||
def test_returns_default_when_not_set(self):
|
||||
with patch.dict(os.environ, clear=True):
|
||||
result = read_float_env("TEST_FLOAT", default=9.99)
|
||||
assert result == 9.99
|
||||
|
||||
def test_raises_error_for_invalid_float(self):
|
||||
with patch.dict(os.environ, {"TEST_FLOAT": "not_a_number"}):
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
read_float_env("TEST_FLOAT", default=0.0)
|
||||
assert "must be a float" in str(exc_info.value)
|
||||
|
||||
def test_handles_negative_numbers(self):
|
||||
with patch.dict(os.environ, {"TEST_FLOAT": "-42.5"}):
|
||||
result = read_float_env("TEST_FLOAT", default=0.0)
|
||||
assert result == -42.5
|
||||
|
||||
def test_handles_integer_values(self):
|
||||
with patch.dict(os.environ, {"TEST_FLOAT": "42"}):
|
||||
result = read_float_env("TEST_FLOAT", default=0.0)
|
||||
assert result == 42.0
|
||||
|
||||
def test_handles_zero(self):
|
||||
with patch.dict(os.environ, {"TEST_FLOAT": "0"}):
|
||||
result = read_float_env("TEST_FLOAT", default=1.0)
|
||||
assert result == 0.0
|
||||
@@ -0,0 +1,266 @@
|
||||
import logging
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("sentry_sdk")
|
||||
|
||||
from src.config.sentry_config import SentryConfig
|
||||
from src.sentry import TaskRunnerSentry, setup_sentry
|
||||
from src.constants import (
|
||||
EXECUTOR_ALL_ITEMS_FILENAME,
|
||||
EXECUTOR_PER_ITEM_FILENAME,
|
||||
IGNORED_ERROR_TYPES,
|
||||
LOG_SENTRY_MISSING,
|
||||
SENTRY_TAG_SERVER_TYPE_KEY,
|
||||
SENTRY_TAG_SERVER_TYPE_VALUE,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sentry_config():
|
||||
return SentryConfig(
|
||||
dsn="https://test@sentry.io/123456",
|
||||
n8n_version="1.0.0",
|
||||
environment="test",
|
||||
deployment_name="test-deployment",
|
||||
profiles_sample_rate=0,
|
||||
traces_sample_rate=0,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def disabled_sentry_config():
|
||||
return SentryConfig(
|
||||
dsn="",
|
||||
n8n_version="1.0.0",
|
||||
environment="test",
|
||||
deployment_name="test-deployment",
|
||||
profiles_sample_rate=0,
|
||||
traces_sample_rate=0,
|
||||
)
|
||||
|
||||
|
||||
class TestTaskRunnerSentry:
|
||||
def test_init_configures_sentry_correctly(self, sentry_config):
|
||||
with (
|
||||
patch("sentry_sdk.init") as mock_init,
|
||||
patch("sentry_sdk.set_tag") as mock_set_tag,
|
||||
patch("sentry_sdk.integrations.logging.LoggingIntegration") as mock_logging,
|
||||
):
|
||||
mock_logging_instance = Mock()
|
||||
mock_logging.return_value = mock_logging_instance
|
||||
sentry = TaskRunnerSentry(sentry_config)
|
||||
|
||||
sentry.init()
|
||||
|
||||
mock_init.assert_called_once_with(
|
||||
dsn="https://test@sentry.io/123456",
|
||||
release="n8n@1.0.0",
|
||||
environment="test",
|
||||
server_name="test-deployment",
|
||||
before_send=sentry._filter_out_ignored_errors,
|
||||
attach_stacktrace=True,
|
||||
send_default_pii=False,
|
||||
auto_enabling_integrations=False,
|
||||
default_integrations=True,
|
||||
integrations=[mock_logging_instance],
|
||||
)
|
||||
mock_set_tag.assert_called_once_with(
|
||||
SENTRY_TAG_SERVER_TYPE_KEY, SENTRY_TAG_SERVER_TYPE_VALUE
|
||||
)
|
||||
|
||||
def test_shutdown_flushes_sentry(self, sentry_config):
|
||||
with patch("sentry_sdk.flush") as mock_flush:
|
||||
sentry = TaskRunnerSentry(sentry_config)
|
||||
|
||||
sentry.shutdown()
|
||||
|
||||
mock_flush.assert_called_once_with(timeout=2.0)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error_type",
|
||||
IGNORED_ERROR_TYPES,
|
||||
)
|
||||
def test_filter_out_ignored_errors(self, sentry_config, error_type):
|
||||
sentry = TaskRunnerSentry(sentry_config)
|
||||
event = {"exception": {"values": []}}
|
||||
hint = {"exc_info": (error_type, None, None)}
|
||||
|
||||
result = sentry._filter_out_ignored_errors(event, hint)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_filter_out_syntax_error_subclasses(self, sentry_config):
|
||||
sentry = TaskRunnerSentry(sentry_config)
|
||||
event = {"exception": {"values": []}}
|
||||
hint = {"exc_info": (IndentationError, None, None)}
|
||||
|
||||
result = sentry._filter_out_ignored_errors(event, hint)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_filter_out_errors_by_type_name(self, sentry_config):
|
||||
sentry = TaskRunnerSentry(sentry_config)
|
||||
|
||||
for ignored_type in IGNORED_ERROR_TYPES:
|
||||
event = {
|
||||
"exception": {
|
||||
"values": [
|
||||
{
|
||||
"type": ignored_type.__name__,
|
||||
"stacktrace": {"frames": [{"filename": "some_file.py"}]},
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
hint = {} # No exc_info, so it falls back to type name matching
|
||||
|
||||
result = sentry._filter_out_ignored_errors(event, hint)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_filter_out_user_code_errors_from_executors(self, sentry_config):
|
||||
sentry = TaskRunnerSentry(sentry_config)
|
||||
|
||||
for executor_filename in [
|
||||
EXECUTOR_ALL_ITEMS_FILENAME,
|
||||
EXECUTOR_PER_ITEM_FILENAME,
|
||||
]:
|
||||
event = {
|
||||
"exception": {
|
||||
"values": [
|
||||
{
|
||||
"stacktrace": {
|
||||
"frames": [
|
||||
{"filename": "some_file.py"},
|
||||
{"filename": executor_filename},
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
hint = {}
|
||||
|
||||
result = sentry._filter_out_ignored_errors(event, hint)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_allows_non_user_code_errors(self, sentry_config):
|
||||
sentry = TaskRunnerSentry(sentry_config)
|
||||
event = {
|
||||
"exception": {
|
||||
"values": [
|
||||
{
|
||||
"stacktrace": {
|
||||
"frames": [
|
||||
{"filename": "some_system_file.py"},
|
||||
{"filename": "another_system_file.py"},
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
hint = {}
|
||||
|
||||
result = sentry._filter_out_ignored_errors(event, hint)
|
||||
|
||||
assert result == event
|
||||
|
||||
def test_handles_malformed_exception_data(self, sentry_config):
|
||||
sentry = TaskRunnerSentry(sentry_config)
|
||||
|
||||
test_cases = [
|
||||
{},
|
||||
{"exception": {"values": []}},
|
||||
{"exception": {"values": [{"type": "ValueError"}]}},
|
||||
{"exception": {"values": [{"stacktrace": {}}]}},
|
||||
{"exception": {"values": [{"stacktrace": {"frames": []}}]}},
|
||||
]
|
||||
|
||||
for event in test_cases:
|
||||
result = sentry._filter_out_ignored_errors(event, {})
|
||||
assert result == event
|
||||
|
||||
|
||||
class TestSetupSentry:
|
||||
def test_returns_none_when_disabled(self, disabled_sentry_config):
|
||||
result = setup_sentry(disabled_sentry_config)
|
||||
assert result is None
|
||||
|
||||
@patch("src.sentry.TaskRunnerSentry")
|
||||
def test_initializes_sentry_when_enabled(self, mock_sentry_class, sentry_config):
|
||||
mock_sentry = Mock()
|
||||
mock_sentry_class.return_value = mock_sentry
|
||||
|
||||
result = setup_sentry(sentry_config)
|
||||
|
||||
mock_sentry_class.assert_called_once_with(sentry_config)
|
||||
mock_sentry.init.assert_called_once()
|
||||
assert result == mock_sentry
|
||||
|
||||
@patch("src.sentry.TaskRunnerSentry")
|
||||
def test_handles_import_error(self, mock_sentry_class, sentry_config, caplog):
|
||||
mock_sentry = Mock()
|
||||
mock_sentry.init.side_effect = ImportError("sentry_sdk not found")
|
||||
mock_sentry_class.return_value = mock_sentry
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result = setup_sentry(sentry_config)
|
||||
|
||||
assert result is None
|
||||
assert LOG_SENTRY_MISSING in caplog.text
|
||||
|
||||
@patch("src.sentry.TaskRunnerSentry")
|
||||
def test_handles_general_exception(self, mock_sentry_class, sentry_config, caplog):
|
||||
mock_sentry = Mock()
|
||||
mock_sentry.init.side_effect = Exception("Something went wrong")
|
||||
mock_sentry_class.return_value = mock_sentry
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result = setup_sentry(sentry_config)
|
||||
|
||||
assert result is None
|
||||
assert "Failed to initialize Sentry: Something went wrong" in caplog.text
|
||||
|
||||
|
||||
class TestSentryConfig:
|
||||
def test_enabled_returns_true_with_dsn(self, sentry_config):
|
||||
assert sentry_config.enabled is True
|
||||
|
||||
def test_enabled_returns_false_without_dsn(self, disabled_sentry_config):
|
||||
assert disabled_sentry_config.enabled is False
|
||||
|
||||
@patch.dict(
|
||||
"os.environ",
|
||||
{
|
||||
"N8N_SENTRY_DSN": "https://test@sentry.io/789",
|
||||
"N8N_VERSION": "2.0.0",
|
||||
"ENVIRONMENT": "production",
|
||||
"DEPLOYMENT_NAME": "prod-deployment",
|
||||
"N8N_SENTRY_PROFILES_SAMPLE_RATE": "0.5",
|
||||
"N8N_SENTRY_TRACES_SAMPLE_RATE": "0.1",
|
||||
},
|
||||
)
|
||||
def test_from_env_creates_config_from_environment(self):
|
||||
config = SentryConfig.from_env()
|
||||
|
||||
assert config.dsn == "https://test@sentry.io/789"
|
||||
assert config.n8n_version == "2.0.0"
|
||||
assert config.environment == "production"
|
||||
assert config.deployment_name == "prod-deployment"
|
||||
assert config.profiles_sample_rate == 0.5
|
||||
assert config.traces_sample_rate == 0.1
|
||||
|
||||
@patch.dict("os.environ", {}, clear=True)
|
||||
def test_from_env_uses_defaults_when_missing(self):
|
||||
config = SentryConfig.from_env()
|
||||
|
||||
assert config.dsn == ""
|
||||
assert config.n8n_version == ""
|
||||
assert config.environment == ""
|
||||
assert config.deployment_name == ""
|
||||
assert config.profiles_sample_rate == 0
|
||||
assert config.traces_sample_rate == 0
|
||||
@@ -0,0 +1,339 @@
|
||||
import pytest
|
||||
|
||||
from src.errors.security_violation_error import SecurityViolationError
|
||||
from src.task_analyzer import TaskAnalyzer
|
||||
from src.config.security_config import SecurityConfig
|
||||
from src.constants import BLOCKED_ATTRIBUTES, BLOCKED_NAMES
|
||||
|
||||
|
||||
class TestTaskAnalyzer:
|
||||
@pytest.fixture
|
||||
def analyzer(self) -> TaskAnalyzer:
|
||||
security_config = SecurityConfig(
|
||||
stdlib_allow={
|
||||
"json",
|
||||
"math",
|
||||
"re",
|
||||
"datetime",
|
||||
"random",
|
||||
"string",
|
||||
"collections",
|
||||
"itertools",
|
||||
"functools",
|
||||
"operator",
|
||||
},
|
||||
external_allow=set(),
|
||||
builtins_deny=set(),
|
||||
runner_env_deny=True,
|
||||
)
|
||||
|
||||
return TaskAnalyzer(security_config)
|
||||
|
||||
|
||||
class TestImportValidation(TestTaskAnalyzer):
|
||||
def test_allowed_standard_imports(self, analyzer: TaskAnalyzer) -> None:
|
||||
valid_imports = [
|
||||
"import json",
|
||||
"import math",
|
||||
"from datetime import datetime",
|
||||
"from collections import Counter",
|
||||
"import re as regex",
|
||||
"from itertools import chain, cycle",
|
||||
"from math import *",
|
||||
]
|
||||
|
||||
for code in valid_imports:
|
||||
analyzer.validate(code)
|
||||
|
||||
def test_blocked_dangerous_imports(self, analyzer: TaskAnalyzer) -> None:
|
||||
dangerous_imports = [
|
||||
"import os",
|
||||
"import sys",
|
||||
"import subprocess",
|
||||
"from os import path",
|
||||
"import socket",
|
||||
]
|
||||
|
||||
for code in dangerous_imports:
|
||||
with pytest.raises(SecurityViolationError):
|
||||
analyzer.validate(code)
|
||||
|
||||
def test_blocked_relative_imports(self, analyzer: TaskAnalyzer) -> None:
|
||||
relative_imports = [
|
||||
"from . import module",
|
||||
"from .. import parent_module",
|
||||
"from ...package import something",
|
||||
]
|
||||
|
||||
for code in relative_imports:
|
||||
with pytest.raises(SecurityViolationError):
|
||||
analyzer.validate(code)
|
||||
|
||||
|
||||
class TestAttributeAccessValidation(TestTaskAnalyzer):
|
||||
def test_all_blocked_attributes_are_blocked(self, analyzer: TaskAnalyzer) -> None:
|
||||
for attr in BLOCKED_ATTRIBUTES:
|
||||
code = f"obj.{attr}"
|
||||
with pytest.raises(SecurityViolationError) as exc_info:
|
||||
analyzer.validate(code)
|
||||
assert attr in exc_info.value.description.lower()
|
||||
|
||||
def test_all_blocked_names_are_blocked(self, analyzer: TaskAnalyzer) -> None:
|
||||
for name in BLOCKED_NAMES:
|
||||
code = f"{name}"
|
||||
with pytest.raises(SecurityViolationError) as exc_info:
|
||||
analyzer.validate(code)
|
||||
assert name in exc_info.value.description
|
||||
|
||||
def test_loader_access_attempts_blocked(self, analyzer: TaskAnalyzer) -> None:
|
||||
exploit_attempts = [
|
||||
"__loader__.load_module('posix')",
|
||||
"posix = __loader__.load_module('posix'); posix.system('echo')",
|
||||
"module = __loader__.load_module('os')",
|
||||
]
|
||||
|
||||
for code in exploit_attempts:
|
||||
with pytest.raises(SecurityViolationError) as exc_info:
|
||||
analyzer.validate(code)
|
||||
assert "__loader__" in exc_info.value.description
|
||||
|
||||
def test_spec_access_attempts_blocked(self, analyzer: TaskAnalyzer) -> None:
|
||||
exploit_attempts = [
|
||||
"__spec__.loader().load_module('posix')",
|
||||
"posix = __spec__.loader().load_module('posix')",
|
||||
"__spec__",
|
||||
"loader = __spec__.loader()",
|
||||
]
|
||||
|
||||
for code in exploit_attempts:
|
||||
with pytest.raises(SecurityViolationError) as exc_info:
|
||||
analyzer.validate(code)
|
||||
assert "__spec__" in exc_info.value.description
|
||||
|
||||
def test_dunder_name_attempts_blocked(self, analyzer: TaskAnalyzer) -> None:
|
||||
exploit_attempts = [
|
||||
"sys.modules[__name__]",
|
||||
"builtins_module = sys.modules[__name__]",
|
||||
"sys.modules[__name__].open('/etc/passwd', 'r')",
|
||||
"builtins_module = sys.modules[__name__]; unfiltered_open = builtins_module.open",
|
||||
]
|
||||
|
||||
for code in exploit_attempts:
|
||||
with pytest.raises(SecurityViolationError) as exc_info:
|
||||
analyzer.validate(code)
|
||||
assert "__name__" in exc_info.value.description
|
||||
|
||||
def test_allowed_attribute_access(self, analyzer: TaskAnalyzer) -> None:
|
||||
allowed_attributes = [
|
||||
"obj.value",
|
||||
"data.items()",
|
||||
"list.append(item)",
|
||||
"dict.keys()",
|
||||
"str.upper()",
|
||||
"math.pi",
|
||||
]
|
||||
|
||||
for code in allowed_attributes:
|
||||
analyzer.validate(code)
|
||||
|
||||
def test_name_mangled_attributes_blocked(self, analyzer: TaskAnalyzer) -> None:
|
||||
exploit_attempts = [
|
||||
"license._Printer__filenames",
|
||||
"obj._SomeClass__private_attr",
|
||||
"help._Helper__name",
|
||||
"credits._Printer__data",
|
||||
"instance._MyClass__secret",
|
||||
]
|
||||
|
||||
for code in exploit_attempts:
|
||||
with pytest.raises(SecurityViolationError) as exc_info:
|
||||
analyzer.validate(code)
|
||||
assert "name-mangled" in exc_info.value.description.lower()
|
||||
|
||||
def test_objclass_attribute_blocked(self, analyzer: TaskAnalyzer) -> None:
|
||||
exploit_attempts = [
|
||||
"str.__or__.__objclass__",
|
||||
"str.__init__.__objclass__",
|
||||
"type_ref = str.__or__.__objclass__",
|
||||
"object_ref = str.__init__.__objclass__",
|
||||
]
|
||||
|
||||
for code in exploit_attempts:
|
||||
with pytest.raises(SecurityViolationError) as exc_info:
|
||||
analyzer.validate(code)
|
||||
assert "__objclass__" in exc_info.value.description
|
||||
|
||||
def test_attribute_error_obj_blocked(self, analyzer: TaskAnalyzer) -> None:
|
||||
exploit_attempts = [
|
||||
"e.obj",
|
||||
"exception.obj",
|
||||
"error.obj",
|
||||
]
|
||||
|
||||
for code in exploit_attempts:
|
||||
with pytest.raises(SecurityViolationError) as exc_info:
|
||||
analyzer.validate(code)
|
||||
assert "obj" in exc_info.value.description
|
||||
|
||||
|
||||
class TestDynamicImportDetection(TestTaskAnalyzer):
|
||||
def test_various_dynamic_import_patterns(self, analyzer: TaskAnalyzer) -> None:
|
||||
disallowed_dynamic_imports = [
|
||||
"__import__('os')",
|
||||
"import builtins; builtins.__import__('sys')",
|
||||
"module_name = 'subprocess'; __import__(module_name)",
|
||||
]
|
||||
|
||||
for code in disallowed_dynamic_imports:
|
||||
with pytest.raises(SecurityViolationError):
|
||||
analyzer.validate(code)
|
||||
|
||||
def test_allowed_modules_via_dynamic_import(self, analyzer: TaskAnalyzer) -> None:
|
||||
allowed_dynamic_imports = [
|
||||
"__import__('json')",
|
||||
"__import__('math')",
|
||||
"__import__('collections')",
|
||||
"module = __import__('datetime')",
|
||||
]
|
||||
|
||||
for code in allowed_dynamic_imports:
|
||||
analyzer.validate(code)
|
||||
|
||||
|
||||
class TestFormatStringAttacks(TestTaskAnalyzer):
|
||||
def test_dangerous_format_patterns_blocked(self, analyzer: TaskAnalyzer) -> None:
|
||||
dangerous_strings = [
|
||||
# Attribute access patterns
|
||||
'"{.__builtins__}".format(print)',
|
||||
'"{.__class__}".format(obj)',
|
||||
'"{.__globals__}".format(fn)',
|
||||
'"{.__class__.__mro__}".format(obj)',
|
||||
# Subscript access patterns
|
||||
'"{.__builtins__[__import__]}".format(print)',
|
||||
'"{[__import__]}".format(__builtins__)',
|
||||
'fmt = "{.__class__}"',
|
||||
'fmt = "{.__builtins__}"; fmt.format(obj)',
|
||||
]
|
||||
|
||||
for code in dangerous_strings:
|
||||
with pytest.raises(SecurityViolationError) as exc_info:
|
||||
analyzer.validate(code)
|
||||
assert "disallowed" in exc_info.value.description.lower()
|
||||
|
||||
def test_safe_format_strings_allowed(self, analyzer: TaskAnalyzer) -> None:
|
||||
safe_format_strings = [
|
||||
'"Hello {}".format(name)',
|
||||
'"{0} {1}".format(a, b)',
|
||||
'"{name}".format(name="world")',
|
||||
'"{.value}".format(obj)',
|
||||
'"{[key]}".format(data)',
|
||||
'"{:.2f}".format(3.14159)',
|
||||
]
|
||||
|
||||
for code in safe_format_strings:
|
||||
analyzer.validate(code)
|
||||
|
||||
def test_escaped_braces_allowed(self, analyzer: TaskAnalyzer) -> None:
|
||||
safe_escaped = [
|
||||
'"{{.__class__}}".format()',
|
||||
'"{{.__builtins__}}".format()',
|
||||
'"{{.__globals__}}".format()',
|
||||
]
|
||||
|
||||
for code in safe_escaped:
|
||||
analyzer.validate(code)
|
||||
|
||||
def test_fstring_blocked_attributes_detected(self, analyzer: TaskAnalyzer) -> None:
|
||||
exploit_attempts = [
|
||||
'f"{obj.__class__}"',
|
||||
'f"{fn.__globals__}"',
|
||||
]
|
||||
|
||||
for code in exploit_attempts:
|
||||
with pytest.raises(SecurityViolationError) as exc_info:
|
||||
analyzer.validate(code)
|
||||
assert "disallowed" in exc_info.value.description.lower()
|
||||
|
||||
|
||||
class TestMatchPatternValidation(TestTaskAnalyzer):
|
||||
def test_match_pattern_with_blocked_attributes_blocked(
|
||||
self, analyzer: TaskAnalyzer
|
||||
) -> None:
|
||||
attempts = [
|
||||
"""
|
||||
ex = None
|
||||
try:
|
||||
pass
|
||||
except Exception as e:
|
||||
ex = e
|
||||
match ex:
|
||||
case AttributeError(obj=rip):
|
||||
pass
|
||||
""",
|
||||
"""
|
||||
match error:
|
||||
case ValueError(obj=x):
|
||||
pass
|
||||
""",
|
||||
"""
|
||||
match e:
|
||||
case Exception(__traceback__=tb):
|
||||
pass
|
||||
""",
|
||||
]
|
||||
|
||||
for code in attempts:
|
||||
with pytest.raises(SecurityViolationError) as exc_info:
|
||||
analyzer.validate(code)
|
||||
assert "disallowed" in exc_info.value.description.lower()
|
||||
|
||||
def test_safe_match_patterns_allowed(self, analyzer: TaskAnalyzer) -> None:
|
||||
safe_patterns = [
|
||||
"""
|
||||
match value:
|
||||
case 1:
|
||||
pass
|
||||
case "hello":
|
||||
pass
|
||||
""",
|
||||
"""
|
||||
match point:
|
||||
case Point(x=x, y=y):
|
||||
pass
|
||||
""",
|
||||
"""
|
||||
match data:
|
||||
case {"key": value}:
|
||||
pass
|
||||
""",
|
||||
"""
|
||||
match result:
|
||||
case [first, *rest]:
|
||||
pass
|
||||
""",
|
||||
]
|
||||
|
||||
for code in safe_patterns:
|
||||
analyzer.validate(code)
|
||||
|
||||
|
||||
class TestAllowAll(TestTaskAnalyzer):
|
||||
def test_allow_all_bypasses_validation(self) -> None:
|
||||
security_config = SecurityConfig(
|
||||
stdlib_allow={"*"},
|
||||
external_allow={"*"},
|
||||
builtins_deny=set(),
|
||||
runner_env_deny=True,
|
||||
)
|
||||
analyzer = TaskAnalyzer(security_config)
|
||||
|
||||
unsafe_allowed_code = [
|
||||
"import os",
|
||||
"import sys",
|
||||
"__import__('subprocess')",
|
||||
"obj.__subclasses__",
|
||||
"from . import relative",
|
||||
]
|
||||
|
||||
for code in unsafe_allowed_code:
|
||||
analyzer.validate(code)
|
||||
@@ -0,0 +1,198 @@
|
||||
import pytest
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from src.task_executor import TaskExecutor
|
||||
from src.pipe_reader import PipeReader
|
||||
from src.errors import TaskCancelledError, TaskKilledError, TaskSubprocessFailedError
|
||||
from src.constants import SIGTERM_EXIT_CODE, SIGKILL_EXIT_CODE, PIPE_MSG_PREFIX_LENGTH
|
||||
from src.message_types.pipe import (
|
||||
PipeResultMessage,
|
||||
PipeErrorMessage,
|
||||
TaskErrorInfo,
|
||||
)
|
||||
|
||||
|
||||
class TestTaskExecutorProcessExitHandling:
|
||||
def test_sigterm_raises_task_cancelled_error(self):
|
||||
process = MagicMock()
|
||||
process.is_alive.return_value = False
|
||||
process.exitcode = SIGTERM_EXIT_CODE
|
||||
|
||||
read_conn = MagicMock()
|
||||
write_conn = MagicMock()
|
||||
read_conn.fileno.return_value = 999
|
||||
|
||||
with pytest.raises(TaskCancelledError):
|
||||
TaskExecutor.execute_process(
|
||||
process=process,
|
||||
read_conn=read_conn,
|
||||
write_conn=write_conn,
|
||||
task_timeout=60,
|
||||
continue_on_fail=False,
|
||||
)
|
||||
|
||||
def test_sigkill_raises_task_killed_error(self):
|
||||
process = MagicMock()
|
||||
process.is_alive.return_value = False
|
||||
process.exitcode = SIGKILL_EXIT_CODE
|
||||
|
||||
read_conn = MagicMock()
|
||||
write_conn = MagicMock()
|
||||
read_conn.fileno.return_value = 999
|
||||
|
||||
with pytest.raises(TaskKilledError):
|
||||
TaskExecutor.execute_process(
|
||||
process=process,
|
||||
read_conn=read_conn,
|
||||
write_conn=write_conn,
|
||||
task_timeout=60,
|
||||
continue_on_fail=False,
|
||||
)
|
||||
|
||||
def test_other_non_zero_exit_code_raises_task_subprocess_failed_error(self):
|
||||
process = MagicMock()
|
||||
process.is_alive.return_value = False
|
||||
process.exitcode = -1 # Some other error code
|
||||
|
||||
read_conn = MagicMock()
|
||||
write_conn = MagicMock()
|
||||
read_conn.fileno.return_value = 999
|
||||
|
||||
with pytest.raises(TaskSubprocessFailedError) as exc_info:
|
||||
TaskExecutor.execute_process(
|
||||
process=process,
|
||||
read_conn=read_conn,
|
||||
write_conn=write_conn,
|
||||
task_timeout=60,
|
||||
continue_on_fail=False,
|
||||
)
|
||||
|
||||
assert exc_info.value.exit_code == -1
|
||||
|
||||
def test_zero_exit_code_with_empty_pipe_raises_task_result_read_error(self):
|
||||
from src.errors import TaskResultReadError
|
||||
|
||||
process = MagicMock()
|
||||
process.is_alive.return_value = False
|
||||
process.exitcode = 0
|
||||
|
||||
read_conn = MagicMock()
|
||||
write_conn = MagicMock()
|
||||
read_conn.fileno.return_value = 999
|
||||
|
||||
with pytest.raises(TaskResultReadError):
|
||||
TaskExecutor.execute_process(
|
||||
process=process,
|
||||
read_conn=read_conn,
|
||||
write_conn=write_conn,
|
||||
task_timeout=60,
|
||||
continue_on_fail=False,
|
||||
)
|
||||
|
||||
|
||||
class TestTaskExecutorPipeCommunication:
|
||||
@patch("os.read")
|
||||
def test_successful_result_communication(self, mock_os_read):
|
||||
result_data: PipeResultMessage = {
|
||||
"result": [{"json": {"foo": "bar"}}],
|
||||
"print_args": [],
|
||||
}
|
||||
result_json = json.dumps(result_data).encode("utf-8")
|
||||
result_length = len(result_json).to_bytes(PIPE_MSG_PREFIX_LENGTH, "big")
|
||||
|
||||
mock_os_read.side_effect = [result_length, result_json]
|
||||
|
||||
process = MagicMock()
|
||||
process.is_alive.return_value = False
|
||||
process.exitcode = 0
|
||||
|
||||
read_conn = MagicMock()
|
||||
write_conn = MagicMock()
|
||||
read_conn.fileno.return_value = 999
|
||||
|
||||
result, print_args, size = TaskExecutor.execute_process(
|
||||
process=process,
|
||||
read_conn=read_conn,
|
||||
write_conn=write_conn,
|
||||
task_timeout=60,
|
||||
continue_on_fail=False,
|
||||
)
|
||||
|
||||
assert result == [{"json": {"foo": "bar"}}]
|
||||
assert print_args == []
|
||||
assert size == len(result_json)
|
||||
|
||||
@patch("os.read")
|
||||
def test_successful_error_communication(self, mock_os_read):
|
||||
from src.errors import TaskRuntimeError
|
||||
|
||||
error_info: TaskErrorInfo = {
|
||||
"message": "Test error",
|
||||
"description": "",
|
||||
"stack": "traceback...",
|
||||
"stderr": "",
|
||||
}
|
||||
error_data: PipeErrorMessage = {
|
||||
"error": error_info,
|
||||
"print_args": [],
|
||||
}
|
||||
error_json = json.dumps(error_data).encode("utf-8")
|
||||
error_length = len(error_json).to_bytes(PIPE_MSG_PREFIX_LENGTH, "big")
|
||||
|
||||
mock_os_read.side_effect = [error_length, error_json]
|
||||
|
||||
process = MagicMock()
|
||||
process.is_alive.return_value = False
|
||||
process.exitcode = 0
|
||||
|
||||
read_conn = MagicMock()
|
||||
write_conn = MagicMock()
|
||||
read_conn.fileno.return_value = 999
|
||||
|
||||
with pytest.raises(TaskRuntimeError) as exc_info:
|
||||
TaskExecutor.execute_process(
|
||||
process=process,
|
||||
read_conn=read_conn,
|
||||
write_conn=write_conn,
|
||||
task_timeout=60,
|
||||
continue_on_fail=False,
|
||||
)
|
||||
|
||||
assert str(exc_info.value) == "Test error"
|
||||
assert exc_info.value.stack_trace == "traceback..."
|
||||
|
||||
|
||||
class TestTaskExecutorLowLevelIO:
|
||||
@patch("os.read")
|
||||
def test_read_exact_bytes_single_read(self, mock_os_read):
|
||||
data = b"test data"
|
||||
mock_os_read.return_value = data
|
||||
|
||||
result = PipeReader._read_exact_bytes(999, len(data))
|
||||
|
||||
assert result == data
|
||||
mock_os_read.assert_called_once_with(999, len(data))
|
||||
|
||||
@patch("os.read")
|
||||
def test_read_exact_bytes_multiple_reads(self, mock_os_read):
|
||||
mock_os_read.side_effect = [b"test", b" ", b"data"]
|
||||
|
||||
result = PipeReader._read_exact_bytes(999, 9)
|
||||
|
||||
assert result == b"test data"
|
||||
assert mock_os_read.call_count == 3
|
||||
|
||||
@patch("os.read")
|
||||
def test_read_exact_bytes_eof_error(self, mock_os_read):
|
||||
mock_os_read.side_effect = [b"test", b""] # empty for EOF
|
||||
|
||||
with pytest.raises(EOFError, match="Pipe closed before reading all data"):
|
||||
PipeReader._read_exact_bytes(999, 10)
|
||||
|
||||
@patch("os.write")
|
||||
def test_write_bytes_write_failure(self, mock_os_write):
|
||||
mock_os_write.return_value = 0
|
||||
|
||||
with pytest.raises(OSError, match="Write failed"):
|
||||
TaskExecutor._write_bytes(999, b"test data")
|
||||
@@ -0,0 +1,105 @@
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, Mock
|
||||
|
||||
from websockets.exceptions import InvalidStatus
|
||||
|
||||
from src.task_runner import TaskRunner
|
||||
from src.config.task_runner_config import TaskRunnerConfig
|
||||
|
||||
|
||||
class TestTaskRunnerConnectionRetry:
|
||||
@pytest.fixture
|
||||
def config(self):
|
||||
return TaskRunnerConfig(
|
||||
grant_token="test-token",
|
||||
task_broker_uri="http://127.0.0.1:5679",
|
||||
max_concurrency=5,
|
||||
max_payload_size=1024 * 1024,
|
||||
task_timeout=60,
|
||||
auto_shutdown_timeout=0,
|
||||
graceful_shutdown_timeout=10,
|
||||
stdlib_allow={"*"},
|
||||
external_allow={"*"},
|
||||
builtins_deny=set(),
|
||||
env_deny=False,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connection_failure_logs_warning_not_crash(self, config):
|
||||
runner = TaskRunner(config)
|
||||
|
||||
def connection_side_effect(*args, **kwargs):
|
||||
if mock_connect.call_count >= 2:
|
||||
runner.is_shutting_down = True
|
||||
raise ConnectionRefusedError("Connection refused")
|
||||
|
||||
with (
|
||||
patch("src.task_runner.websockets.connect") as mock_connect,
|
||||
patch.object(runner, "logger") as mock_logger,
|
||||
patch("src.task_runner.asyncio.sleep"),
|
||||
):
|
||||
mock_connect.side_effect = connection_side_effect
|
||||
|
||||
await runner.start()
|
||||
|
||||
assert mock_connect.call_count >= 2
|
||||
mock_logger.warning.assert_called()
|
||||
args = mock_logger.warning.call_args[0][0]
|
||||
assert "Failed to connect to broker" in args
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_failure_raises_without_retry(self, config):
|
||||
runner = TaskRunner(config)
|
||||
|
||||
with (
|
||||
patch("src.task_runner.websockets.connect") as mock_connect,
|
||||
patch.object(runner, "logger") as mock_logger,
|
||||
):
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 403
|
||||
auth_error = InvalidStatus(mock_response)
|
||||
mock_connect.side_effect = auth_error
|
||||
|
||||
with pytest.raises(InvalidStatus):
|
||||
await runner.start()
|
||||
|
||||
mock_logger.error.assert_called_once()
|
||||
args = mock_logger.error.call_args[0][0]
|
||||
assert "Authentication failed with status 403" in args
|
||||
|
||||
assert mock_connect.call_count == 1
|
||||
|
||||
|
||||
class TestTaskRunnerDrain:
|
||||
@pytest.fixture
|
||||
def config(self):
|
||||
return TaskRunnerConfig(
|
||||
grant_token="test-token",
|
||||
task_broker_uri="http://127.0.0.1:5679",
|
||||
max_concurrency=5,
|
||||
max_payload_size=1024 * 1024,
|
||||
task_timeout=60,
|
||||
auto_shutdown_timeout=0,
|
||||
graceful_shutdown_timeout=10,
|
||||
stdlib_allow={"*"},
|
||||
external_allow={"*"},
|
||||
builtins_deny=set(),
|
||||
env_deny=False,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drain_stops_sending_offers(self, config):
|
||||
runner = TaskRunner(config)
|
||||
runner.can_send_offers = True
|
||||
|
||||
async def wait_forever():
|
||||
await asyncio.sleep(1000)
|
||||
|
||||
runner.offers_coroutine = asyncio.create_task(wait_forever())
|
||||
|
||||
await runner._handle_drain()
|
||||
|
||||
assert runner.can_send_offers is False
|
||||
assert runner.offers_coroutine.cancelled()
|
||||
Reference in New Issue
Block a user