- Add core agent architecture with Command + Skill pattern - Implement Claude API integration for content analysis - Add Obsidian REST API integration for vault operations - Create conversational interface (v2.0) with natural language processing - Add comprehensive configuration management and validation - Include project documentation and developer guides - Set up testing framework with unit, integration, and property tests - Add Kiro specs for Claude API configuration and code quality improvements - Configure project steering files for development guidelines
526 lines
20 KiB
Python
526 lines
20 KiB
Python
"""
|
|
Unit tests for error_handling module.
|
|
Tests custom exceptions, ErrorHandler, and error sanitization.
|
|
"""
|
|
import pytest
|
|
import logging
|
|
from unittest.mock import Mock, patch
|
|
from datetime import datetime
|
|
|
|
from error_handling import (
|
|
JournalOrganizerError, ConfigurationError, APIError, ValidationError,
|
|
FileSystemError, SecurityError, ErrorContext, ErrorHandler,
|
|
get_error_handler, set_error_handler, audit_error_message_security
|
|
)
|
|
|
|
|
|
class TestJournalOrganizerError:
|
|
"""Test base JournalOrganizerError class"""
|
|
|
|
def test_basic_error_creation(self):
|
|
"""Test creating basic error"""
|
|
error = JournalOrganizerError("Test error message")
|
|
|
|
assert str(error) == "Test error message"
|
|
assert error.message == "Test error message"
|
|
assert error.context == {}
|
|
assert error.cause is None
|
|
assert error.timestamp is not None
|
|
|
|
def test_error_with_context(self):
|
|
"""Test creating error with context"""
|
|
context = {"key": "value", "number": 42}
|
|
error = JournalOrganizerError("Test error", context=context)
|
|
|
|
assert error.context == context
|
|
|
|
def test_error_with_cause(self):
|
|
"""Test creating error with cause"""
|
|
original_error = ValueError("Original error")
|
|
error = JournalOrganizerError("Wrapped error", cause=original_error)
|
|
|
|
assert error.cause == original_error
|
|
|
|
def test_to_dict(self):
|
|
"""Test converting error to dictionary"""
|
|
context = {"test_key": "test_value"}
|
|
original_error = RuntimeError("Original")
|
|
error = JournalOrganizerError("Test error", context=context, cause=original_error)
|
|
|
|
error_dict = error.to_dict()
|
|
|
|
assert error_dict["error_type"] == "JournalOrganizerError"
|
|
assert error_dict["message"] == "Test error"
|
|
assert error_dict["context"] == context
|
|
assert error_dict["cause"] == "Original"
|
|
assert "timestamp" in error_dict
|
|
|
|
|
|
class TestConfigurationError:
|
|
"""Test ConfigurationError class"""
|
|
|
|
def test_basic_configuration_error(self):
|
|
"""Test basic configuration error"""
|
|
error = ConfigurationError("Config error", config_key="api_key")
|
|
|
|
assert error.message == "Config error"
|
|
assert error.context["config_key"] == "api_key"
|
|
|
|
def test_configuration_error_with_sensitive_value(self):
|
|
"""Test configuration error with sensitive value redaction"""
|
|
error = ConfigurationError(
|
|
"Invalid API key",
|
|
config_key="api_key",
|
|
config_value="sk-secret-key-123"
|
|
)
|
|
|
|
assert error.context["config_key"] == "api_key"
|
|
assert error.context["config_value"] == "[REDACTED]"
|
|
|
|
def test_configuration_error_with_non_sensitive_value(self):
|
|
"""Test configuration error with non-sensitive value"""
|
|
error = ConfigurationError(
|
|
"Invalid timeout",
|
|
config_key="timeout",
|
|
config_value="30"
|
|
)
|
|
|
|
assert error.context["config_key"] == "timeout"
|
|
assert error.context["config_value"] == "30"
|
|
|
|
|
|
class TestAPIError:
|
|
"""Test APIError class"""
|
|
|
|
def test_basic_api_error(self):
|
|
"""Test basic API error"""
|
|
error = APIError("API call failed", api_name="claude", status_code=500)
|
|
|
|
assert error.message == "API call failed"
|
|
assert error.context["api_name"] == "claude"
|
|
assert error.context["status_code"] == 500
|
|
|
|
def test_api_error_with_long_response(self):
|
|
"""Test API error with long response data truncation"""
|
|
long_response = "x" * 1000
|
|
error = APIError("API error", response_data=long_response)
|
|
|
|
assert len(error.context["response_data"]) <= 503 # 500 + "..."
|
|
assert error.context["response_data"].endswith("...")
|
|
|
|
def test_api_error_with_short_response(self):
|
|
"""Test API error with short response data"""
|
|
short_response = "Short error"
|
|
error = APIError("API error", response_data=short_response)
|
|
|
|
assert error.context["response_data"] == short_response
|
|
|
|
|
|
class TestValidationError:
|
|
"""Test ValidationError class"""
|
|
|
|
def test_basic_validation_error(self):
|
|
"""Test basic validation error"""
|
|
error = ValidationError(
|
|
"Invalid email",
|
|
field_name="email",
|
|
field_value="invalid-email",
|
|
validation_rule="email_format"
|
|
)
|
|
|
|
assert error.message == "Invalid email"
|
|
assert error.context["field_name"] == "email"
|
|
assert error.context["field_value"] == "invalid-email"
|
|
assert error.context["validation_rule"] == "email_format"
|
|
|
|
def test_validation_error_with_sensitive_field(self):
|
|
"""Test validation error with sensitive field value redaction"""
|
|
error = ValidationError(
|
|
"Invalid password",
|
|
field_name="password",
|
|
field_value="secret123"
|
|
)
|
|
|
|
assert error.context["field_name"] == "password"
|
|
assert error.context["field_value"] == "[REDACTED]"
|
|
|
|
def test_validation_error_with_long_value(self):
|
|
"""Test validation error with long field value truncation"""
|
|
long_value = "x" * 200
|
|
error = ValidationError(
|
|
"Invalid input",
|
|
field_name="description",
|
|
field_value=long_value
|
|
)
|
|
|
|
assert len(error.context["field_value"]) == 100 # Truncated to 100 chars
|
|
|
|
|
|
class TestFileSystemError:
|
|
"""Test FileSystemError class"""
|
|
|
|
def test_basic_filesystem_error(self):
|
|
"""Test basic filesystem error"""
|
|
error = FileSystemError(
|
|
"File not found",
|
|
file_path="/path/to/file.txt",
|
|
operation="read"
|
|
)
|
|
|
|
assert error.message == "File not found"
|
|
assert error.context["file_path"] == "/path/to/file.txt"
|
|
assert error.context["operation"] == "read"
|
|
|
|
|
|
class TestSecurityError:
|
|
"""Test SecurityError class"""
|
|
|
|
def test_basic_security_error(self):
|
|
"""Test basic security error"""
|
|
error = SecurityError(
|
|
"Path traversal detected",
|
|
security_issue="path_traversal",
|
|
attempted_path="../../../etc/passwd",
|
|
risk_level="critical"
|
|
)
|
|
|
|
assert error.message == "Path traversal detected"
|
|
assert error.context["security_issue"] == "path_traversal"
|
|
assert error.context["attempted_path"] == "../../../etc/passwd"
|
|
assert error.context["risk_level"] == "critical"
|
|
|
|
def test_security_error_with_long_path(self):
|
|
"""Test security error with long path truncation"""
|
|
long_path = "/" + "x" * 600
|
|
error = SecurityError("Security violation", attempted_path=long_path)
|
|
|
|
assert len(error.context["attempted_path"]) == 500 # Truncated
|
|
|
|
|
|
class TestErrorContext:
|
|
"""Test ErrorContext class"""
|
|
|
|
def test_valid_error_context(self):
|
|
"""Test creating valid error context"""
|
|
context = ErrorContext(
|
|
component="test_component",
|
|
operation="test_operation",
|
|
user_message="User friendly message",
|
|
technical_details={"key": "value"},
|
|
severity="warning"
|
|
)
|
|
|
|
assert context.component == "test_component"
|
|
assert context.operation == "test_operation"
|
|
assert context.user_message == "User friendly message"
|
|
assert context.technical_details == {"key": "value"}
|
|
assert context.severity == "warning"
|
|
|
|
def test_error_context_defaults(self):
|
|
"""Test error context with default values"""
|
|
context = ErrorContext(
|
|
component="test_component",
|
|
operation="test_operation"
|
|
)
|
|
|
|
assert context.user_message == ""
|
|
assert context.technical_details == {}
|
|
assert context.severity == "error"
|
|
|
|
def test_error_context_validation_empty_component(self):
|
|
"""Test error context validation with empty component"""
|
|
with pytest.raises(ValueError, match="component cannot be empty"):
|
|
ErrorContext(component="", operation="test_operation")
|
|
|
|
def test_error_context_validation_empty_operation(self):
|
|
"""Test error context validation with empty operation"""
|
|
with pytest.raises(ValueError, match="operation cannot be empty"):
|
|
ErrorContext(component="test_component", operation="")
|
|
|
|
def test_error_context_validation_invalid_severity(self):
|
|
"""Test error context validation with invalid severity"""
|
|
with pytest.raises(ValueError, match="severity must be one of"):
|
|
ErrorContext(
|
|
component="test_component",
|
|
operation="test_operation",
|
|
severity="invalid"
|
|
)
|
|
|
|
def test_error_context_validation_invalid_technical_details(self):
|
|
"""Test error context validation with invalid technical_details"""
|
|
with pytest.raises(ValueError, match="technical_details must be a dictionary"):
|
|
ErrorContext(
|
|
component="test_component",
|
|
operation="test_operation",
|
|
technical_details="not a dict"
|
|
)
|
|
|
|
def test_to_dict(self):
|
|
"""Test converting error context to dictionary"""
|
|
context = ErrorContext(
|
|
component="test_component",
|
|
operation="test_operation",
|
|
user_message="Test message",
|
|
technical_details={"key": "value"},
|
|
severity="info"
|
|
)
|
|
|
|
context_dict = context.to_dict()
|
|
|
|
assert context_dict["component"] == "test_component"
|
|
assert context_dict["operation"] == "test_operation"
|
|
assert context_dict["user_message"] == "Test message"
|
|
assert context_dict["technical_details"] == {"key": "value"}
|
|
assert context_dict["severity"] == "info"
|
|
|
|
|
|
class TestErrorHandler:
|
|
"""Test ErrorHandler class"""
|
|
|
|
def setup_method(self):
|
|
"""Set up test fixtures"""
|
|
self.mock_logger = Mock(spec=logging.Logger)
|
|
self.error_handler = ErrorHandler(self.mock_logger)
|
|
|
|
def test_error_handler_creation(self):
|
|
"""Test creating error handler"""
|
|
handler = ErrorHandler()
|
|
assert handler.logger is not None
|
|
|
|
handler_with_logger = ErrorHandler(self.mock_logger)
|
|
assert handler_with_logger.logger == self.mock_logger
|
|
|
|
def test_handle_error_with_custom_error(self):
|
|
"""Test handling custom JournalOrganizerError"""
|
|
error = ConfigurationError("Config error", config_key="api_key")
|
|
context = ErrorContext(
|
|
component="config",
|
|
operation="load",
|
|
user_message="Please check your configuration"
|
|
)
|
|
|
|
result = self.error_handler.handle_error(error, context)
|
|
|
|
assert result["success"] is False
|
|
assert result["error"] == "Config error"
|
|
assert result["message"] == "Please check your configuration"
|
|
assert result["component"] == "config"
|
|
assert result["operation"] == "load"
|
|
assert result["error_type"] == "ConfigurationError"
|
|
assert "timestamp" in result
|
|
|
|
# Check that logger was called
|
|
self.mock_logger.log.assert_called_once()
|
|
|
|
def test_handle_error_with_generic_error(self):
|
|
"""Test handling generic Python exception"""
|
|
error = ValueError("Generic error")
|
|
context = ErrorContext(
|
|
component="test",
|
|
operation="test_op",
|
|
severity="warning"
|
|
)
|
|
|
|
result = self.error_handler.handle_error(error, context)
|
|
|
|
assert result["success"] is False
|
|
assert result["error"] == "Generic error"
|
|
assert result["error_type"] == "ValueError"
|
|
|
|
# Check that logger was called with warning level
|
|
self.mock_logger.log.assert_called_once()
|
|
call_args = self.mock_logger.log.call_args
|
|
assert call_args[0][0] == logging.WARNING # Log level
|
|
|
|
def test_handle_api_error(self):
|
|
"""Test handling API errors"""
|
|
error = RuntimeError("Connection failed")
|
|
|
|
result = self.error_handler.handle_api_error(error, "claude", "analyze_text")
|
|
|
|
assert result["success"] is False
|
|
assert "claude API error" in result["error"]
|
|
assert result["component"] == "claude_api"
|
|
assert result["operation"] == "analyze_text"
|
|
assert "Failed to communicate with claude" in result["message"]
|
|
|
|
def test_handle_api_error_with_api_error_instance(self):
|
|
"""Test handling APIError instance"""
|
|
api_error = APIError("API failed", api_name="obsidian", status_code=404)
|
|
|
|
result = self.error_handler.handle_api_error(api_error, "obsidian", "read_note")
|
|
|
|
assert result["success"] is False
|
|
assert result["error"] == "API failed"
|
|
assert result["component"] == "obsidian_api"
|
|
|
|
def test_handle_validation_error(self):
|
|
"""Test handling validation errors"""
|
|
error = ValueError("Invalid format")
|
|
|
|
result = self.error_handler.handle_validation_error(error, "email", "validate_input")
|
|
|
|
assert result["success"] is False
|
|
assert "Invalid email" in result["error"]
|
|
assert result["component"] == "validation"
|
|
assert result["operation"] == "validate_input"
|
|
assert "Please check your email" in result["message"]
|
|
|
|
def test_handle_configuration_error(self):
|
|
"""Test handling configuration errors"""
|
|
error = ValueError("Missing key")
|
|
|
|
result = self.error_handler.handle_configuration_error(error, "api_key")
|
|
|
|
assert result["success"] is False
|
|
assert "Configuration error for api_key" in result["error"]
|
|
assert result["component"] == "configuration"
|
|
assert result["operation"] == "load_config"
|
|
assert "Please check your API key configuration" in result["message"]
|
|
|
|
def test_sanitize_error_message_api_key(self):
|
|
"""Test sanitizing error messages with API keys"""
|
|
message = "Error: api_key=sk-secret-key-123 is invalid"
|
|
|
|
sanitized = self.error_handler._sanitize_error_message(message)
|
|
|
|
assert "sk-secret-key-123" not in sanitized
|
|
assert "api_key=[REDACTED]" in sanitized
|
|
|
|
def test_sanitize_error_message_bearer_token(self):
|
|
"""Test sanitizing error messages with Bearer tokens"""
|
|
message = "Authorization failed: Bearer abc123xyz789"
|
|
|
|
sanitized = self.error_handler._sanitize_error_message(message)
|
|
|
|
assert "abc123xyz789" not in sanitized
|
|
assert "[REDACTED]" in sanitized
|
|
|
|
def test_sanitize_error_message_email(self):
|
|
"""Test sanitizing error messages with email addresses"""
|
|
message = "Failed to send email to user@example.com"
|
|
|
|
sanitized = self.error_handler._sanitize_error_message(message)
|
|
|
|
assert "user@example.com" not in sanitized
|
|
assert "[EMAIL_REDACTED]" in sanitized
|
|
|
|
def test_sanitize_error_message_file_paths(self):
|
|
"""Test sanitizing error messages with user file paths"""
|
|
message = "Cannot access /Users/john/Documents/secret.txt"
|
|
|
|
sanitized = self.error_handler._sanitize_error_message(message)
|
|
|
|
assert "john" not in sanitized
|
|
assert "[USER_REDACTED]" in sanitized
|
|
|
|
def test_sanitize_context_data(self):
|
|
"""Test sanitizing context data"""
|
|
data = {
|
|
"api_key": "secret-key-123",
|
|
"username": "john_doe",
|
|
"password": "secret123",
|
|
"timeout": 30,
|
|
"nested": {
|
|
"token": "bearer-token-xyz",
|
|
"safe_value": "public_info"
|
|
}
|
|
}
|
|
|
|
sanitized = self.error_handler._sanitize_context_data(data)
|
|
|
|
assert sanitized["api_key"] == "[REDACTED]"
|
|
assert sanitized["username"] == "john_doe" # Not sensitive
|
|
assert sanitized["password"] == "[REDACTED]"
|
|
assert sanitized["timeout"] == 30
|
|
assert sanitized["nested"]["token"] == "[REDACTED]"
|
|
assert sanitized["nested"]["safe_value"] == "public_info"
|
|
|
|
def test_sanitize_context_data_non_dict(self):
|
|
"""Test sanitizing non-dictionary context data"""
|
|
result = self.error_handler._sanitize_context_data("not a dict")
|
|
assert result == "not a dict"
|
|
|
|
|
|
class TestGlobalErrorHandler:
|
|
"""Test global error handler functions"""
|
|
|
|
def test_get_error_handler_singleton(self):
|
|
"""Test that get_error_handler returns singleton"""
|
|
handler1 = get_error_handler()
|
|
handler2 = get_error_handler()
|
|
|
|
assert handler1 is handler2
|
|
|
|
def test_set_error_handler(self):
|
|
"""Test setting custom error handler"""
|
|
custom_handler = ErrorHandler()
|
|
set_error_handler(custom_handler)
|
|
|
|
retrieved_handler = get_error_handler()
|
|
assert retrieved_handler is custom_handler
|
|
|
|
|
|
class TestAuditErrorMessageSecurity:
|
|
"""Test error message security auditing"""
|
|
|
|
def test_audit_clean_message(self):
|
|
"""Test auditing clean message with no issues"""
|
|
message = "Simple error message with no sensitive data"
|
|
|
|
result = audit_error_message_security(message)
|
|
|
|
assert result["has_issues"] is False
|
|
assert result["issues"] == []
|
|
assert result["risk_level"] == "low"
|
|
|
|
def test_audit_message_with_email(self):
|
|
"""Test auditing message with email address"""
|
|
message = "Failed to send notification to user@example.com"
|
|
|
|
result = audit_error_message_security(message)
|
|
|
|
assert result["has_issues"] is True
|
|
assert len(result["issues"]) == 1
|
|
assert result["issues"][0]["type"] == "email_address"
|
|
assert result["risk_level"] == "medium"
|
|
|
|
def test_audit_message_with_api_key(self):
|
|
"""Test auditing message with API key"""
|
|
message = "Authentication failed: api_key=sk-secret-123"
|
|
|
|
result = audit_error_message_security(message)
|
|
|
|
assert result["has_issues"] is True
|
|
assert any(issue["type"] == "credential_pattern" for issue in result["issues"])
|
|
assert result["risk_level"] == "high"
|
|
|
|
def test_audit_message_with_bearer_token(self):
|
|
"""Test auditing message with Bearer token"""
|
|
message = "Authorization header: Bearer abc123xyz789"
|
|
|
|
result = audit_error_message_security(message)
|
|
|
|
assert result["has_issues"] is True
|
|
assert any(issue["type"] == "bearer_token" for issue in result["issues"])
|
|
assert result["risk_level"] == "high"
|
|
|
|
def test_audit_message_with_ip_address(self):
|
|
"""Test auditing message with IP address"""
|
|
message = "Connection failed to 192.168.1.100"
|
|
|
|
result = audit_error_message_security(message)
|
|
|
|
assert result["has_issues"] is True
|
|
assert any(issue["type"] == "ip_address" for issue in result["issues"])
|
|
assert result["risk_level"] == "medium"
|
|
|
|
def test_audit_message_with_multiple_issues(self):
|
|
"""Test auditing message with multiple security issues"""
|
|
message = "Failed to connect to 192.168.1.100 with api_key=secret123 for user@example.com"
|
|
|
|
result = audit_error_message_security(message)
|
|
|
|
assert result["has_issues"] is True
|
|
assert len(result["issues"]) >= 2 # Should find multiple issues
|
|
assert result["risk_level"] == "high" # High due to credential pattern |