cef70ff98a
Add Robot Framework integration test verifying that load_from_entry_points does not call ep.load() for entry points with disallowed module prefixes (security regression test for issue #7476). Also add HAL 9000 to CONTRIBUTORS.md per CONTRIBUTING.md process rules. ISSUES CLOSED: #7476
202 lines
7.2 KiB
Python
202 lines
7.2 KiB
Python
"""Step definitions for plugins_loader_coverage.feature.
|
|
|
|
These steps target specific uncovered lines in
|
|
cleveragents/infrastructure/plugins/loader.py:
|
|
|
|
- Lines 203-209: except block in load_from_entry_points when ep.load() fails
|
|
- Lines 242, 244-246: validate_protocol fallback to issubclass on
|
|
instantiation failure
|
|
- Lines 247-248: validate_protocol when issubclass raises TypeError
|
|
"""
|
|
|
|
from typing import Protocol, runtime_checkable
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from behave import given, then, when
|
|
|
|
from cleveragents.infrastructure.plugins.exceptions import ProtocolMismatchError
|
|
from cleveragents.infrastructure.plugins.loader import PluginLoader
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Background
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("the plugin loader module is imported")
|
|
def step_plugin_loader_imported(context):
|
|
"""Verify the PluginLoader class is importable."""
|
|
assert PluginLoader is not None
|
|
context.plugin_loader = PluginLoader()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Entry-point discovery: failure path (lines 203-209)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("the entry points are mocked with one that raises on load")
|
|
def step_mock_failing_entry_point(context):
|
|
"""Create a mock entry point whose load() raises an exception."""
|
|
failing_ep = MagicMock()
|
|
failing_ep.name = "broken_plugin"
|
|
failing_ep.value = "cleveragents.domain.models.acms.nonexistent:BrokenClass"
|
|
failing_ep.load.side_effect = ImportError("Simulated import failure")
|
|
|
|
# Mock the entry_points() call to return a selectable object
|
|
mock_eps = MagicMock()
|
|
mock_eps.select.return_value = [failing_ep]
|
|
context.mock_entry_points = mock_eps
|
|
context.failing_ep = failing_ep
|
|
|
|
|
|
@when("I call load_from_entry_points")
|
|
def step_call_load_from_entry_points(context):
|
|
"""Call load_from_entry_points with the mocked entry points."""
|
|
with patch(
|
|
"cleveragents.infrastructure.plugins.loader.importlib.metadata.entry_points",
|
|
return_value=context.mock_entry_points,
|
|
):
|
|
context.discovered = context.plugin_loader.load_from_entry_points()
|
|
|
|
|
|
@then("the plugin result should be an empty descriptor list")
|
|
def step_verify_empty_descriptors(context):
|
|
"""The failing entry point should have been skipped."""
|
|
assert context.discovered == [], f"Expected empty list, got {context.discovered}"
|
|
|
|
|
|
@then("the failed entry point should have been logged as a warning")
|
|
def step_verify_warning_logged(context):
|
|
"""Verify the entry point was attempted (load was called)."""
|
|
context.failing_ep.load.assert_called_once()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# validate_protocol: instantiation fails, issubclass succeeds (lines 242, 244-246)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@runtime_checkable
|
|
class _SampleProtocol(Protocol):
|
|
"""A simple runtime-checkable Protocol for testing issubclass fallback."""
|
|
|
|
def do_work(self) -> str: ...
|
|
|
|
|
|
@given("I have a class that requires constructor arguments")
|
|
def step_class_requires_args(context):
|
|
"""Create a class whose __init__ requires mandatory arguments."""
|
|
|
|
class RequiresArgs(_SampleProtocol):
|
|
def __init__(self, mandatory_arg):
|
|
self._arg = mandatory_arg
|
|
|
|
def do_work(self) -> str:
|
|
return "done"
|
|
|
|
context.non_instantiable_class = RequiresArgs
|
|
|
|
|
|
@given("I have a runtime checkable protocol the class satisfies via issubclass")
|
|
def step_protocol_satisfied_by_subclass(context):
|
|
"""Store the protocol that the class satisfies structurally."""
|
|
context.target_protocol = _SampleProtocol
|
|
|
|
|
|
@when("I call validate_protocol with the non-instantiable class and protocol")
|
|
def step_call_validate_protocol_subclass_fallback(context):
|
|
"""Call validate_protocol; expect it to fall back to issubclass and succeed."""
|
|
context.validate_result = PluginLoader.validate_protocol(
|
|
context.non_instantiable_class,
|
|
context.target_protocol,
|
|
)
|
|
|
|
|
|
@then("validate_protocol should return True")
|
|
def step_verify_validate_true(context):
|
|
"""The fallback issubclass check should have returned True."""
|
|
assert context.validate_result is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# validate_protocol: instantiation fails, issubclass raises TypeError (lines 247-248)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("I have a class that cannot be instantiated without arguments")
|
|
def step_class_cannot_instantiate(context):
|
|
"""Create a class whose __init__ raises when called with no args."""
|
|
|
|
class NeedsArgs:
|
|
def __init__(self, required):
|
|
self._r = required
|
|
|
|
context.non_instantiable_class = NeedsArgs
|
|
|
|
|
|
@given("I have a protocol-like object that causes issubclass to raise TypeError")
|
|
def step_protocol_causes_typeerror(context):
|
|
"""Create a protocol-like object that causes issubclass to raise TypeError.
|
|
|
|
We need an object that:
|
|
- Has a __name__ attribute (so the error message in validate_protocol works)
|
|
- Causes issubclass() to raise TypeError when used as second arg
|
|
- Also causes isinstance() to raise TypeError
|
|
|
|
A class with a metaclass that raises TypeError on __instancecheck__
|
|
and __subclasscheck__ achieves this.
|
|
"""
|
|
|
|
class TypeErrorMeta(type):
|
|
def __instancecheck__(cls, instance):
|
|
raise TypeError("Simulated TypeError from isinstance")
|
|
|
|
def __subclasscheck__(cls, instance):
|
|
raise TypeError("Simulated TypeError from issubclass")
|
|
|
|
class BadProtocol(metaclass=TypeErrorMeta):
|
|
pass
|
|
|
|
context.target_protocol = BadProtocol
|
|
|
|
|
|
@when("I call validate_protocol expecting a mismatch error")
|
|
def step_call_validate_protocol_expect_error(context):
|
|
"""Call validate_protocol and capture the expected error."""
|
|
try:
|
|
PluginLoader.validate_protocol(
|
|
context.non_instantiable_class,
|
|
context.target_protocol,
|
|
)
|
|
context.validate_error = None
|
|
except ProtocolMismatchError as exc:
|
|
context.validate_error = exc
|
|
except Exception as exc:
|
|
context.validate_error = exc
|
|
|
|
|
|
@then("a plugin-loader ProtocolMismatchError should be raised")
|
|
def step_verify_protocol_mismatch(context):
|
|
"""Verify that ProtocolMismatchError was raised."""
|
|
assert context.validate_error is not None, "Expected an error but none was raised"
|
|
assert isinstance(context.validate_error, ProtocolMismatchError), (
|
|
f"Expected ProtocolMismatchError, got {type(context.validate_error).__name__}: "
|
|
f"{context.validate_error}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# validate_protocol: instantiation fails, issubclass returns False
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("I have a runtime checkable protocol the class does not satisfy")
|
|
def step_protocol_not_satisfied(context):
|
|
"""Store a protocol the class does NOT satisfy."""
|
|
|
|
@runtime_checkable
|
|
class UnrelatedProtocol(Protocol):
|
|
def completely_different_method(self) -> int: ...
|
|
|
|
context.target_protocol = UnrelatedProtocol
|