Files
wehub-resource-sync 19ce5adb69
CI / Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Type Check (push) Has been cancelled
CI / Docker Tests (push) Has been cancelled
CI / Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:46:45 +08:00

482 lines
15 KiB
Python

"""Tests for PipelineOrchestrator.
Unit tests for pentestgpt.core.pipeline module.
"""
from pathlib import Path
import pytest
from pentestgpt.core.backend import AgentMessage, MessageType
from pentestgpt.core.config import PentestGPTConfig
from pentestgpt.core.events import EventBus
from pentestgpt.core.pipeline import (
PipelineMode,
PipelineOrchestrator,
PipelineResult,
StageDefinition,
StageResult,
)
from pentestgpt.core.session import SessionStore
from tests.conftest import MockBackend
# =============================================================================
# Data structure tests
# =============================================================================
@pytest.mark.unit
class TestStageResult:
"""Tests for StageResult dataclass."""
def test_defaults(self):
"""Test default values."""
r = StageResult(stage_name="recon", display_name="Recon")
assert r.status == "completed"
assert r.output == ""
assert r.flags_found == []
assert r.cost_usd == 0.0
assert r.error is None
assert r.session_id == ""
def test_with_values(self):
"""Test with explicit values."""
r = StageResult(
stage_name="exploit",
display_name="Exploitation",
status="completed",
output="Got shell",
flags_found=["flag{test}"],
cost_usd=1.23,
session_id="sess-123",
)
assert r.flags_found == ["flag{test}"]
assert r.cost_usd == 1.23
@pytest.mark.unit
class TestPipelineResult:
"""Tests for PipelineResult dataclass."""
def test_empty_result(self):
"""Test empty result."""
r = PipelineResult(mode=PipelineMode.CTF)
assert r.success is True # vacuously true
assert r.all_flags == []
assert r.total_cost == 0.0
assert r.combined_output == ""
def test_success_with_stages(self):
"""Test success computation with completed stages."""
r = PipelineResult(
mode=PipelineMode.CTF,
stage_results=[
StageResult(stage_name="s1", display_name="S1", status="completed"),
StageResult(stage_name="s2", display_name="S2", status="completed"),
],
)
assert r.success is True
def test_failure_with_error_stage(self):
"""Test success is False when a stage has error."""
r = PipelineResult(
mode=PipelineMode.CTF,
stage_results=[
StageResult(stage_name="s1", display_name="S1", status="completed"),
StageResult(stage_name="s2", display_name="S2", status="error"),
],
)
assert r.success is False
def test_all_flags_deduplication(self):
"""Test that all_flags deduplicates across stages."""
r = PipelineResult(
mode=PipelineMode.CTF,
stage_results=[
StageResult(
stage_name="s1",
display_name="S1",
flags_found=["flag{a}", "flag{b}"],
),
StageResult(
stage_name="s2",
display_name="S2",
flags_found=["flag{b}", "flag{c}"],
),
],
)
assert r.all_flags == ["flag{a}", "flag{b}", "flag{c}"]
def test_total_cost(self):
"""Test total cost aggregation."""
r = PipelineResult(
mode=PipelineMode.PENTEST,
stage_results=[
StageResult(stage_name="s1", display_name="S1", cost_usd=1.5),
StageResult(stage_name="s2", display_name="S2", cost_usd=2.3),
StageResult(stage_name="s3", display_name="S3", cost_usd=0.7),
],
)
assert abs(r.total_cost - 4.5) < 0.01
def test_combined_output(self):
"""Test combined output formatting."""
r = PipelineResult(
mode=PipelineMode.CTF,
stage_results=[
StageResult(stage_name="s1", display_name="Recon", output="Found port 80"),
StageResult(stage_name="s2", display_name="Exploit", output="Got root"),
],
)
combined = r.combined_output
assert "=== Recon ===" in combined
assert "Found port 80" in combined
assert "=== Exploit ===" in combined
assert "Got root" in combined
# =============================================================================
# PipelineOrchestrator tests (using mock backend)
# =============================================================================
def _make_stage_def(
name: str, display_name: str, sys_prompt: str = "test", task_prompt: str = "test task"
) -> StageDefinition:
"""Helper to create a simple StageDefinition."""
return StageDefinition(
name=name,
display_name=display_name,
get_system_prompt=lambda config, _sp=sys_prompt: _sp,
get_task_prompt=lambda config, prior, _tp=task_prompt: _tp,
)
@pytest.mark.unit
class TestPipelineOrchestrator:
"""Tests for PipelineOrchestrator."""
@pytest.fixture
def config(self, temp_working_dir: Path) -> PentestGPTConfig:
"""Create test config."""
return PentestGPTConfig(
target="test.example.com",
working_directory=temp_working_dir,
)
@pytest.fixture
def events(self) -> EventBus:
"""Get event bus."""
return EventBus.get()
@pytest.fixture
def session_store(self, temp_sessions_dir: Path) -> SessionStore:
"""Create session store with temp dir."""
return SessionStore(sessions_dir=temp_sessions_dir)
@pytest.mark.asyncio
async def test_run_single_stage(
self,
config: PentestGPTConfig,
session_store: SessionStore,
events: EventBus,
monkeypatch,
):
"""Test running a single-stage pipeline with mock backend."""
mock_backend = MockBackend()
mock_backend.set_messages(
[
AgentMessage(type=MessageType.TEXT, content="Scanning target..."),
AgentMessage(type=MessageType.RESULT, content=None, metadata={"cost_usd": 0.1}),
]
)
stages = [_make_stage_def("recon", "Recon")]
# Monkeypatch ClaudeCodeBackend to return our mock
monkeypatch.setattr(
"pentestgpt.core.backend.ClaudeCodeBackend",
lambda **kwargs: mock_backend,
)
orchestrator = PipelineOrchestrator(
config=config,
stages=stages,
mode=PipelineMode.CTF,
session_store=session_store,
events=events,
)
result = await orchestrator.run()
assert len(result.stage_results) == 1
assert result.stage_results[0].stage_name == "recon"
assert result.stage_results[0].status == "completed"
@pytest.mark.asyncio
async def test_run_multi_stage(
self,
config: PentestGPTConfig,
session_store: SessionStore,
events: EventBus,
monkeypatch,
):
"""Test running a multi-stage pipeline."""
mock_backend = MockBackend()
mock_backend.set_messages(
[
AgentMessage(type=MessageType.TEXT, content="Stage output"),
AgentMessage(type=MessageType.RESULT, content=None, metadata={}),
]
)
stages = [
_make_stage_def("s1", "Stage 1"),
_make_stage_def("s2", "Stage 2"),
_make_stage_def("s3", "Stage 3"),
]
monkeypatch.setattr(
"pentestgpt.core.backend.ClaudeCodeBackend",
lambda **kwargs: mock_backend,
)
orchestrator = PipelineOrchestrator(
config=config,
stages=stages,
mode=PipelineMode.CTF,
session_store=session_store,
events=events,
)
result = await orchestrator.run()
assert len(result.stage_results) == 3
assert all(r.status == "completed" for r in result.stage_results)
@pytest.mark.asyncio
async def test_flags_accumulated_across_stages(
self,
config: PentestGPTConfig,
session_store: SessionStore,
events: EventBus,
monkeypatch,
):
"""Test that flags are detected and accumulated across stages."""
mock_backend = MockBackend()
mock_backend.set_messages(
[
AgentMessage(type=MessageType.TEXT, content="Found flag{stage_flag}!"),
AgentMessage(type=MessageType.RESULT, content=None, metadata={}),
]
)
stages = [
_make_stage_def("s1", "Stage 1"),
_make_stage_def("s2", "Stage 2"),
]
monkeypatch.setattr(
"pentestgpt.core.backend.ClaudeCodeBackend",
lambda **kwargs: mock_backend,
)
orchestrator = PipelineOrchestrator(
config=config,
stages=stages,
mode=PipelineMode.CTF,
session_store=session_store,
events=events,
)
result = await orchestrator.run()
# flag{stage_flag} should be detected in both stages but deduplicated
assert "flag{stage_flag}" in result.all_flags
@pytest.mark.asyncio
async def test_stop_halts_pipeline(
self,
config: PentestGPTConfig,
session_store: SessionStore,
events: EventBus,
monkeypatch,
):
"""Test that stop() prevents remaining stages from running."""
stage_count = 0
class StopAfterFirstBackend(MockBackend):
"""Backend that triggers orchestrator stop after first stage."""
def __init__(self, orchestrator_ref):
super().__init__()
self._orchestrator_ref = orchestrator_ref
async def connect(self):
nonlocal stage_count
stage_count += 1
if stage_count == 1:
# After first stage connects, request stop so stage 2+ won't run
self._orchestrator_ref._stop_requested = True
self._connected = True
stages_defs = [
_make_stage_def("s1", "Stage 1"),
_make_stage_def("s2", "Stage 2"),
_make_stage_def("s3", "Stage 3"),
]
orchestrator = PipelineOrchestrator(
config=config,
stages=stages_defs,
mode=PipelineMode.CTF,
session_store=session_store,
events=events,
)
stop_backend = StopAfterFirstBackend(orchestrator)
stop_backend.set_messages(
[
AgentMessage(type=MessageType.TEXT, content="Output"),
AgentMessage(type=MessageType.RESULT, content=None, metadata={}),
]
)
monkeypatch.setattr(
"pentestgpt.core.backend.ClaudeCodeBackend",
lambda **kwargs: stop_backend,
)
result = await orchestrator.run()
# Only stage 1 ran; stop was requested before stage 2
assert len(result.stage_results) == 1
assert result.stage_results[0].stage_name == "s1"
@pytest.mark.asyncio
async def test_stage_error_continues_pipeline(
self,
config: PentestGPTConfig,
session_store: SessionStore,
events: EventBus,
monkeypatch,
):
"""Test that a stage error doesn't abort the entire pipeline."""
call_count = 0
class FailFirstBackend(MockBackend):
"""Backend that fails on the first call."""
async def connect(self):
nonlocal call_count
call_count += 1
if call_count == 1:
raise Exception("Connection failed")
self._connected = True
fail_backend = FailFirstBackend()
fail_backend.set_messages(
[
AgentMessage(type=MessageType.TEXT, content="Success"),
AgentMessage(type=MessageType.RESULT, content=None, metadata={}),
]
)
stages = [
_make_stage_def("s1", "Stage 1"),
_make_stage_def("s2", "Stage 2"),
]
monkeypatch.setattr(
"pentestgpt.core.backend.ClaudeCodeBackend",
lambda **kwargs: fail_backend,
)
orchestrator = PipelineOrchestrator(
config=config,
stages=stages,
mode=PipelineMode.CTF,
session_store=session_store,
events=events,
)
result = await orchestrator.run()
assert len(result.stage_results) == 2
assert result.stage_results[0].status == "error"
assert result.stage_results[1].status == "completed"
def test_pause_without_active_controller(
self,
config: PentestGPTConfig,
session_store: SessionStore,
events: EventBus,
):
"""Test pause when no stage is active."""
orchestrator = PipelineOrchestrator(
config=config,
stages=[],
mode=PipelineMode.CTF,
session_store=session_store,
events=events,
)
assert orchestrator.pause() is False
def test_resume_without_active_controller(
self,
config: PentestGPTConfig,
session_store: SessionStore,
events: EventBus,
):
"""Test resume when no stage is active."""
orchestrator = PipelineOrchestrator(
config=config,
stages=[],
mode=PipelineMode.CTF,
session_store=session_store,
events=events,
)
assert orchestrator.resume() is False
def test_stop_without_active_controller(
self,
config: PentestGPTConfig,
session_store: SessionStore,
events: EventBus,
):
"""Test stop when no stage is active."""
orchestrator = PipelineOrchestrator(
config=config,
stages=[],
mode=PipelineMode.CTF,
session_store=session_store,
events=events,
)
assert orchestrator.stop() is True
assert orchestrator._stop_requested is True
def test_inject_without_active_controller(
self,
config: PentestGPTConfig,
session_store: SessionStore,
events: EventBus,
):
"""Test inject when no stage is active."""
orchestrator = PipelineOrchestrator(
config=config,
stages=[],
mode=PipelineMode.CTF,
session_store=session_store,
events=events,
)
assert orchestrator.inject("test") is False
@pytest.mark.unit
class TestPipelineMode:
"""Tests for PipelineMode enum."""
def test_ctf_value(self):
assert PipelineMode.CTF.value == "ctf"
def test_pentest_value(self):
assert PipelineMode.PENTEST.value == "pentest"