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

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -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()