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

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