Implement configuration management, drag-and-drop functionality, and logging utilities for WebDrop Bridge
This commit is contained in:
parent
04ef84cf9a
commit
6bef2f6119
9 changed files with 1154 additions and 0 deletions
148
tests/unit/test_config.py
Normal file
148
tests/unit/test_config.py
Normal 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
154
tests/unit/test_logging.py
Normal 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
|
||||
181
tests/unit/test_validator.py
Normal file
181
tests/unit/test_validator.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue