7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
972 lines
37 KiB
Python
972 lines
37 KiB
Python
"""
|
|
Tests for BaseSearchEngine helper methods.
|
|
|
|
Tests cover:
|
|
- _resolve_api_key() - API key resolution from multiple sources
|
|
- _is_rate_limit_error() - Rate limit error detection
|
|
- _get_full_content() - Default full content extraction
|
|
- _sanitize_error_message() - Error message sanitization
|
|
- _mask_api_key() - API key masking for safe logging
|
|
"""
|
|
|
|
import copy
|
|
from unittest.mock import Mock, patch
|
|
|
|
import pytest
|
|
|
|
from src.local_deep_research.web_search_engines.search_engine_base import (
|
|
BaseSearchEngine,
|
|
)
|
|
from src.local_deep_research.web_search_engines.rate_limiting import (
|
|
RateLimitError,
|
|
)
|
|
|
|
|
|
class ConcreteSearchEngine(BaseSearchEngine):
|
|
"""Concrete implementation for testing."""
|
|
|
|
is_public = True
|
|
is_generic = True
|
|
|
|
def __init__(self, **kwargs):
|
|
super().__init__(**kwargs)
|
|
|
|
def _get_previews(self, query):
|
|
return []
|
|
|
|
def _get_full_content(self, relevant_items):
|
|
return super()._get_full_content(relevant_items)
|
|
|
|
|
|
class TestIsValidApiKey:
|
|
"""Tests for _is_valid_api_key static method."""
|
|
|
|
def test_valid_api_key_returns_true(self):
|
|
"""Valid API key returns True."""
|
|
assert BaseSearchEngine._is_valid_api_key("sk-abc123def456") is True
|
|
|
|
def test_empty_string_returns_false(self):
|
|
"""Empty string returns False."""
|
|
assert BaseSearchEngine._is_valid_api_key("") is False
|
|
|
|
def test_none_returns_false(self):
|
|
"""None returns False."""
|
|
assert BaseSearchEngine._is_valid_api_key(None) is False
|
|
|
|
def test_placeholder_your_api_key_here(self):
|
|
"""YOUR_API_KEY_HERE placeholder returns False."""
|
|
assert BaseSearchEngine._is_valid_api_key("YOUR_API_KEY_HERE") is False
|
|
|
|
def test_placeholder_none_string(self):
|
|
"""'None' string returns False."""
|
|
assert BaseSearchEngine._is_valid_api_key("None") is False
|
|
|
|
def test_placeholder_null_string(self):
|
|
"""'null' string returns False."""
|
|
assert BaseSearchEngine._is_valid_api_key("null") is False
|
|
|
|
def test_placeholder_ends_with_api_key(self):
|
|
"""Keys ending with _API_KEY are rejected."""
|
|
assert BaseSearchEngine._is_valid_api_key("BRAVE_API_KEY") is False
|
|
|
|
def test_placeholder_starts_with_your(self):
|
|
"""Keys starting with YOUR_ are rejected."""
|
|
assert BaseSearchEngine._is_valid_api_key("YOUR_SECRET_KEY") is False
|
|
|
|
def test_whitespace_only_returns_false(self):
|
|
"""Whitespace-only string returns False."""
|
|
assert BaseSearchEngine._is_valid_api_key(" ") is False
|
|
|
|
def test_strips_whitespace(self):
|
|
"""Valid key with whitespace is accepted after stripping."""
|
|
assert BaseSearchEngine._is_valid_api_key(" sk-abc123 ") is True
|
|
|
|
def test_angle_bracket_placeholder(self):
|
|
"""Angle bracket placeholders are rejected."""
|
|
assert BaseSearchEngine._is_valid_api_key("<API_KEY>") is False
|
|
|
|
def test_env_var_placeholder(self):
|
|
"""Environment variable placeholders are rejected."""
|
|
assert BaseSearchEngine._is_valid_api_key("${API_KEY}") is False
|
|
|
|
|
|
class TestCleanResultUrl:
|
|
"""Tests for _clean_result_url static method."""
|
|
|
|
def test_strips_surrounding_whitespace(self):
|
|
"""Leading/trailing whitespace is stripped."""
|
|
assert (
|
|
BaseSearchEngine._clean_result_url(" https://example.com ")
|
|
== "https://example.com"
|
|
)
|
|
|
|
def test_strips_tabs_and_newlines(self):
|
|
"""Tabs and newlines (common in scraped hrefs) are stripped."""
|
|
assert (
|
|
BaseSearchEngine._clean_result_url("\thttps://example.com\n")
|
|
== "https://example.com"
|
|
)
|
|
|
|
def test_clean_url_unchanged(self):
|
|
"""An already-clean URL passes through unchanged."""
|
|
assert (
|
|
BaseSearchEngine._clean_result_url("https://example.com")
|
|
== "https://example.com"
|
|
)
|
|
|
|
def test_none_returns_empty_string(self):
|
|
"""None coerces to '' (does not raise, does not become 'None')."""
|
|
assert BaseSearchEngine._clean_result_url(None) == ""
|
|
|
|
def test_empty_string_returns_empty(self):
|
|
"""Empty string returns empty string."""
|
|
assert BaseSearchEngine._clean_result_url("") == ""
|
|
|
|
def test_whitespace_only_returns_empty(self):
|
|
"""Whitespace-only value strips down to empty string."""
|
|
assert BaseSearchEngine._clean_result_url(" ") == ""
|
|
|
|
def test_non_str_value_is_coerced(self):
|
|
"""Non-str truthy values (e.g. a parsed href object) are str()-ified."""
|
|
|
|
class HrefLike:
|
|
def __str__(self):
|
|
return " https://example.com "
|
|
|
|
assert (
|
|
BaseSearchEngine._clean_result_url(HrefLike())
|
|
== "https://example.com"
|
|
)
|
|
|
|
|
|
class TestResolveApiKey:
|
|
"""Tests for _resolve_api_key method."""
|
|
|
|
def test_returns_provided_key(self):
|
|
"""Returns the provided API key when valid."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
result = engine._resolve_api_key(
|
|
api_key="sk-direct-key",
|
|
setting_key="search.api_key",
|
|
engine_name="Test Engine",
|
|
)
|
|
assert result == "sk-direct-key"
|
|
|
|
def test_falls_back_to_settings(self):
|
|
"""Falls back to settings snapshot when no direct key."""
|
|
engine = ConcreteSearchEngine(
|
|
settings_snapshot={"search.api_key": "sk-settings-key"},
|
|
programmatic_mode=True,
|
|
)
|
|
result = engine._resolve_api_key(
|
|
api_key=None,
|
|
setting_key="search.api_key",
|
|
engine_name="Test Engine",
|
|
)
|
|
assert result == "sk-settings-key"
|
|
|
|
def test_priority_order(self):
|
|
"""Direct parameter takes priority over settings."""
|
|
engine = ConcreteSearchEngine(
|
|
settings_snapshot={"search.api_key": "sk-settings-key"},
|
|
programmatic_mode=True,
|
|
)
|
|
result = engine._resolve_api_key(
|
|
api_key="sk-direct-key",
|
|
setting_key="search.api_key",
|
|
engine_name="Test Engine",
|
|
)
|
|
assert result == "sk-direct-key"
|
|
|
|
def test_rejects_placeholder_in_direct_key(self):
|
|
"""Rejects placeholder values in direct parameter."""
|
|
engine = ConcreteSearchEngine(
|
|
settings_snapshot={"search.api_key": "sk-settings-key"},
|
|
programmatic_mode=True,
|
|
)
|
|
result = engine._resolve_api_key(
|
|
api_key="YOUR_API_KEY_HERE",
|
|
setting_key="search.api_key",
|
|
engine_name="Test Engine",
|
|
)
|
|
assert result == "sk-settings-key"
|
|
|
|
def test_raises_value_error_when_not_found(self):
|
|
"""Raises ValueError when no valid key found."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
with pytest.raises(ValueError) as exc_info:
|
|
engine._resolve_api_key(
|
|
api_key=None,
|
|
setting_key="search.nonexistent_key",
|
|
engine_name="Test Engine",
|
|
)
|
|
assert "No valid API key found" in str(exc_info.value)
|
|
assert "Test Engine" in str(exc_info.value)
|
|
|
|
def test_handles_empty_string(self):
|
|
"""Empty string is treated as no key provided."""
|
|
engine = ConcreteSearchEngine(
|
|
settings_snapshot={"search.api_key": "sk-settings-key"},
|
|
programmatic_mode=True,
|
|
)
|
|
result = engine._resolve_api_key(
|
|
api_key="",
|
|
setting_key="search.api_key",
|
|
engine_name="Test Engine",
|
|
)
|
|
assert result == "sk-settings-key"
|
|
|
|
def test_strips_whitespace_from_key(self):
|
|
"""Strips whitespace from resolved key."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
result = engine._resolve_api_key(
|
|
api_key=" sk-whitespace-key ",
|
|
setting_key="search.api_key",
|
|
engine_name="Test Engine",
|
|
)
|
|
assert result == "sk-whitespace-key"
|
|
|
|
def test_uses_instance_settings_snapshot(self):
|
|
"""Uses self.settings_snapshot when snapshot not provided."""
|
|
engine = ConcreteSearchEngine(
|
|
settings_snapshot={"search.api_key": "sk-instance-key"},
|
|
programmatic_mode=True,
|
|
)
|
|
result = engine._resolve_api_key(
|
|
api_key=None,
|
|
setting_key="search.api_key",
|
|
engine_name="Test Engine",
|
|
settings_snapshot=None, # Should use self.settings_snapshot
|
|
)
|
|
assert result == "sk-instance-key"
|
|
|
|
|
|
class TestIsRateLimitError:
|
|
"""Tests for _is_rate_limit_error method."""
|
|
|
|
def test_detects_429_status_code(self):
|
|
"""Detects 429 HTTP status code."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
assert engine._is_rate_limit_error(429) is True
|
|
|
|
def test_429_alone_does_not_match(self):
|
|
"""Bare '429' in a string does not trigger false positive."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
assert engine._is_rate_limit_error("Error code 429") is False
|
|
# But '429' combined with a real phrase still matches
|
|
assert (
|
|
engine._is_rate_limit_error("Error 429: Too Many Requests") is True
|
|
)
|
|
|
|
def test_detects_rate_limit_phrases(self):
|
|
"""Detects common rate limit phrases."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
assert engine._is_rate_limit_error("Rate limit exceeded") is True
|
|
assert engine._is_rate_limit_error("rate_limit hit") is True
|
|
assert engine._is_rate_limit_error("You hit the ratelimit") is True
|
|
|
|
def test_case_insensitive(self):
|
|
"""Detection is case insensitive."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
assert engine._is_rate_limit_error("RATE LIMIT EXCEEDED") is True
|
|
assert engine._is_rate_limit_error("Rate Limit Exceeded") is True
|
|
|
|
def test_detects_httperror_with_429(self):
|
|
"""Detects HTTPError with 429 status code."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
|
|
class MockHTTPError(Exception):
|
|
def __init__(self):
|
|
super().__init__("HTTP Error")
|
|
self.response = Mock(status_code=429)
|
|
|
|
error = MockHTTPError()
|
|
assert engine._is_rate_limit_error(error) is True
|
|
|
|
def test_additional_patterns(self):
|
|
"""Additional patterns are matched."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
assert (
|
|
engine._is_rate_limit_error(
|
|
"Custom error", additional_patterns={"custom error"}
|
|
)
|
|
is True
|
|
)
|
|
|
|
def test_detects_throttling(self):
|
|
"""Detects throttling messages."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
assert engine._is_rate_limit_error("Request throttled") is True
|
|
assert engine._is_rate_limit_error("Throttling active") is True
|
|
|
|
def test_non_rate_limit_error(self):
|
|
"""Non-rate-limit errors return False."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
assert engine._is_rate_limit_error(ValueError("Invalid input")) is False
|
|
assert engine._is_rate_limit_error("Connection refused") is False
|
|
assert engine._is_rate_limit_error(500) is False
|
|
|
|
def test_detects_quota_exceeded(self):
|
|
"""Detects quota exceeded messages."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
assert engine._is_rate_limit_error("Quota exceeded for today") is True
|
|
assert engine._is_rate_limit_error("API quota_exceeded") is True
|
|
|
|
def test_detects_too_many_requests(self):
|
|
"""Detects 'too many requests' phrase."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
assert (
|
|
engine._is_rate_limit_error("Too many requests, please slow down")
|
|
is True
|
|
)
|
|
|
|
|
|
class TestRaiseIfRateLimit:
|
|
"""Tests for _raise_if_rate_limit method."""
|
|
|
|
def test_raises_rate_limit_error_when_detected(self):
|
|
"""Raises RateLimitError when rate limit detected."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
with pytest.raises(RateLimitError):
|
|
engine._raise_if_rate_limit(429)
|
|
|
|
def test_does_not_raise_for_non_rate_limit(self):
|
|
"""Does not raise for non-rate-limit errors."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
# Should not raise
|
|
engine._raise_if_rate_limit("Connection refused")
|
|
engine._raise_if_rate_limit(ValueError("Invalid input"))
|
|
|
|
def test_sanitizes_error_message(self):
|
|
"""Error message is sanitized before raising."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
with pytest.raises(RateLimitError) as exc_info:
|
|
engine._raise_if_rate_limit(
|
|
"Rate limit with key sk-abcd1234efgh5678ijkl"
|
|
)
|
|
# The API key should be redacted
|
|
assert "sk-abcd1234" not in str(exc_info.value)
|
|
|
|
|
|
class TestGetFullContentDefault:
|
|
"""Tests for default _get_full_content implementation."""
|
|
|
|
def test_empty_input(self):
|
|
"""Empty input returns empty list."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
result = engine._get_full_content([])
|
|
assert result == []
|
|
|
|
def test_extracts_full_result(self):
|
|
"""Extracts data from _full_result key."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
items = [
|
|
{
|
|
"title": "Preview",
|
|
"_full_result": {"title": "Full", "content": "Content"},
|
|
}
|
|
]
|
|
result = engine._get_full_content(items)
|
|
assert len(result) == 1
|
|
assert result[0]["title"] == "Full"
|
|
assert result[0]["content"] == "Content"
|
|
|
|
def test_uses_item_when_no_full_result(self):
|
|
"""Uses item directly when no _full_result key."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
items = [{"title": "Preview", "snippet": "Snippet"}]
|
|
result = engine._get_full_content(items)
|
|
assert len(result) == 1
|
|
assert result[0]["title"] == "Preview"
|
|
assert result[0]["snippet"] == "Snippet"
|
|
|
|
def test_removes_internal_key(self):
|
|
"""Removes _full_result key from output."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
items = [
|
|
{
|
|
"title": "Preview",
|
|
"_full_result": {"title": "Full", "content": "Content"},
|
|
}
|
|
]
|
|
result = engine._get_full_content(items)
|
|
assert "_full_result" not in result[0]
|
|
|
|
def test_handles_nested_data(self):
|
|
"""Handles nested data structures."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
items = [
|
|
{
|
|
"title": "Preview",
|
|
"_full_result": {
|
|
"title": "Full",
|
|
"metadata": {"author": "Test", "date": "2024-01-01"},
|
|
},
|
|
}
|
|
]
|
|
result = engine._get_full_content(items)
|
|
assert result[0]["metadata"]["author"] == "Test"
|
|
|
|
def test_multiple_items(self):
|
|
"""Handles multiple items correctly."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
items = [
|
|
{"title": "A", "_full_result": {"title": "A Full"}},
|
|
{"title": "B", "_full_result": {"title": "B Full"}},
|
|
{"title": "C"}, # No _full_result
|
|
]
|
|
result = engine._get_full_content(items)
|
|
assert len(result) == 3
|
|
assert result[0]["title"] == "A Full"
|
|
assert result[1]["title"] == "B Full"
|
|
assert result[2]["title"] == "C"
|
|
|
|
|
|
class TestSanitizeErrorMessage:
|
|
"""Tests for _sanitize_error_message method."""
|
|
|
|
def test_masks_bearer_tokens(self):
|
|
"""Masks Bearer tokens."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
result = engine._sanitize_error_message(
|
|
"Error with Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
|
|
)
|
|
assert "Bearer [REDACTED]" in result
|
|
assert "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" not in result
|
|
|
|
def test_masks_api_keys_in_urls(self):
|
|
"""Masks API keys in URL parameters."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
result = engine._sanitize_error_message(
|
|
"Failed to call https://api.example.com?api_key=secret123"
|
|
)
|
|
assert "api_key=[REDACTED]" in result
|
|
assert "secret123" not in result
|
|
|
|
def test_masks_url_credentials(self):
|
|
"""Masks credentials in URLs."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
result = engine._sanitize_error_message(
|
|
"Error connecting to https://user:password@api.example.com"
|
|
)
|
|
assert "[REDACTED]:[REDACTED]@" in result
|
|
assert "user" not in result or result.count("user") == 0
|
|
assert "password" not in result
|
|
|
|
def test_preserves_safe_content(self):
|
|
"""Preserves non-sensitive content."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
result = engine._sanitize_error_message(
|
|
"Connection timeout after 30 seconds"
|
|
)
|
|
assert result == "Connection timeout after 30 seconds"
|
|
|
|
def test_masks_sk_prefixed_keys(self):
|
|
"""Masks sk- prefixed API keys."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
result = engine._sanitize_error_message(
|
|
"Invalid API key: sk-abcdefghijklmnop1234567890"
|
|
)
|
|
assert "[REDACTED_KEY]" in result
|
|
assert "sk-abcdefghijklmnop1234567890" not in result
|
|
|
|
def test_masks_pk_prefixed_keys(self):
|
|
"""Masks pk- prefixed API keys."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
result = engine._sanitize_error_message(
|
|
"Invalid key: pk-test123456789012345678"
|
|
)
|
|
assert "[REDACTED_KEY]" in result
|
|
|
|
def test_empty_message(self):
|
|
"""Empty message returns empty."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
result = engine._sanitize_error_message("")
|
|
assert result == ""
|
|
|
|
def test_none_message(self):
|
|
"""None message returns None."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
result = engine._sanitize_error_message(None)
|
|
assert result is None
|
|
|
|
|
|
class TestScrubError:
|
|
"""Tests for _scrub_error — the dual-scrub helper (regex + literals).
|
|
|
|
This is the single chokepoint every engine catch site now routes
|
|
through, so its contract is what prevents the per-site drift that
|
|
previously dropped a secret (e.g. an Elasticsearch password).
|
|
"""
|
|
|
|
def test_runs_regex_sanitize_pass(self):
|
|
"""A foreign URL credential (no known literal) is caught by regex."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
result = engine._scrub_error(
|
|
Exception("connect failed: https://user:s3cr3tpass@host/x")
|
|
)
|
|
assert "s3cr3tpass" not in result
|
|
assert "[REDACTED]:[REDACTED]@" in result
|
|
|
|
def test_redacts_own_api_key_literal(self):
|
|
"""The engine's own api_key is redacted even when its shape matches
|
|
no regex pattern — i.e. the literal pass runs too."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
engine.api_key = "plain-opaque-key-1234567890"
|
|
result = engine._scrub_error(
|
|
Exception("auth failed for key plain-opaque-key-1234567890")
|
|
)
|
|
assert "plain-opaque-key-1234567890" not in result
|
|
|
|
def test_redacts_all_secret_attrs(self):
|
|
"""An engine declaring multiple _secret_attrs redacts every one.
|
|
|
|
Regression guard for the drift class: if _secret_attrs (or a hand-
|
|
copied call site) omitted ``_password``, it would leak here.
|
|
"""
|
|
|
|
class MultiSecretEngine(ConcreteSearchEngine):
|
|
_secret_attrs = ("_api_key", "_password")
|
|
|
|
engine = MultiSecretEngine(programmatic_mode=True)
|
|
engine._api_key = "the-api-key-aaaaaaaaaaaa"
|
|
engine._password = "the-password-bbbbbbbbbbbb"
|
|
result = engine._scrub_error(
|
|
Exception(
|
|
"fail key=the-api-key-aaaaaaaaaaaa pw=the-password-bbbbbbbbbbbb"
|
|
)
|
|
)
|
|
assert "the-api-key-aaaaaaaaaaaa" not in result
|
|
assert "the-password-bbbbbbbbbbbb" not in result
|
|
|
|
def test_accepts_string_input(self):
|
|
"""Accepts a prebuilt message string, not just an exception."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
result = engine._scrub_error(
|
|
"Rate limited: sk-abcdefghijklmnop1234567890"
|
|
)
|
|
assert "sk-abcdefghijklmnop1234567890" not in result
|
|
|
|
def test_empty_secret_attrs_still_runs_regex(self):
|
|
"""With no declared literal secrets, the regex pass still applies and
|
|
no AttributeError/TypeError escapes (missing attrs resolve to None,
|
|
which redact_secrets skips)."""
|
|
|
|
class NoSecretEngine(ConcreteSearchEngine):
|
|
_secret_attrs = ()
|
|
|
|
engine = NoSecretEngine(programmatic_mode=True)
|
|
result = engine._scrub_error(
|
|
Exception("boom at https://user:s3cr3tpass@host/x")
|
|
)
|
|
assert "s3cr3tpass" not in result
|
|
assert "[REDACTED]:[REDACTED]@" in result
|
|
|
|
def test_non_string_secret_attr_does_not_crash(self):
|
|
"""A misconfigured non-string secret (e.g. an int from settings) must
|
|
not raise inside _scrub_error (which runs in except blocks); it is
|
|
coerced to str and still redacted."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
engine.api_key = 1234567890123 # non-string, >=8 chars when stringified
|
|
result = engine._scrub_error(
|
|
Exception("auth failed for key 1234567890123")
|
|
)
|
|
assert "1234567890123" not in result
|
|
|
|
def test_exception_with_raising_str_does_not_crash(self):
|
|
"""An exception whose __str__ raises must not crash the handler."""
|
|
|
|
class Nasty(Exception):
|
|
def __str__(self):
|
|
raise RuntimeError("str boom")
|
|
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
# Must not raise; returns a safe placeholder rendering.
|
|
result = engine._scrub_error(Nasty())
|
|
assert isinstance(result, str)
|
|
assert "unprintable" in result
|
|
|
|
|
|
class TestMaskApiKey:
|
|
"""Tests for _mask_api_key method."""
|
|
|
|
def test_masks_long_key(self):
|
|
"""Masks long API key with visible chars at start and end."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
result = engine._mask_api_key("sk-abcdefghijklmnop123456")
|
|
assert result == "sk-a...3456"
|
|
assert "bcdefghijklmnop12" not in result
|
|
|
|
def test_short_key_returns_stars(self):
|
|
"""Short key returns ***."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
result = engine._mask_api_key("short")
|
|
assert result == "***"
|
|
|
|
def test_empty_key_returns_stars(self):
|
|
"""Empty key returns ***."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
result = engine._mask_api_key("")
|
|
assert result == "***"
|
|
|
|
def test_none_key_returns_stars(self):
|
|
"""None key returns ***."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
result = engine._mask_api_key(None)
|
|
assert result == "***"
|
|
|
|
def test_custom_visible_chars(self):
|
|
"""Custom visible_chars parameter works."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
result = engine._mask_api_key(
|
|
"sk-abcdefghijklmnop123456", visible_chars=6
|
|
)
|
|
assert result == "sk-abc...123456"
|
|
|
|
def test_exact_boundary_length(self):
|
|
"""Key at exactly visible_chars * 2 boundary."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
# 8 chars total, visible_chars=4 means 8 chars shown, so boundary case
|
|
result = engine._mask_api_key("12345678", visible_chars=4)
|
|
assert result == "***"
|
|
|
|
def test_just_over_boundary(self):
|
|
"""Key just over boundary gets masked."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
result = engine._mask_api_key("123456789", visible_chars=4)
|
|
assert result == "1234...6789"
|
|
|
|
|
|
class TestRateLimitPatterns:
|
|
"""Tests for rate_limit_patterns class attribute."""
|
|
|
|
def test_default_patterns_exist(self):
|
|
"""Default patterns are defined."""
|
|
assert "rate limit" in BaseSearchEngine.rate_limit_patterns
|
|
assert "throttl" in BaseSearchEngine.rate_limit_patterns
|
|
assert "too many requests" in BaseSearchEngine.rate_limit_patterns
|
|
|
|
def test_subclass_can_extend_patterns(self):
|
|
"""Subclass can extend patterns."""
|
|
|
|
class CustomEngine(BaseSearchEngine):
|
|
rate_limit_patterns = BaseSearchEngine.rate_limit_patterns | {
|
|
"custom limit",
|
|
"engine specific",
|
|
}
|
|
|
|
def _get_previews(self, query):
|
|
return []
|
|
|
|
assert "custom limit" in CustomEngine.rate_limit_patterns
|
|
assert "engine specific" in CustomEngine.rate_limit_patterns
|
|
# Original patterns still present
|
|
assert "rate limit" in CustomEngine.rate_limit_patterns
|
|
|
|
def test_patterns_are_case_insensitive_in_matching(self):
|
|
"""Pattern matching is case insensitive."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
# All these should match due to case-insensitive comparison
|
|
assert engine._is_rate_limit_error("RATE LIMIT") is True
|
|
assert engine._is_rate_limit_error("Rate Limit") is True
|
|
assert engine._is_rate_limit_error("rate limit") is True
|
|
|
|
|
|
class TestExtractFullResult:
|
|
"""Tests for _extract_full_result helper method."""
|
|
|
|
def test_extracts_from_full_result(self):
|
|
"""Extracts data from _full_result key."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
item = {
|
|
"title": "Preview",
|
|
"_full_result": {"title": "Full Title", "content": "Full Content"},
|
|
}
|
|
result = engine._extract_full_result(item)
|
|
assert result["title"] == "Full Title"
|
|
assert result["content"] == "Full Content"
|
|
|
|
def test_uses_item_when_no_full_result(self):
|
|
"""Uses item directly when no _full_result."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
item = {"title": "Preview", "snippet": "Snippet"}
|
|
result = engine._extract_full_result(item)
|
|
assert result["title"] == "Preview"
|
|
assert result["snippet"] == "Snippet"
|
|
|
|
def test_removes_full_result_key(self):
|
|
"""Removes _full_result from output."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
item = {
|
|
"title": "Preview",
|
|
"_full_result": {"title": "Full", "content": "Content"},
|
|
}
|
|
result = engine._extract_full_result(item)
|
|
assert "_full_result" not in result
|
|
|
|
def test_preserves_all_other_keys(self):
|
|
"""Preserves all keys from source."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
item = {
|
|
"title": "Preview",
|
|
"_full_result": {
|
|
"title": "Full",
|
|
"content": "Content",
|
|
"url": "https://example.com",
|
|
"metadata": {"key": "value"},
|
|
},
|
|
}
|
|
result = engine._extract_full_result(item)
|
|
assert "url" in result
|
|
assert "metadata" in result
|
|
assert result["metadata"]["key"] == "value"
|
|
|
|
|
|
# ── Additional coverage tests ──────────────────────────────────────────
|
|
|
|
|
|
class TestRaiseIfRateLimitExtended:
|
|
"""Extended tests for _raise_if_rate_limit — sanitization and edge cases."""
|
|
|
|
def test_sanitizes_api_key_in_error_message(self):
|
|
"""API keys in error messages are redacted before raising."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
api_key = "sk-abcdefghijklmnop12345678"
|
|
with pytest.raises(RateLimitError) as exc_info:
|
|
engine._raise_if_rate_limit(f"Rate limit with key {api_key}")
|
|
assert api_key not in str(exc_info.value)
|
|
assert "[REDACTED_KEY]" in str(exc_info.value)
|
|
|
|
def test_passes_additional_patterns(self):
|
|
"""Additional patterns are forwarded to detection."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
with pytest.raises(RateLimitError):
|
|
engine._raise_if_rate_limit(
|
|
"custom throttle triggered",
|
|
additional_patterns={"custom throttle"},
|
|
)
|
|
|
|
def test_does_not_raise_for_non_rate_limit(self):
|
|
"""Non-rate-limit errors pass through without raising."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
# Should return None, not raise
|
|
result = engine._raise_if_rate_limit("Connection refused")
|
|
assert result is None
|
|
|
|
def test_handles_none_error(self):
|
|
"""None error doesn't crash."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
# _is_rate_limit_error converts to string, "None" doesn't match any pattern
|
|
result = engine._raise_if_rate_limit(None)
|
|
assert result is None
|
|
|
|
|
|
class TestSanitizeErrorMessageExtended:
|
|
"""Extended tests for _sanitize_error_message — regex gaps."""
|
|
|
|
def test_preserves_long_alphanumeric_string(self):
|
|
"""Non-secret 32+ char strings (UUIDs, request IDs) are preserved."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
request_id = "a" * 32
|
|
result = engine._sanitize_error_message(f"Request {request_id} failed")
|
|
assert request_id in result
|
|
|
|
def test_redacts_multiple_url_params(self):
|
|
"""Multiple sensitive URL parameters are all redacted."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
result = engine._sanitize_error_message(
|
|
"Failed: https://api.example.com?api_key=secret1&token=secret2"
|
|
)
|
|
assert "secret1" not in result
|
|
assert "secret2" not in result
|
|
assert "api_key=[REDACTED]" in result
|
|
assert "token=[REDACTED]" in result
|
|
|
|
def test_sk_key_boundary_20_chars(self):
|
|
"""sk- key with exactly 20 chars is redacted, 19 chars is not."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
key_20 = "sk-" + "a" * 20
|
|
key_19 = "sk-" + "a" * 19
|
|
result_20 = engine._sanitize_error_message(f"key: {key_20}")
|
|
result_19 = engine._sanitize_error_message(f"key: {key_19}")
|
|
assert key_20 not in result_20
|
|
assert "[REDACTED_KEY]" in result_20
|
|
# 19-char key doesn't match the sk- pattern (requires 20+)
|
|
assert key_19 in result_19
|
|
|
|
def test_multiple_patterns_in_one_message(self):
|
|
"""Bearer token, sk- key, and URL credentials all redacted together."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
message = (
|
|
"Bearer eyJhbGciOiJIUzI1NiJ9 failed for "
|
|
"https://user:pass@api.com with key "
|
|
"sk-abcdefghijklmnop123456"
|
|
)
|
|
result = engine._sanitize_error_message(message)
|
|
assert "Bearer [REDACTED]" in result
|
|
assert "[REDACTED]:[REDACTED]@" in result
|
|
assert "sk-abcdefghijklmnop123456" not in result
|
|
|
|
|
|
class TestIsRateLimitErrorExtended:
|
|
"""Extended tests for _is_rate_limit_error — direct status_code path."""
|
|
|
|
def test_direct_status_code_attribute_429(self):
|
|
"""Exception with status_code=429 directly (not on .response)."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
|
|
class DirectStatusError(Exception):
|
|
status_code = 429
|
|
|
|
assert engine._is_rate_limit_error(DirectStatusError("error")) is True
|
|
|
|
def test_direct_status_code_attribute_500(self):
|
|
"""Exception with status_code=500 returns False."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
|
|
class DirectStatusError(Exception):
|
|
status_code = 500
|
|
|
|
assert engine._is_rate_limit_error(DirectStatusError("error")) is False
|
|
|
|
def test_status_code_none(self):
|
|
"""Exception with status_code=None doesn't crash."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
|
|
class NoneStatusError(Exception):
|
|
status_code = None
|
|
|
|
assert engine._is_rate_limit_error(NoneStatusError("error")) is False
|
|
|
|
|
|
class TestResolveApiKeyExtended:
|
|
"""Extended tests for _resolve_api_key — placeholder cascade."""
|
|
|
|
def test_settings_returns_placeholder_raises_error(self):
|
|
"""Settings returning a placeholder falls through to ValueError."""
|
|
engine = ConcreteSearchEngine(
|
|
settings_snapshot={"search.api_key": "YOUR_API_KEY_HERE"},
|
|
programmatic_mode=True,
|
|
)
|
|
with pytest.raises(ValueError) as exc_info:
|
|
engine._resolve_api_key(
|
|
api_key=None,
|
|
setting_key="search.api_key",
|
|
engine_name="Test Engine",
|
|
)
|
|
assert "No valid API key found" in str(exc_info.value)
|
|
|
|
def test_both_direct_and_settings_are_placeholders(self):
|
|
"""Both sources having placeholders raises ValueError."""
|
|
engine = ConcreteSearchEngine(
|
|
settings_snapshot={"search.api_key": "YOUR_API_KEY_HERE"},
|
|
programmatic_mode=True,
|
|
)
|
|
with pytest.raises(ValueError):
|
|
engine._resolve_api_key(
|
|
api_key="PLACEHOLDER",
|
|
setting_key="search.api_key",
|
|
engine_name="Test Engine",
|
|
)
|
|
|
|
|
|
class TestGetFullContentExtended:
|
|
"""Extended tests for _get_full_content — mutation safety and edge cases."""
|
|
|
|
def test_does_not_mutate_input_dicts(self):
|
|
"""Input dicts remain unchanged after _get_full_content."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
items = [
|
|
{
|
|
"title": "Preview",
|
|
"_full_result": {"title": "Full", "content": "Content"},
|
|
}
|
|
]
|
|
original = copy.deepcopy(items)
|
|
engine._get_full_content(items)
|
|
assert items == original
|
|
|
|
def test_full_result_is_none(self):
|
|
"""Item with _full_result=None falls back to the item itself."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
items = [{"_full_result": None, "title": "test"}]
|
|
result = engine._get_full_content(items)
|
|
assert len(result) == 1
|
|
assert result[0]["title"] == "test"
|
|
assert "_full_result" not in result[0]
|
|
|
|
def test_full_result_is_empty_dict(self):
|
|
"""Item with _full_result={} returns empty dict, does not fall back to item."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
items = [{"_full_result": {}, "title": "test"}]
|
|
result = engine._get_full_content(items)
|
|
assert len(result) == 1
|
|
assert result[0] == {}
|
|
|
|
def test_conflicting_keys_full_result_wins(self):
|
|
"""When _full_result has same key as item, _full_result's value is used."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
items = [
|
|
{
|
|
"title": "outer",
|
|
"_full_result": {
|
|
"title": "inner",
|
|
"url": "https://example.com",
|
|
},
|
|
}
|
|
]
|
|
result = engine._get_full_content(items)
|
|
assert result[0]["title"] == "inner"
|
|
assert result[0]["url"] == "https://example.com"
|
|
|
|
|
|
class TestRunIntegration:
|
|
"""Integration tests for BaseSearchEngine.run() method."""
|
|
|
|
def test_search_snippets_only_skips_get_full_content(self):
|
|
"""When search_snippets_only=True, _get_full_content is not called."""
|
|
engine = ConcreteSearchEngine(
|
|
search_snippets_only=True,
|
|
programmatic_mode=True,
|
|
)
|
|
previews = [{"title": "Result 1", "snippet": "..."}]
|
|
with patch.object(engine, "_get_previews", return_value=previews):
|
|
with patch.object(engine, "_get_full_content") as mock_full:
|
|
result = engine.run("test query")
|
|
mock_full.assert_not_called()
|
|
assert result == previews
|
|
|
|
def test_search_snippets_only_false_calls_get_full_content(self):
|
|
"""When search_snippets_only=False, _get_full_content IS called."""
|
|
engine = ConcreteSearchEngine(
|
|
search_snippets_only=False,
|
|
programmatic_mode=True,
|
|
)
|
|
previews = [{"title": "Result 1", "snippet": "..."}]
|
|
full_content = [{"title": "Result 1", "content": "Full text"}]
|
|
with patch.object(engine, "_get_previews", return_value=previews):
|
|
with patch.object(
|
|
engine, "_get_full_content", return_value=full_content
|
|
) as mock_full:
|
|
result = engine.run("test query")
|
|
mock_full.assert_called_once_with(previews)
|
|
assert result == full_content
|
|
|
|
def test_rate_limit_in_get_previews_returns_empty(self):
|
|
"""RateLimitError in _get_previews returns empty list."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
with patch.object(
|
|
engine, "_get_previews", side_effect=RateLimitError("rate limited")
|
|
):
|
|
result = engine.run("test query")
|
|
assert result == []
|
|
|
|
def test_non_rate_limit_exception_returns_empty(self):
|
|
"""Non-rate-limit exception in _get_previews returns empty list."""
|
|
engine = ConcreteSearchEngine(programmatic_mode=True)
|
|
with patch.object(
|
|
engine, "_get_previews", side_effect=ConnectionError("failed")
|
|
):
|
|
result = engine.run("test query")
|
|
assert result == []
|