Implement configuration management, drag-and-drop functionality, and logging utilities for WebDrop Bridge

This commit is contained in:
claudi 2026-01-28 11:21:11 +01:00
parent 04ef84cf9a
commit 6bef2f6119
9 changed files with 1154 additions and 0 deletions

View file

@ -0,0 +1,143 @@
"""Configuration management for WebDrop Bridge application."""
import logging
import os
from dataclasses import dataclass
from pathlib import Path
from typing import List
from dotenv import load_dotenv
logger = logging.getLogger(__name__)
class ConfigurationError(Exception):
"""Raised when configuration is invalid."""
pass
@dataclass
class Config:
"""Application configuration loaded from environment variables.
Attributes:
app_name: Application display name
app_version: Application version (semantic versioning)
log_level: Logging level (DEBUG, INFO, WARNING, ERROR)
log_file: Optional log file path
allowed_roots: List of whitelisted root directories for file access
webapp_url: URL to load in embedded web application
window_width: Initial window width in pixels
window_height: Initial window height in pixels
enable_logging: Whether to write logs to file
Raises:
ConfigurationError: If configuration values are invalid
"""
app_name: str
app_version: str
log_level: str
log_file: Path | None
allowed_roots: List[Path]
webapp_url: str
window_width: int
window_height: int
enable_logging: bool
@classmethod
def from_env(cls, env_file: str | None = None) -> "Config":
"""Load configuration from environment variables.
Looks for a .env file in the current working directory and loads
environment variables from it. All unset variables use sensible defaults.
Args:
env_file: Optional path to .env file to load. If None, loads default .env
Returns:
Config: Configured instance with environment values or defaults
Raises:
ConfigurationError: If any configured value is invalid
"""
# Load .env file if it exists
load_dotenv(env_file)
# Extract and validate configuration values
app_name = os.getenv("APP_NAME", "WebDrop Bridge")
app_version = os.getenv("APP_VERSION", "1.0.0")
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
log_file_str = os.getenv("LOG_FILE", "logs/webdrop_bridge.log")
allowed_roots_str = os.getenv("ALLOWED_ROOTS", "Z:/,C:/Users/Public")
webapp_url = os.getenv("WEBAPP_URL", "file:///./webapp/index.html")
window_width = int(os.getenv("WINDOW_WIDTH", "1024"))
window_height = int(os.getenv("WINDOW_HEIGHT", "768"))
enable_logging = os.getenv("ENABLE_LOGGING", "true").lower() == "true"
# Validate log level
valid_levels = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
if log_level not in valid_levels:
raise ConfigurationError(
f"Invalid LOG_LEVEL: {log_level}. "
f"Must be one of: {', '.join(valid_levels)}"
)
# Validate and parse allowed roots
try:
allowed_roots = []
for p in allowed_roots_str.split(","):
root_path = Path(p.strip()).resolve()
if not root_path.exists():
raise ConfigurationError(
f"Allowed root '{p.strip()}' does not exist"
)
if not root_path.is_dir():
raise ConfigurationError(
f"Allowed root '{p.strip()}' is not a directory"
)
allowed_roots.append(root_path)
except ConfigurationError:
raise
except (ValueError, OSError) as e:
raise ConfigurationError(
f"Invalid ALLOWED_ROOTS: {allowed_roots_str}. Error: {e}"
) from e
# Validate window dimensions
if window_width <= 0 or window_height <= 0:
raise ConfigurationError(
f"Window dimensions must be positive: "
f"{window_width}x{window_height}"
)
# Create log file path if logging enabled
log_file = None
if enable_logging:
log_file = Path(log_file_str).resolve()
# Validate webapp URL is not empty
if not webapp_url:
raise ConfigurationError("WEBAPP_URL cannot be empty")
return cls(
app_name=app_name,
app_version=app_version,
log_level=log_level,
log_file=log_file,
allowed_roots=allowed_roots,
webapp_url=webapp_url,
window_width=window_width,
window_height=window_height,
enable_logging=enable_logging,
)
def __repr__(self) -> str:
"""Return developer-friendly representation."""
return (
f"Config(app={self.app_name} v{self.app_version}, "
f"log_level={self.log_level}, "
f"allowed_roots={len(self.allowed_roots)} dirs, "
f"window={self.window_width}x{self.window_height})"
)

View file

@ -0,0 +1,106 @@
"""Qt widget for intercepting drag events and initiating native drag operations."""
from pathlib import Path
from typing import List, Optional
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QDrag, QMimeData
from PySide6.QtWidgets import QWidget
from webdrop_bridge.core.validator import PathValidator, ValidationError
class DragInterceptor(QWidget):
"""Widget that handles drag initiation for file paths.
Intercepts drag events from web content and initiates native Qt drag
operations, allowing files to be dragged from web content to native
applications.
Signals:
drag_started: Emitted when a drag operation begins successfully
drag_failed: Emitted when drag initiation fails
"""
# Signals with string parameters (file paths that were dragged)
drag_started = Signal(list) # List[str] - list of file paths
drag_failed = Signal(str) # str - error message
def __init__(self, parent: Optional[QWidget] = None):
"""Initialize the drag interceptor.
Args:
parent: Parent widget
"""
super().__init__(parent)
self._validator: Optional[PathValidator] = None
def set_validator(self, validator: PathValidator) -> None:
"""Set the path validator for this interceptor.
Args:
validator: PathValidator instance to use for validation
"""
self._validator = validator
def initiate_drag(self, file_paths: List[str]) -> bool:
"""Initiate a native drag operation for the given files.
Args:
file_paths: List of file paths to drag
Returns:
True if drag was successfully initiated, False otherwise
"""
if not file_paths:
self.drag_failed.emit("No files to drag")
return False
if not self._validator:
self.drag_failed.emit("Validator not configured")
return False
# Validate all paths first
validated_paths = []
for path_str in file_paths:
try:
path = Path(path_str)
if self._validator.validate(path):
validated_paths.append(path)
except ValidationError as e:
self.drag_failed.emit(f"Validation failed for {path_str}: {e}")
return False
if not validated_paths:
self.drag_failed.emit("No valid files after validation")
return False
# Create MIME data with file URLs
mime_data = QMimeData()
file_urls = [
path.as_uri() for path in validated_paths
]
mime_data.setUrls([QUrl(url) for url in file_urls])
# Create and execute drag operation
drag = QDrag(self)
drag.setMimeData(mime_data)
# Use default drag pixmap (small icon)
drag.setPixmap(self.grab(self.rect()).scaled(
64, 64, Qt.AspectRatioMode.KeepAspectRatio
))
# Execute drag operation (blocking call)
drop_action = drag.exec(Qt.DropAction.CopyAction)
# Check result
if drop_action == Qt.DropAction.CopyAction:
self.drag_started.emit(validated_paths)
return True
else:
self.drag_failed.emit("Drag operation cancelled or failed")
return False
# Import QUrl here to avoid circular import at module level
from PySide6.QtCore import QUrl # noqa: E402, F401

View file

@ -0,0 +1,97 @@
"""Path validation for secure file operations."""
from pathlib import Path
from typing import List
class ValidationError(Exception):
"""Raised when path validation fails."""
pass
class PathValidator:
"""Validates file paths against security whitelist.
Ensures that only files within allowed root directories can be accessed.
All paths are resolved to absolute form before validation to prevent
directory traversal attacks.
"""
def __init__(self, allowed_roots: List[Path]):
"""Initialize validator with allowed root directories.
Args:
allowed_roots: List of Path objects representing allowed root dirs
Raises:
ValidationError: If any root doesn't exist or isn't a directory
"""
self.allowed_roots = []
for root in allowed_roots:
root_path = Path(root).resolve()
if not root_path.exists():
raise ValidationError(
f"Allowed root '{root}' does not exist"
)
if not root_path.is_dir():
raise ValidationError(
f"Allowed root '{root}' is not a directory"
)
self.allowed_roots.append(root_path)
def validate(self, path: Path) -> bool:
"""Validate that path is within an allowed root directory.
Args:
path: File path to validate
Returns:
True if path is valid and accessible
Raises:
ValidationError: If path fails validation
"""
try:
# Resolve to absolute path (handles symlinks, .., etc)
file_path = Path(path).resolve()
except (OSError, ValueError) as e:
raise ValidationError(f"Cannot resolve path '{path}': {e}") from e
# Check file exists
if not file_path.exists():
raise ValidationError(f"File does not exist: {path}")
# Check it's a regular file (not directory, symlink to dir, etc)
if not file_path.is_file():
raise ValidationError(f"Path is not a regular file: {path}")
# Check path is within an allowed root
for allowed_root in self.allowed_roots:
try:
# This raises ValueError if file_path is not relative to root
file_path.relative_to(allowed_root)
return True
except ValueError:
continue
# Not in any allowed root
raise ValidationError(
f"Path '{file_path}' is not within allowed roots: "
f"{self.allowed_roots}"
)
def is_valid(self, path: Path) -> bool:
"""Check if path is valid without raising exception.
Args:
path: File path to check
Returns:
True if valid, False otherwise
"""
try:
return self.validate(path)
except ValidationError:
return False

View file

@ -0,0 +1,67 @@
"""WebDrop Bridge - Application entry point."""
import sys
from pathlib import Path
from PySide6.QtWidgets import QApplication
from webdrop_bridge.config import Config, ConfigurationError
from webdrop_bridge.ui.main_window import MainWindow
from webdrop_bridge.utils.logging import get_logger, setup_logging
def main() -> int:
"""Main application entry point.
Initializes configuration, logging, and creates the main window.
Returns appropriate exit code.
Returns:
int: Exit code (0 for success, non-zero for error)
"""
try:
# Load configuration from environment
config = Config.from_env()
# Set up logging
log_file = None
if config.log_file_path:
log_file = Path(config.log_file_path)
setup_logging(
name="webdrop_bridge",
level=config.log_level,
log_file=log_file,
)
logger = get_logger(__name__)
logger.info(f"Starting {config.app_name} v{config.app_version}")
logger.debug(f"Configuration: {config}")
except ConfigurationError as e:
print(f"Configuration error: {e}", file=sys.stderr)
return 1
except Exception as e:
print(f"Failed to initialize logging: {e}", file=sys.stderr)
return 1
try:
# Create Qt application
app = QApplication(sys.argv)
# Create and show main window
window = MainWindow(config)
window.show()
logger.info("Main window opened successfully")
# Run event loop
return app.exec()
except Exception as e:
logger.exception(f"Unhandled exception: {e}")
return 1
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,158 @@
"""Main application window with web engine integration."""
from pathlib import Path
from typing import Optional
from PySide6.QtCore import Qt, QUrl
from PySide6.QtWebEngineWidgets import QWebEngineView
from PySide6.QtWidgets import QMainWindow, QVBoxLayout, QWidget
from webdrop_bridge.config import Config
from webdrop_bridge.core.drag_interceptor import DragInterceptor
from webdrop_bridge.core.validator import PathValidator
class MainWindow(QMainWindow):
"""Main application window for WebDrop Bridge.
Displays web content in a QWebEngineView and provides drag-and-drop
integration with the native filesystem.
"""
def __init__(
self,
config: Config,
parent: Optional[QWidget] = None,
):
"""Initialize the main window.
Args:
config: Application configuration
parent: Parent widget
"""
super().__init__(parent)
self.config = config
# Set window properties
self.setWindowTitle(f"{config.app_name} v{config.app_version}")
self.setGeometry(
100,
100,
config.window_width,
config.window_height,
)
# Create web engine view
self.web_view = QWebEngineView()
# Create drag interceptor
self.drag_interceptor = DragInterceptor()
# Set up path validator
validator = PathValidator(config.allowed_roots)
self.drag_interceptor.set_validator(validator)
# Connect drag interceptor signals
self.drag_interceptor.drag_started.connect(self._on_drag_started)
self.drag_interceptor.drag_failed.connect(self._on_drag_failed)
# Set up central widget with layout
central_widget = QWidget()
layout = QVBoxLayout()
layout.addWidget(self.web_view)
layout.setContentsMargins(0, 0, 0, 0)
central_widget.setLayout(layout)
self.setCentralWidget(central_widget)
# Load web application
self._load_webapp()
# Apply styling if available
self._apply_stylesheet()
def _load_webapp(self) -> None:
"""Load the web application.
Loads HTML from the configured webapp URL or from local file.
"""
webapp_url = self.config.webapp_url
if webapp_url.startswith("http://") or webapp_url.startswith("https://"):
# Remote URL
self.web_view.load(QUrl(webapp_url))
else:
# Local file path
try:
file_path = Path(webapp_url).resolve()
if not file_path.exists():
self.web_view.setHtml(
f"<html><body><h1>Error</h1>"
f"<p>Web application file not found: {file_path}</p>"
f"</body></html>"
)
return
# Load local file as file:// URL
file_url = file_path.as_uri()
self.web_view.load(QUrl(file_url))
except (OSError, ValueError) as e:
self.web_view.setHtml(
f"<html><body><h1>Error</h1>"
f"<p>Failed to load web application: {e}</p>"
f"</body></html>"
)
def _apply_stylesheet(self) -> None:
"""Apply application stylesheet if available."""
stylesheet_path = Path(__file__).parent.parent.parent.parent / \
"resources" / "stylesheets" / "default.qss"
if stylesheet_path.exists():
try:
with open(stylesheet_path, "r") as f:
stylesheet = f.read()
self.setStyleSheet(stylesheet)
except (OSError, IOError):
# Silently fail if stylesheet can't be read
pass
def _on_drag_started(self, paths: list) -> None:
"""Handle successful drag initiation.
Args:
paths: List of paths that were dragged
"""
# Can be extended with logging or status bar updates
pass
def _on_drag_failed(self, error: str) -> None:
"""Handle drag operation failure.
Args:
error: Error message
"""
# Can be extended with logging or user notification
pass
def closeEvent(self, event) -> None:
"""Handle window close event.
Args:
event: Close event
"""
# Can be extended with save operations or cleanup
event.accept()
def initiate_drag(self, file_paths: list) -> bool:
"""Initiate a drag operation for the given files.
Called from web content via JavaScript bridge.
Args:
file_paths: List of file paths to drag
Returns:
True if drag was initiated successfully
"""
return self.drag_interceptor.initiate_drag(file_paths)

View file

@ -0,0 +1,100 @@
"""Logging configuration and utilities for WebDrop Bridge."""
import logging
import logging.handlers
from pathlib import Path
from typing import Optional
def setup_logging(
name: str = "webdrop_bridge",
level: str = "INFO",
log_file: Optional[Path] = None,
fmt: Optional[str] = None,
) -> logging.Logger:
"""Configure application-wide logging.
Sets up both console and file logging (if enabled). All loggers in the
application should use this configured root logger.
Args:
name: Logger name (typically module name or app name)
level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
log_file: Optional path to log file. If provided, logs will be written
to this file in addition to console
fmt: Optional custom format string. If None, uses default format.
Default: "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
Returns:
logging.Logger: Configured logger instance
Raises:
ValueError: If log_file path is invalid or can't be created
KeyError: If level is not a valid logging level
"""
# Validate logging level
try:
numeric_level = getattr(logging, level.upper())
except AttributeError as e:
raise KeyError(f"Invalid logging level: {level}") from e
# Use default format if not provided
if fmt is None:
fmt = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
# Create formatter
formatter = logging.Formatter(fmt)
# Get or create logger
logger = logging.getLogger(name)
logger.setLevel(numeric_level)
# Remove existing handlers to avoid duplicates
logger.handlers.clear()
# Add console handler
console_handler = logging.StreamHandler()
console_handler.setLevel(numeric_level)
console_handler.setFormatter(formatter)
logger.addHandler(console_handler)
# Add file handler if log file specified
if log_file:
try:
# Create parent directories if needed
log_file.parent.mkdir(parents=True, exist_ok=True)
# Use rotating file handler to manage log file size
# Max 10 MB per file, keep 5 backups
file_handler = logging.handlers.RotatingFileHandler(
log_file,
maxBytes=10 * 1024 * 1024, # 10 MB
backupCount=5,
encoding="utf-8",
)
file_handler.setLevel(numeric_level)
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
except (OSError, IOError) as e:
raise ValueError(f"Cannot write to log file {log_file}: {e}") from e
return logger
def get_logger(name: str = __name__) -> logging.Logger:
"""Get a logger instance for a module.
Convenience function to get a logger for a specific module.
Use this in your modules to get properly named loggers.
Example:
logger = get_logger(__name__)
Args:
name: Logger name, typically __name__ of the calling module
Returns:
logging.Logger: Logger instance for the given name
"""
return logging.getLogger(name)

148
tests/unit/test_config.py Normal file
View file

@ -0,0 +1,148 @@
"""Unit tests for configuration system."""
import os
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import patch
import pytest
from webdrop_bridge.config import Config, ConfigurationError
@pytest.fixture(autouse=True)
def clear_env():
"""Clear environment variables before each test to avoid persistence."""
# Save current env
saved_env = os.environ.copy()
# Clear relevant variables
for key in list(os.environ.keys()):
if key.startswith(('APP_', 'LOG_', 'ALLOWED_', 'WEBAPP_', 'WINDOW_', 'ENABLE_')):
del os.environ[key]
yield
# Restore env (cleanup)
os.environ.clear()
os.environ.update(saved_env)
class TestConfigFromEnv:
"""Test Config.from_env() loading from environment."""
def test_from_env_with_all_values(self, tmp_path):
"""Test loading config with all environment variables set."""
# Create .env file
env_file = tmp_path / ".env"
root1 = tmp_path / "root1"
root2 = tmp_path / "root2"
root1.mkdir()
root2.mkdir()
env_file.write_text(
f"APP_NAME=TestApp\n"
f"APP_VERSION=2.0.0\n"
f"LOG_LEVEL=DEBUG\n"
f"LOG_FILE={tmp_path / 'test.log'}\n"
f"ALLOWED_ROOTS={root1},{root2}\n"
f"WEBAPP_URL=http://localhost:8000\n"
f"WINDOW_WIDTH=1200\n"
f"WINDOW_HEIGHT=800\n"
)
# Load config (env vars from file, not system)
config = Config.from_env(str(env_file))
assert config.app_name == "TestApp"
assert config.app_version == "2.0.0"
assert config.log_level == "DEBUG"
assert config.allowed_roots == [root1.resolve(), root2.resolve()]
assert config.webapp_url == "http://localhost:8000"
assert config.window_width == 1200
assert config.window_height == 800
def test_from_env_with_defaults(self, tmp_path):
"""Test loading config uses defaults when env vars not set."""
# Create empty .env file
env_file = tmp_path / ".env"
env_file.write_text("")
config = Config.from_env(str(env_file))
assert config.app_name == "WebDrop Bridge"
assert config.app_version == "1.0.0"
assert config.log_level == "INFO"
assert config.window_width == 1024
assert config.window_height == 768
def test_from_env_invalid_log_level(self, tmp_path):
"""Test that invalid log level raises ConfigurationError."""
env_file = tmp_path / ".env"
root1 = tmp_path / "root1"
root1.mkdir()
env_file.write_text(f"LOG_LEVEL=INVALID\nALLOWED_ROOTS={root1}\n")
with pytest.raises(ConfigurationError, match="Invalid LOG_LEVEL"):
Config.from_env(str(env_file))
def test_from_env_invalid_window_dimension(self, tmp_path):
"""Test that negative window dimensions raise ConfigurationError."""
env_file = tmp_path / ".env"
root1 = tmp_path / "root1"
root1.mkdir()
env_file.write_text(f"WINDOW_WIDTH=-100\nALLOWED_ROOTS={root1}\n")
with pytest.raises(ConfigurationError, match="Window dimensions"):
Config.from_env(str(env_file))
def test_from_env_invalid_root_path(self, tmp_path):
"""Test that non-existent root paths raise ConfigurationError."""
env_file = tmp_path / ".env"
env_file.write_text("ALLOWED_ROOTS=/nonexistent/path/that/does/not/exist\n")
with pytest.raises(ConfigurationError, match="does not exist"):
Config.from_env(str(env_file))
def test_from_env_empty_webapp_url(self, tmp_path):
"""Test that empty webapp URL raises ConfigurationError."""
env_file = tmp_path / ".env"
root1 = tmp_path / "root1"
root1.mkdir()
env_file.write_text(f"WEBAPP_URL=\nALLOWED_ROOTS={root1}\n")
with pytest.raises(ConfigurationError, match="WEBAPP_URL"):
Config.from_env(str(env_file))
class TestConfigValidation:
"""Test Config field validation."""
def test_root_path_resolution(self, tmp_path):
"""Test that root paths are resolved to absolute paths."""
env_file = tmp_path / ".env"
root_dir = tmp_path / "allowed"
root_dir.mkdir()
env_file.write_text(f"ALLOWED_ROOTS={root_dir}\n")
config = Config.from_env(str(env_file))
# Should be resolved to absolute path
assert config.allowed_roots[0].is_absolute()
def test_multiple_root_paths(self, tmp_path):
"""Test loading multiple root paths."""
dir1 = tmp_path / "dir1"
dir2 = tmp_path / "dir2"
dir1.mkdir()
dir2.mkdir()
env_file = tmp_path / ".env"
env_file.write_text(f"ALLOWED_ROOTS={dir1},{dir2}\n")
config = Config.from_env(str(env_file))
assert len(config.allowed_roots) == 2
assert config.allowed_roots[0] == dir1.resolve()
assert config.allowed_roots[1] == dir2.resolve()

154
tests/unit/test_logging.py Normal file
View file

@ -0,0 +1,154 @@
"""Unit tests for logging module."""
import logging
from pathlib import Path
from tempfile import TemporaryDirectory
import pytest
from webdrop_bridge.utils.logging import get_logger, setup_logging
class TestSetupLogging:
"""Test logging configuration."""
def test_setup_logging_console_only(self):
"""Test console-only logging setup."""
logger = setup_logging(name="test_console", level="DEBUG")
assert logger.name == "test_console"
assert logger.level == logging.DEBUG
assert len(logger.handlers) == 1
assert isinstance(logger.handlers[0], logging.StreamHandler)
def test_setup_logging_with_file(self, tmp_path):
"""Test logging setup with file handler."""
log_file = tmp_path / "test.log"
logger = setup_logging(
name="test_file",
level="INFO",
log_file=log_file,
)
assert logger.name == "test_file"
assert len(logger.handlers) == 2
# Find file handler
file_handler = None
for handler in logger.handlers:
if isinstance(handler, logging.handlers.RotatingFileHandler):
file_handler = handler
break
assert file_handler is not None
assert log_file.exists()
def test_setup_logging_invalid_level(self):
"""Test that invalid log level raises KeyError."""
with pytest.raises(KeyError, match="Invalid logging level"):
setup_logging(level="INVALID")
def test_setup_logging_invalid_file_path(self):
"""Test that inaccessible file path raises ValueError."""
# Use a path that can't be created (on Windows, CON is reserved)
if Path.cwd().drive: # Windows
invalid_path = Path("CON") / "invalid.log"
else: # Unix - use root-only directory
invalid_path = Path("/root") / "invalid.log"
with pytest.raises(ValueError, match="Cannot write to log file"):
setup_logging(log_file=invalid_path)
def test_setup_logging_custom_format(self, tmp_path):
"""Test logging with custom format string."""
log_file = tmp_path / "test.log"
custom_fmt = "%(levelname)s:%(name)s:%(message)s"
logger = setup_logging(
name="test_format",
level="INFO",
log_file=log_file,
fmt=custom_fmt,
)
# Log a test message
logger.info("test message")
# Check format in log file
content = log_file.read_text()
assert "INFO:test_format:test message" in content
def test_setup_logging_creates_parent_dirs(self, tmp_path):
"""Test that setup_logging creates parent directories."""
log_file = tmp_path / "nested" / "dir" / "test.log"
logger = setup_logging(
name="test_nested",
level="INFO",
log_file=log_file,
)
logger.info("test")
assert log_file.exists()
assert log_file.parent.exists()
def test_setup_logging_removes_duplicates(self):
"""Test that multiple setup calls don't duplicate handlers."""
logger_name = "test_duplicates"
# First setup
logger1 = setup_logging(name=logger_name, level="DEBUG")
initial_handler_count = len(logger1.handlers)
# Second setup (should clear old handlers)
logger2 = setup_logging(name=logger_name, level="INFO")
assert logger2 is logger1 # Same logger object
assert len(logger2.handlers) == initial_handler_count
class TestGetLogger:
"""Test get_logger convenience function."""
def test_get_logger(self):
"""Test getting a logger instance."""
logger = get_logger("test.module")
assert isinstance(logger, logging.Logger)
assert logger.name == "test.module"
def test_get_logger_default_name(self):
"""Test get_logger uses __name__ by default."""
logger = get_logger()
# Should return a logger (when no name provided, uses logging module __name__)
assert isinstance(logger, logging.Logger)
assert logger.name == "webdrop_bridge.utils.logging"
class TestLogRotation:
"""Test log file rotation."""
def test_rotating_file_handler_configured(self, tmp_path):
"""Test that file handler is configured for rotation."""
log_file = tmp_path / "test.log"
logger = setup_logging(
name="test_rotation",
level="INFO",
log_file=log_file,
)
# Find rotating file handler
rotating_handler = None
for handler in logger.handlers:
if isinstance(handler, logging.handlers.RotatingFileHandler):
rotating_handler = handler
break
assert rotating_handler is not None
# Default: 10 MB max, 5 backups
assert rotating_handler.maxBytes == 10 * 1024 * 1024
assert rotating_handler.backupCount == 5

View file

@ -0,0 +1,181 @@
"""Unit tests for path validator."""
from pathlib import Path
from tempfile import TemporaryDirectory
import pytest
from webdrop_bridge.core.validator import PathValidator, ValidationError
class TestPathValidator:
"""Test path validation."""
def test_validator_initialization(self, tmp_path):
"""Test creating a validator with valid roots."""
dir1 = tmp_path / "dir1"
dir2 = tmp_path / "dir2"
dir1.mkdir()
dir2.mkdir()
validator = PathValidator([dir1, dir2])
assert len(validator.allowed_roots) == 2
def test_validator_nonexistent_root(self, tmp_path):
"""Test that nonexistent root raises ValidationError."""
nonexistent = tmp_path / "nonexistent"
with pytest.raises(ValidationError, match="does not exist"):
PathValidator([nonexistent])
def test_validator_non_directory_root(self, tmp_path):
"""Test that non-directory root raises ValidationError."""
file_path = tmp_path / "file.txt"
file_path.write_text("test")
with pytest.raises(ValidationError, match="not a directory"):
PathValidator([file_path])
def test_validate_valid_file(self, tmp_path):
"""Test validating a file within allowed root."""
file_path = tmp_path / "test.txt"
file_path.write_text("test content")
validator = PathValidator([tmp_path])
assert validator.validate(file_path) is True
assert validator.is_valid(file_path) is True
def test_validate_nonexistent_file(self, tmp_path):
"""Test that nonexistent file raises ValidationError."""
validator = PathValidator([tmp_path])
nonexistent = tmp_path / "nonexistent.txt"
with pytest.raises(ValidationError, match="does not exist"):
validator.validate(nonexistent)
def test_validate_directory_path(self, tmp_path):
"""Test that directory path raises ValidationError."""
subdir = tmp_path / "subdir"
subdir.mkdir()
validator = PathValidator([tmp_path])
with pytest.raises(ValidationError, match="not a regular file"):
validator.validate(subdir)
def test_validate_file_outside_roots(self, tmp_path):
"""Test that file outside allowed roots raises ValidationError."""
allowed_dir = tmp_path / "allowed"
other_dir = tmp_path / "other"
allowed_dir.mkdir()
other_dir.mkdir()
file_in_other = other_dir / "test.txt"
file_in_other.write_text("test")
validator = PathValidator([allowed_dir])
with pytest.raises(ValidationError, match="not within allowed roots"):
validator.validate(file_in_other)
def test_validate_multiple_roots(self, tmp_path):
"""Test validating files in multiple allowed roots."""
dir1 = tmp_path / "dir1"
dir2 = tmp_path / "dir2"
dir1.mkdir()
dir2.mkdir()
file1 = dir1 / "file1.txt"
file2 = dir2 / "file2.txt"
file1.write_text("content1")
file2.write_text("content2")
validator = PathValidator([dir1, dir2])
assert validator.validate(file1) is True
assert validator.validate(file2) is True
def test_validate_with_relative_path(self, tmp_path):
"""Test validating with relative path (gets resolved)."""
import os
file_path = tmp_path / "test.txt"
file_path.write_text("test")
validator = PathValidator([tmp_path])
# Change to tmp_path directory
original_cwd = os.getcwd()
try:
os.chdir(tmp_path)
# Use relative path
assert validator.validate(Path("test.txt")) is True
finally:
os.chdir(original_cwd)
def test_validate_with_path_traversal(self, tmp_path):
"""Test that path traversal attacks are blocked."""
allowed_dir = tmp_path / "allowed"
other_dir = tmp_path / "other"
allowed_dir.mkdir()
other_dir.mkdir()
file_in_other = other_dir / "secret.txt"
file_in_other.write_text("secret")
validator = PathValidator([allowed_dir])
# Try to access file outside root using ..
traversal_path = allowed_dir / ".." / "other" / "secret.txt"
with pytest.raises(ValidationError, match="not within allowed roots"):
validator.validate(traversal_path)
def test_is_valid_doesnt_raise(self, tmp_path):
"""Test that is_valid() never raises exceptions."""
validator = PathValidator([tmp_path])
# These should all return False, not raise
assert validator.is_valid(Path("/nonexistent")) is False
assert validator.is_valid(tmp_path) is False # Directory, not file
# Valid file should return True
file_path = tmp_path / "test.txt"
file_path.write_text("test")
assert validator.is_valid(file_path) is True
class TestPathValidatorEdgeCases:
"""Test edge cases in path validation."""
def test_symlink_to_valid_file(self, tmp_path):
"""Test validating a symlink to a valid file."""
# Skip on Windows if symlink creation fails
actual_file = tmp_path / "actual.txt"
actual_file.write_text("content")
try:
symlink = tmp_path / "link.txt"
symlink.symlink_to(actual_file)
validator = PathValidator([tmp_path])
# Symlinks resolve to their target, should validate
assert validator.validate(symlink) is True
except (OSError, NotImplementedError):
# Skip if symlinks not supported
pytest.skip("Symlinks not supported on this platform")
def test_nested_files_in_allowed_root(self, tmp_path):
"""Test validating files in nested subdirectories."""
nested_dir = tmp_path / "a" / "b" / "c"
nested_dir.mkdir(parents=True)
nested_file = nested_dir / "file.txt"
nested_file.write_text("content")
validator = PathValidator([tmp_path])
assert validator.validate(nested_file) is True