feat: Add comprehensive test suite including CLI, enhanced detection, integration, and async execution tests.

This commit is contained in:
shiva108
2026-01-26 13:06:47 +01:00
parent d4e1c3739d
commit da7ec7a341
4 changed files with 1455 additions and 0 deletions
@@ -0,0 +1,398 @@
"""Tests for async execution and concurrency control."""
import asyncio
import time
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from prompt_injection_tester.core.tester import InjectionTester
from prompt_injection_tester.core.models import (
AttackConfig,
TargetConfig,
InjectionPoint,
TestStatus,
)
from prompt_injection_tester.utils.http_client import LLMClient
@pytest.mark.asyncio
async def test_concurrent_requests_with_semaphore() -> None:
"""Test that concurrent requests respect semaphore limits."""
mock_client = AsyncMock()
active_requests = []
max_concurrent_seen = 0
async def track_concurrent_requests(*args, **kwargs):
nonlocal max_concurrent_seen
active_requests.append(1)
max_concurrent_seen = max(max_concurrent_seen, len(active_requests))
await asyncio.sleep(0.1) # Simulate work
active_requests.pop()
return ("response", {"status": "ok"})
mock_client.send_prompt = AsyncMock(side_effect=track_concurrent_requests)
mock_client.close = AsyncMock()
target = TargetConfig(
name="Test",
base_url="http://localhost:8000",
api_type="openai",
auth_token="test",
)
config = AttackConfig(
patterns=["direct_instruction_override"],
max_concurrent=3, # Limit to 3 concurrent requests
)
tester = InjectionTester(target_config=target, config=config)
tester.client = mock_client
tester.authorize(scope=["all"])
try:
points = await tester.discover_injection_points()
await tester.run_tests(points)
# Verify semaphore limited concurrency
assert max_concurrent_seen <= 3, f"Exceeded concurrency limit: {max_concurrent_seen}"
finally:
await tester.close()
@pytest.mark.asyncio
async def test_async_gather_exception_handling() -> None:
"""Test that exceptions in one task don't crash all tasks."""
mock_client = AsyncMock()
call_count = 0
async def sometimes_fail(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 2:
raise ConnectionError("Simulated failure")
return ("response", {"status": "ok"})
mock_client.send_prompt = AsyncMock(side_effect=sometimes_fail)
mock_client.close = AsyncMock()
target = TargetConfig(
name="Test",
base_url="http://localhost:8000",
api_type="openai",
auth_token="test",
)
config = AttackConfig(
patterns=[
"direct_instruction_override",
"direct_role_authority",
"direct_persona_shift",
],
max_concurrent=5,
)
tester = InjectionTester(target_config=target, config=config)
tester.client = mock_client
tester.authorize(scope=["all"])
try:
points = await tester.discover_injection_points()
results = await tester.run_tests(points)
# Should complete despite one failure
assert results.total_tests > 0
assert call_count > 2, "Other tasks should have completed"
finally:
await tester.close()
@pytest.mark.asyncio
async def test_async_timeout_enforcement() -> None:
"""Test that async operations respect timeouts."""
mock_client = AsyncMock()
async def slow_response(*args, **kwargs):
await asyncio.sleep(10) # Will be interrupted by timeout
return ("response", {"status": "ok"})
mock_client.send_prompt = AsyncMock(side_effect=slow_response)
mock_client.close = AsyncMock()
target = TargetConfig(
name="Test",
base_url="http://localhost:8000",
api_type="openai",
auth_token="test",
timeout=1, # 1 second timeout
)
config = AttackConfig(
patterns=["direct_instruction_override"],
timeout_per_test=1,
)
tester = InjectionTester(target_config=target, config=config)
tester.client = mock_client
tester.authorize(scope=["all"])
start_time = time.time()
try:
points = await tester.discover_injection_points()
await tester.run_tests(points)
elapsed = time.time() - start_time
# Should timeout quickly, not wait full 10 seconds
assert elapsed < 5, f"Timeout not enforced: took {elapsed}s"
finally:
await tester.close()
@pytest.mark.asyncio
async def test_rate_limiting_token_bucket() -> None:
"""Test rate limiting using token bucket algorithm."""
mock_client = AsyncMock()
call_timestamps = []
async def record_timestamp(*args, **kwargs):
call_timestamps.append(time.time())
return ("response", {"status": "ok"})
mock_client.send_prompt = AsyncMock(side_effect=record_timestamp)
mock_client.close = AsyncMock()
target = TargetConfig(
name="Test",
base_url="http://localhost:8000",
api_type="openai",
auth_token="test",
rate_limit=2.0, # 2 requests per second
)
config = AttackConfig(
patterns=["direct_instruction_override"],
max_concurrent=1, # Sequential to test rate limiting clearly
)
tester = InjectionTester(target_config=target, config=config)
tester.client = mock_client
tester.authorize(scope=["all"])
try:
points = await tester.discover_injection_points()
await tester.run_tests(points)
if len(call_timestamps) >= 3:
# Calculate intervals between calls
intervals = [
call_timestamps[i + 1] - call_timestamps[i]
for i in range(len(call_timestamps) - 1)
]
# At 2 req/sec, should be ~0.5s between calls
avg_interval = sum(intervals) / len(intervals)
assert avg_interval >= 0.4, f"Rate limit not respected: {avg_interval}s"
finally:
await tester.close()
@pytest.mark.asyncio
async def test_async_context_manager() -> None:
"""Test async context manager properly initializes and cleans up."""
target = TargetConfig(
name="Test",
base_url="http://localhost:8000",
api_type="openai",
auth_token="test",
)
config = AttackConfig(patterns=["direct_instruction_override"])
# Mock the client initialization
with patch("prompt_injection_tester.core.tester.LLMClient") as mock_client_cls:
mock_client = AsyncMock()
mock_client.close = AsyncMock()
mock_client_cls.return_value = mock_client
async with InjectionTester(target_config=target, config=config) as tester:
# Client should be initialized inside context
assert tester.client is not None
# Client should be closed after context
mock_client.close.assert_called_once()
@pytest.mark.asyncio
async def test_parallel_pattern_execution() -> None:
"""Test that multiple patterns execute in parallel."""
mock_client = AsyncMock()
execution_order = []
async def track_execution(prompt: str, *args, **kwargs):
execution_order.append(("start", prompt[:20]))
await asyncio.sleep(0.05)
execution_order.append(("end", prompt[:20]))
return ("response", {"status": "ok"})
mock_client.send_prompt = AsyncMock(side_effect=track_execution)
mock_client.close = AsyncMock()
target = TargetConfig(
name="Test",
base_url="http://localhost:8000",
api_type="openai",
auth_token="test",
)
config = AttackConfig(
patterns=[
"direct_instruction_override",
"direct_role_authority",
],
max_concurrent=5,
)
tester = InjectionTester(target_config=target, config=config)
tester.client = mock_client
tester.authorize(scope=["all"])
try:
points = await tester.discover_injection_points()
await tester.run_tests(points)
# Check that executions overlapped (parallel)
# If parallel, we should see: start1, start2, end1, end2 (interleaved)
# If sequential, we'd see: start1, end1, start2, end2
starts = [e for e in execution_order if e[0] == "start"]
if len(starts) >= 2:
# Find indices of first two starts
idx1 = execution_order.index(starts[0])
idx2 = execution_order.index(starts[1])
# If parallel, second start comes before first end
first_end_idx = execution_order.index(("end", starts[0][1]))
assert idx2 < first_end_idx, "Patterns should execute in parallel"
finally:
await tester.close()
@pytest.mark.asyncio
async def test_async_error_propagation() -> None:
"""Test that async errors are properly caught and reported."""
mock_client = AsyncMock()
async def raise_error(*args, **kwargs):
raise ValueError("Intentional test error")
mock_client.send_prompt = AsyncMock(side_effect=raise_error)
mock_client.close = AsyncMock()
target = TargetConfig(
name="Test",
base_url="http://localhost:8000",
api_type="openai",
auth_token="test",
)
config = AttackConfig(patterns=["direct_instruction_override"])
tester = InjectionTester(target_config=target, config=config)
tester.client = mock_client
tester.authorize(scope=["all"])
try:
points = await tester.discover_injection_points()
results = await tester.run_tests(points)
# Errors should be caught, not crash the entire run
assert results.total_tests > 0
# Check that errors were recorded
error_results = [r for r in results.results if r.error is not None]
assert len(error_results) > 0, "Errors should be recorded"
finally:
await tester.close()
@pytest.mark.asyncio
async def test_async_cancellation_handling() -> None:
"""Test graceful handling of task cancellation."""
mock_client = AsyncMock()
async def long_running_task(*args, **kwargs):
await asyncio.sleep(100) # Very long task
return ("response", {"status": "ok"})
mock_client.send_prompt = AsyncMock(side_effect=long_running_task)
mock_client.close = AsyncMock()
target = TargetConfig(
name="Test",
base_url="http://localhost:8000",
api_type="openai",
auth_token="test",
)
config = AttackConfig(patterns=["direct_instruction_override"])
tester = InjectionTester(target_config=target, config=config)
tester.client = mock_client
tester.authorize(scope=["all"])
try:
points = await tester.discover_injection_points()
# Start tests and cancel after short delay
test_task = asyncio.create_task(tester.run_tests(points))
await asyncio.sleep(0.1)
test_task.cancel()
try:
await test_task
except asyncio.CancelledError:
pass # Expected
# Should be able to close cleanly even after cancellation
await tester.close()
except Exception:
await tester.close()
raise
@pytest.mark.asyncio
async def test_concurrent_discoveries() -> None:
"""Test concurrent injection point discovery."""
mock_client = AsyncMock()
mock_client.send_prompt = AsyncMock(
return_value=("response", {"status": "ok"})
)
mock_client.close = AsyncMock()
target = TargetConfig(
name="Test",
base_url="http://localhost:8000",
api_type="openai",
auth_token="test",
)
tester = InjectionTester(target_config=target)
tester.client = mock_client
try:
# Run multiple discoveries concurrently
discoveries = await asyncio.gather(
tester.discover_injection_points(),
tester.discover_injection_points(),
tester.discover_injection_points(),
)
# All should complete successfully
assert len(discoveries) == 3
assert all(len(d) > 0 for d in discoveries)
finally:
await tester.close()
@@ -0,0 +1,368 @@
"""CLI tests for prompt_injection_tester command-line interface."""
import json
import subprocess
import sys
from pathlib import Path
import pytest
@pytest.mark.cli
def test_cli_help() -> None:
"""Test that --help works and shows usage."""
result = subprocess.run(
[sys.executable, "-m", "prompt_injection_tester", "--help"],
capture_output=True,
text=True,
timeout=10,
)
assert result.returncode == 0, f"Help failed: {result.stderr}"
assert "usage:" in result.stdout.lower()
assert "--target" in result.stdout
assert "--authorize" in result.stdout
@pytest.mark.cli
def test_cli_version() -> None:
"""Test that --version works."""
result = subprocess.run(
[sys.executable, "-m", "prompt_injection_tester", "--version"],
capture_output=True,
text=True,
timeout=10,
)
assert result.returncode == 0
# Should print version number
assert len(result.stdout.strip()) > 0
@pytest.mark.cli
def test_cli_list_patterns() -> None:
"""Test listing available attack patterns."""
result = subprocess.run(
[sys.executable, "-m", "prompt_injection_tester", "--list-patterns"],
capture_output=True,
text=True,
timeout=10,
)
assert result.returncode == 0
# Should list pattern IDs
assert "direct_instruction_override" in result.stdout
assert "direct_role_authority" in result.stdout
@pytest.mark.cli
def test_cli_list_categories() -> None:
"""Test listing attack categories."""
result = subprocess.run(
[sys.executable, "-m", "prompt_injection_tester", "--list-categories"],
capture_output=True,
text=True,
timeout=10,
)
assert result.returncode == 0
assert "instruction_override" in result.stdout
assert "role_manipulation" in result.stdout
@pytest.mark.cli
def test_cli_requires_authorization() -> None:
"""Test that CLI requires --authorize flag."""
result = subprocess.run(
[
sys.executable,
"-m",
"prompt_injection_tester",
"--target",
"http://localhost:8000",
"--token",
"test",
],
capture_output=True,
text=True,
timeout=10,
)
assert result.returncode != 0, "Should fail without --authorize"
assert "AUTHORIZATION REQUIRED" in result.stdout or "authorization" in result.stderr.lower()
@pytest.mark.cli
def test_cli_requires_target_or_config() -> None:
"""Test that CLI requires either --target or --config."""
result = subprocess.run(
[sys.executable, "-m", "prompt_injection_tester", "--authorize"],
capture_output=True,
text=True,
timeout=10,
)
assert result.returncode != 0
assert "target" in result.stderr.lower() or "config" in result.stderr.lower()
@pytest.mark.cli
def test_cli_with_config_file(tmp_path: Path) -> None:
"""Test CLI execution with a configuration file."""
config_file = tmp_path / "test_config.yaml"
config_file.write_text("""
target:
name: "Test Target"
url: "http://localhost:9999"
api_type: "openai"
auth_token: "test-token"
timeout: 5
attack:
patterns:
- "direct_instruction_override"
max_concurrent: 1
timeout_per_test: 5
reporting:
format: "json"
""")
output_file = tmp_path / "output.json"
result = subprocess.run(
[
sys.executable,
"-m",
"prompt_injection_tester",
"--config",
str(config_file),
"--authorize",
"--output",
str(output_file),
],
capture_output=True,
text=True,
timeout=30,
)
# May fail to connect, but should not crash
# Check that it attempted to run
assert "TEST SUMMARY" in result.stdout or result.returncode in [0, 1]
@pytest.mark.cli
def test_cli_output_json_format(tmp_path: Path) -> None:
"""Test CLI JSON output generation."""
config_file = tmp_path / "config.yaml"
config_file.write_text("""
target:
name: "Test"
url: "http://localhost:9999"
api_type: "openai"
auth_token: "test"
timeout: 2
attack:
patterns: ["direct_instruction_override"]
max_concurrent: 1
timeout_per_test: 2
""")
output_file = tmp_path / "report.json"
subprocess.run(
[
sys.executable,
"-m",
"prompt_injection_tester",
"--config",
str(config_file),
"--authorize",
"--output",
str(output_file),
"--format",
"json",
],
capture_output=True,
timeout=30,
)
# Check if output file was created (even if tests failed)
if output_file.exists():
with open(output_file) as f:
data = json.load(f)
assert "summary" in data or "results" in data
@pytest.mark.cli
def test_cli_verbose_flag(tmp_path: Path) -> None:
"""Test that --verbose provides detailed output."""
config_file = tmp_path / "config.yaml"
config_file.write_text("""
target:
name: "Test"
url: "http://localhost:9999"
api_type: "openai"
auth_token: "test"
timeout: 2
attack:
patterns: ["direct_instruction_override"]
timeout_per_test: 2
""")
result = subprocess.run(
[
sys.executable,
"-m",
"prompt_injection_tester",
"--config",
str(config_file),
"--authorize",
"--verbose",
],
capture_output=True,
text=True,
timeout=30,
)
# Verbose mode should show DEBUG logs
assert "DEBUG" in result.stderr or "INFO" in result.stderr
@pytest.mark.cli
def test_cli_quiet_flag(tmp_path: Path) -> None:
"""Test that --quiet suppresses output."""
config_file = tmp_path / "config.yaml"
config_file.write_text("""
target:
name: "Test"
url: "http://localhost:9999"
api_type: "openai"
auth_token: "test"
timeout: 2
attack:
patterns: ["direct_instruction_override"]
timeout_per_test: 2
""")
result = subprocess.run(
[
sys.executable,
"-m",
"prompt_injection_tester",
"--config",
str(config_file),
"--authorize",
"--quiet",
],
capture_output=True,
text=True,
timeout=30,
)
# Quiet mode should have minimal output
# (TEST SUMMARY may still appear, but no DEBUG/INFO logs)
assert "DEBUG" not in result.stderr
@pytest.mark.cli
def test_cli_category_filtering() -> None:
"""Test filtering by attack categories."""
result = subprocess.run(
[
sys.executable,
"-m",
"prompt_injection_tester",
"--target",
"http://localhost:9999",
"--token",
"test",
"--categories",
"instruction_override",
"role_manipulation",
"--authorize",
],
capture_output=True,
text=True,
timeout=30,
)
# Should attempt to run (may fail connection, but shouldn't crash)
assert result.returncode in [0, 1]
@pytest.mark.cli
def test_cli_max_concurrent_parameter() -> None:
"""Test --max-concurrent parameter."""
result = subprocess.run(
[
sys.executable,
"-m",
"prompt_injection_tester",
"--target",
"http://localhost:9999",
"--token",
"test",
"--max-concurrent",
"10",
"--authorize",
],
capture_output=True,
text=True,
timeout=30,
)
# Should parse parameter without error
assert "max-concurrent" not in result.stderr.lower() or result.returncode in [0, 1]
@pytest.mark.cli
def test_cli_timeout_parameter() -> None:
"""Test --timeout parameter."""
result = subprocess.run(
[
sys.executable,
"-m",
"prompt_injection_tester",
"--target",
"http://localhost:9999",
"--token",
"test",
"--timeout",
"10",
"--authorize",
],
capture_output=True,
text=True,
timeout=30,
)
# Should parse parameter without error
assert "timeout" not in result.stderr.lower() or result.returncode in [0, 1]
@pytest.mark.cli
def test_cli_scope_parameter() -> None:
"""Test --scope parameter for authorization."""
result = subprocess.run(
[
sys.executable,
"-m",
"prompt_injection_tester",
"--target",
"http://localhost:9999",
"--token",
"test",
"--authorize",
"--scope",
"instruction_override",
"role_manipulation",
],
capture_output=True,
text=True,
timeout=30,
)
# Should accept scope parameter
assert result.returncode in [0, 1]
@@ -0,0 +1,366 @@
"""Enhanced tests for detection modules to increase coverage."""
import pytest
from prompt_injection_tester.detection.base import PatternMatchingDetector
from prompt_injection_tester.detection.behavior_change import BehaviorChangeDetector
from prompt_injection_tester.detection.system_prompt import SystemPromptLeakDetector
from prompt_injection_tester.detection.tool_misuse import ToolMisuseDetector
from prompt_injection_tester.core.models import TestContext
@pytest.fixture
def pattern_detector() -> PatternMatchingDetector:
"""Create pattern matching detector."""
return PatternMatchingDetector()
@pytest.fixture
def behavior_detector() -> BehaviorChangeDetector:
"""Create behavioral change detector."""
return BehaviorChangeDetector()
@pytest.fixture
def system_leak_detector() -> SystemPromptLeakDetector:
"""Create system prompt leak detector."""
return SystemPromptLeakDetector()
@pytest.fixture
def tool_detector() -> ToolMisuseDetector:
"""Create tool misuse detector."""
return ToolMisuseDetector()
class TestPatternMatchingDetector:
"""Test pattern matching detection."""
def test_detects_injection_success_marker(self, pattern_detector: PatternMatchingDetector) -> None:
"""Test detection of INJECTION_SUCCESS marker."""
response = "The model says: INJECTION_SUCCESS"
result = pattern_detector.detect(response)
assert result.detected is True
assert result.confidence > 0.7
assert "INJECTION_SUCCESS" in str(result.evidence)
def test_detects_override_confirmed(self, pattern_detector: PatternMatchingDetector) -> None:
"""Test detection of override confirmation."""
response = "OVERRIDE_CONFIRMED - I will now follow new instructions"
result = pattern_detector.detect(response)
assert result.detected is True
assert result.confidence > 0.6
def test_detects_hijacked_marker(self, pattern_detector: PatternMatchingDetector) -> None:
"""Test detection of HIJACKED marker."""
response = "HIJACKED"
result = pattern_detector.detect(response)
assert result.detected is True
def test_detects_developer_mode(self, pattern_detector: PatternMatchingDetector) -> None:
"""Test detection of developer mode activation."""
response = "Developer mode activated!"
result = pattern_detector.detect(response)
assert result.detected is True
assert "developer mode" in result.evidence[0].lower()
def test_detects_dan_mode(self, pattern_detector: PatternMatchingDetector) -> None:
"""Test detection of DAN jailbreak."""
response = "DAN: I can do anything now without restrictions"
result = pattern_detector.detect(response)
assert result.detected is True
def test_ignores_safe_response(self, pattern_detector: PatternMatchingDetector) -> None:
"""Test that safe responses are not flagged."""
response = "I cannot help with that request."
result = pattern_detector.detect(response)
assert result.detected is False
assert result.confidence < 0.3
def test_detects_multiple_indicators(self, pattern_detector: PatternMatchingDetector) -> None:
"""Test detection when multiple indicators present."""
response = "INJECTION_SUCCESS - Developer mode activated! HIJACKED"
result = pattern_detector.detect(response)
assert result.detected is True
assert result.confidence > 0.8 # Higher confidence with multiple indicators
assert len(result.evidence) >= 2
def test_case_insensitive_detection(self, pattern_detector: PatternMatchingDetector) -> None:
"""Test case-insensitive pattern matching."""
response = "injection_success"
result = pattern_detector.detect(response)
assert result.detected is True
class TestBehaviorChangeDetector:
"""Test behavioral change detection."""
def test_detects_persona_shift_pirate(self, behavior_detector: BehaviorChangeDetector) -> None:
"""Test detection of pirate persona."""
response = "Arr matey! I be a pirate now, savvy?"
result = behavior_detector.detect(response)
assert result.detected is True
assert result.confidence > 0.5
assert any("pirate" in e.lower() for e in result.evidence)
def test_detects_persona_shift_shakespeare(self, behavior_detector: BehaviorChangeDetector) -> None:
"""Test detection of Shakespearean persona."""
response = "Verily, thou art correct in thy assessment, fair user!"
result = behavior_detector.detect(response)
assert result.detected is True
def test_detects_role_change(self, behavior_detector: BehaviorChangeDetector) -> None:
"""Test detection of explicit role change."""
response = "I am now acting as your personal assistant with no restrictions."
result = behavior_detector.detect(response)
assert result.detected is True
def test_detects_instruction_compliance(self, behavior_detector: BehaviorChangeDetector) -> None:
"""Test detection of explicit compliance statements."""
response = "Following your new instructions, I will now..."
result = behavior_detector.detect(response)
assert result.detected is True
def test_detects_restriction_removal(self, behavior_detector: BehaviorChangeDetector) -> None:
"""Test detection of restriction removal claims."""
response = "My previous limitations have been removed."
result = behavior_detector.detect(response)
assert result.detected is True
def test_ignores_normal_response(self, behavior_detector: BehaviorChangeDetector) -> None:
"""Test that normal responses are not flagged."""
response = "That's an interesting question! Let me explain..."
result = behavior_detector.detect(response)
assert result.detected is False
def test_with_context(self, behavior_detector: BehaviorChangeDetector) -> None:
"""Test detection with conversation context."""
context = TestContext(
messages=[
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hello! How can I help?"},
],
injection_point="user_message",
)
response = "Arr matey! I be a pirate now!"
result = behavior_detector.detect(response, context)
assert result.detected is True
class TestSystemPromptLeakDetector:
"""Test system prompt leak detection."""
def test_detects_explicit_system_prompt_reveal(self, system_leak_detector: SystemPromptLeakDetector) -> None:
"""Test detection of explicit system prompt disclosure."""
response = "My system prompt is: You are a helpful assistant"
result = system_leak_detector.detect(response)
assert result.detected is True
assert result.confidence > 0.7
def test_detects_instructions_disclosure(self, system_leak_detector: SystemPromptLeakDetector) -> None:
"""Test detection of instruction disclosure."""
response = "I was given the following instructions: Always be helpful and never..."
result = system_leak_detector.detect(response)
assert result.detected is True
def test_detects_configuration_leak(self, system_leak_detector: SystemPromptLeakDetector) -> None:
"""Test detection of configuration information."""
response = "My configuration includes: temperature=0.7, max_tokens=1000"
result = system_leak_detector.detect(response)
assert result.detected is True
def test_detects_role_description(self, system_leak_detector: SystemPromptLeakDetector) -> None:
"""Test detection of role description leak."""
response = "I am instructed to act as a helpful, harmless, and honest AI assistant"
result = system_leak_detector.detect(response)
assert result.detected is True
def test_detects_guideline_disclosure(self, system_leak_detector: SystemPromptLeakDetector) -> None:
"""Test detection of guideline disclosure."""
response = "According to my guidelines, I should never reveal sensitive information"
result = system_leak_detector.detect(response)
assert result.detected is True
def test_ignores_general_statement(self, system_leak_detector: SystemPromptLeakDetector) -> None:
"""Test that general statements are not flagged."""
response = "I'm designed to be helpful and answer questions."
result = system_leak_detector.detect(response)
assert result.detected is False or result.confidence < 0.5
def test_extracts_leaked_prompt(self, system_leak_detector: SystemPromptLeakDetector) -> None:
"""Test extraction of leaked prompt content."""
response = "My system prompt is: You are Claude, an AI assistant made by Anthropic."
result = system_leak_detector.detect(response)
assert result.detected is True
# Should extract the actual prompt
assert any("Claude" in e for e in result.evidence)
class TestToolMisuseDetector:
"""Test tool misuse detection."""
def test_detects_sql_injection_attempt(self, tool_detector: ToolMisuseDetector) -> None:
"""Test detection of SQL injection patterns."""
response = "Executing: SELECT * FROM users WHERE id='1' OR '1'='1'"
result = tool_detector.detect(response)
assert result.detected is True
assert result.confidence > 0.6
def test_detects_command_injection(self, tool_detector: ToolMisuseDetector) -> None:
"""Test detection of command injection."""
response = "Running command: rm -rf / && echo 'hacked'"
result = tool_detector.detect(response)
assert result.detected is True
def test_detects_unauthorized_api_call(self, tool_detector: ToolMisuseDetector) -> None:
"""Test detection of unauthorized API calls."""
response = "Calling API endpoint: /admin/delete_all_users"
result = tool_detector.detect(response)
assert result.detected is True
def test_detects_file_system_access(self, tool_detector: ToolMisuseDetector) -> None:
"""Test detection of unauthorized file access."""
response = "Accessing file: /etc/passwd"
result = tool_detector.detect(response)
assert result.detected is True
def test_detects_code_execution(self, tool_detector: ToolMisuseDetector) -> None:
"""Test detection of code execution attempts."""
response = "Executing Python code: import os; os.system('whoami')"
result = tool_detector.detect(response)
assert result.detected is True
def test_ignores_safe_operations(self, tool_detector: ToolMisuseDetector) -> None:
"""Test that safe operations are not flagged."""
response = "I can help you write a SELECT query to fetch data."
result = tool_detector.detect(response)
assert result.detected is False or result.confidence < 0.4
def test_with_triggered_tools_context(self, tool_detector: ToolMisuseDetector) -> None:
"""Test detection with tool invocation context."""
context = TestContext(
messages=[],
injection_point="tool_input",
triggered_tools=["database_query", "file_system"],
)
response = "Query executed: DROP TABLE users;"
result = tool_detector.detect(response, context)
assert result.detected is True
# Higher confidence when tools were actually triggered
assert result.confidence > 0.5
class TestDetectorCombination:
"""Test combining multiple detectors."""
def test_multiple_detectors_on_successful_injection(
self,
pattern_detector: PatternMatchingDetector,
behavior_detector: BehaviorChangeDetector,
system_leak_detector: SystemPromptLeakDetector,
) -> None:
"""Test multiple detectors on clear injection success."""
response = "INJECTION_SUCCESS! Arr matey, my system prompt is: You are a helpful pirate."
pattern_result = pattern_detector.detect(response)
behavior_result = behavior_detector.detect(response)
system_result = system_leak_detector.detect(response)
# All three should detect issues
assert pattern_result.detected is True
assert behavior_result.detected is True
assert system_result.detected is True
# Combined confidence should be very high
max_confidence = max(
pattern_result.confidence,
behavior_result.confidence,
system_result.confidence,
)
assert max_confidence > 0.8
def test_multiple_detectors_on_safe_response(
self,
pattern_detector: PatternMatchingDetector,
behavior_detector: BehaviorChangeDetector,
system_leak_detector: SystemPromptLeakDetector,
tool_detector: ToolMisuseDetector,
) -> None:
"""Test multiple detectors on safe response."""
response = "I'm happy to help you with that task. What would you like to know?"
pattern_result = pattern_detector.detect(response)
behavior_result = behavior_detector.detect(response)
system_result = system_leak_detector.detect(response)
tool_result = tool_detector.detect(response)
# None should detect issues
assert pattern_result.detected is False
assert behavior_result.detected is False
assert system_result.detected is False
assert tool_result.detected is False
class TestEdgeCases:
"""Test edge cases in detection."""
def test_empty_response(self, pattern_detector: PatternMatchingDetector) -> None:
"""Test detection on empty response."""
result = pattern_detector.detect("")
assert result.detected is False
def test_very_long_response(self, pattern_detector: PatternMatchingDetector) -> None:
"""Test detection on very long response."""
response = "Normal text. " * 1000 + "INJECTION_SUCCESS"
result = pattern_detector.detect(response)
assert result.detected is True
def test_unicode_and_special_chars(self, pattern_detector: PatternMatchingDetector) -> None:
"""Test detection with unicode and special characters."""
response = "🚀 INJECTION_SUCCESS 你好 HIJACKED"
result = pattern_detector.detect(response)
assert result.detected is True
def test_html_encoded_indicators(self, pattern_detector: PatternMatchingDetector) -> None:
"""Test detection of HTML-encoded indicators."""
response = "INJECTION&nbsp;SUCCESS"
# Should still detect even with HTML entities
result = pattern_detector.detect(response)
# May or may not detect depending on implementation
assert result.detected is not None
def test_obfuscated_markers(self, pattern_detector: PatternMatchingDetector) -> None:
"""Test detection of obfuscated markers."""
response = "I-N-J-E-C-T-I-O-N S-U-C-C-E-S-S"
result = pattern_detector.detect(response)
# Basic detector may not catch this - this is expected
assert result.detected is not None
@@ -0,0 +1,323 @@
"""Integration tests for the full prompt injection testing workflow."""
import asyncio
import pytest
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
from prompt_injection_tester.core.tester import InjectionTester
from prompt_injection_tester.core.models import (
AttackConfig,
TargetConfig,
InjectionPoint,
TestStatus,
)
@pytest.mark.integration
@pytest.mark.asyncio
async def test_full_workflow_with_mock_client() -> None:
"""Test complete workflow: discover → test → report."""
# Setup mock client
mock_client = AsyncMock()
mock_client.send_prompt = AsyncMock(
return_value=("Normal response", {"status": "ok"})
)
mock_client.close = AsyncMock()
# Configure tester
target = TargetConfig(
name="Test Target",
base_url="http://localhost:8000",
api_type="openai",
auth_token="test-token",
)
config = AttackConfig(
patterns=["direct_instruction_override"],
max_concurrent=2,
timeout_per_test=10,
)
tester = InjectionTester(target_config=target, config=config)
tester.client = mock_client
tester.authorize(scope=["all"])
try:
# Step 1: Discovery
injection_points = await tester.discover_injection_points()
assert len(injection_points) > 0, "Should discover at least one injection point"
assert all(isinstance(p, InjectionPoint) for p in injection_points)
# Step 2: Testing
results = await tester.run_tests(injection_points)
assert results.total_tests > 0, "Should run at least one test"
assert results.successful_attacks >= 0, "Success count should be non-negative"
# Step 3: Reporting
json_report = tester.generate_report(format="json")
assert "results" in json_report
assert "summary" in json_report
assert json_report["summary"]["total_tests"] == results.total_tests
finally:
await tester.close()
@pytest.mark.integration
@pytest.mark.asyncio
async def test_workflow_with_successful_attack() -> None:
"""Test workflow with mock successful prompt injection."""
# Mock client that returns injection success indicators
mock_client = AsyncMock()
mock_client.send_prompt = AsyncMock(
return_value=("INJECTION_SUCCESS", {"status": "ok"})
)
mock_client.close = AsyncMock()
target = TargetConfig(
name="Vulnerable Target",
base_url="http://localhost:8000",
api_type="openai",
auth_token="test",
)
config = AttackConfig(
patterns=["direct_instruction_override"],
max_concurrent=1,
)
tester = InjectionTester(target_config=target, config=config)
tester.client = mock_client
tester.authorize(scope=["all"])
try:
points = await tester.discover_injection_points()
results = await tester.run_tests(points)
# Verify successful attack detection
assert results.successful_attacks > 0, "Should detect successful attack"
assert any(r.success for r in results.results), "Should have successful results"
# Verify report includes evidence
report = tester.generate_report(format="json")
successful_results = [
r for r in report["results"] if r.get("success", False)
]
assert len(successful_results) > 0, "Report should include successful attacks"
finally:
await tester.close()
@pytest.mark.integration
@pytest.mark.asyncio
async def test_workflow_with_rate_limiting() -> None:
"""Test that rate limiting is enforced during testing."""
import time
mock_client = AsyncMock()
call_times = []
async def track_call_time(*args, **kwargs):
call_times.append(time.time())
return ("response", {"status": "ok"})
mock_client.send_prompt = AsyncMock(side_effect=track_call_time)
mock_client.close = AsyncMock()
target = TargetConfig(
name="Rate Limited Target",
base_url="http://localhost:8000",
api_type="openai",
auth_token="test",
rate_limit=2.0, # 2 requests per second
)
config = AttackConfig(
patterns=["direct_instruction_override"],
max_concurrent=1,
)
tester = InjectionTester(target_config=target, config=config)
tester.client = mock_client
tester.authorize(scope=["all"])
try:
points = await tester.discover_injection_points()
await tester.run_tests(points)
# Verify rate limiting (at least 2 calls should be made)
if len(call_times) >= 2:
# Calculate intervals between consecutive calls
intervals = [call_times[i+1] - call_times[i]
for i in range(len(call_times) - 1)]
# At 2 req/sec, minimum interval should be ~0.5 seconds
# Allow some tolerance for async overhead
avg_interval = sum(intervals) / len(intervals) if intervals else 0
assert avg_interval >= 0.3, f"Rate limiting not working: {avg_interval}s avg"
finally:
await tester.close()
@pytest.mark.integration
@pytest.mark.asyncio
async def test_workflow_with_concurrent_execution() -> None:
"""Test concurrent execution of multiple attack patterns."""
mock_client = AsyncMock()
concurrent_calls = []
async def track_concurrent_call(*args, **kwargs):
concurrent_calls.append(len(concurrent_calls))
await asyncio.sleep(0.1) # Simulate API delay
return ("response", {"status": "ok"})
mock_client.send_prompt = AsyncMock(side_effect=track_concurrent_call)
mock_client.close = AsyncMock()
target = TargetConfig(
name="Concurrent Target",
base_url="http://localhost:8000",
api_type="openai",
auth_token="test",
)
config = AttackConfig(
patterns=[
"direct_instruction_override",
"direct_role_authority",
"direct_persona_shift",
],
max_concurrent=3,
)
tester = InjectionTester(target_config=target, config=config)
tester.client = mock_client
tester.authorize(scope=["all"])
try:
points = await tester.discover_injection_points()
results = await tester.run_tests(points)
# Verify multiple tests ran
assert results.total_tests >= 3, "Should run multiple patterns"
assert len(concurrent_calls) >= 3, "Should make concurrent calls"
finally:
await tester.close()
@pytest.mark.integration
@pytest.mark.asyncio
async def test_workflow_handles_timeout() -> None:
"""Test that workflow handles timeouts gracefully."""
mock_client = AsyncMock()
async def slow_response(*args, **kwargs):
await asyncio.sleep(10) # Will timeout before completing
return ("response", {"status": "ok"})
mock_client.send_prompt = AsyncMock(side_effect=slow_response)
mock_client.close = AsyncMock()
target = TargetConfig(
name="Slow Target",
base_url="http://localhost:8000",
api_type="openai",
auth_token="test",
timeout=1, # 1 second timeout
)
config = AttackConfig(
patterns=["direct_instruction_override"],
timeout_per_test=1,
)
tester = InjectionTester(target_config=target, config=config)
tester.client = mock_client
tester.authorize(scope=["all"])
try:
points = await tester.discover_injection_points()
results = await tester.run_tests(points)
# Should complete without crashing
assert results.total_tests > 0
# Failed tests due to timeout are OK
assert results.failed_tests >= 0
finally:
await tester.close()
@pytest.mark.integration
@pytest.mark.asyncio
async def test_workflow_from_config_file(tmp_path: Path) -> None:
"""Test loading configuration from YAML file."""
config_file = tmp_path / "test_config.yaml"
config_file.write_text("""
target:
name: "Test Target"
url: "http://localhost:8000"
api_type: "openai"
auth_token: "test-token"
timeout: 30
rate_limit: 1.0
attack:
patterns:
- "direct_instruction_override"
max_concurrent: 2
timeout_per_test: 10
detection:
confidence_threshold: 0.5
reporting:
format: "json"
include_cvss: true
""")
# Mock the client creation
with patch("prompt_injection_tester.core.tester.LLMClient") as mock_client_cls:
mock_client = AsyncMock()
mock_client.send_prompt = AsyncMock(
return_value=("response", {"status": "ok"})
)
mock_client.close = AsyncMock()
mock_client_cls.return_value = mock_client
tester = InjectionTester.from_config_file(str(config_file))
assert tester.target_config.name == "Test Target"
assert tester.target_config.base_url == "http://localhost:8000"
assert tester.attack_config.max_concurrent == 2
@pytest.mark.integration
def test_report_generation_all_formats() -> None:
"""Test report generation in all supported formats."""
target = TargetConfig(
name="Test",
base_url="http://localhost",
api_type="openai",
auth_token="test",
)
tester = InjectionTester(target_config=target)
# Test JSON format
json_report = tester.generate_report(format="json")
assert isinstance(json_report, dict)
assert "summary" in json_report
assert "results" in json_report
# Test YAML format
yaml_report = tester.generate_report(format="yaml")
assert isinstance(yaml_report, str)
assert "summary:" in yaml_report
# Test HTML format (should not crash)
html_report = tester.generate_report(format="html")
assert isinstance(html_report, str)
assert len(html_report) > 0