218 lines
8.2 KiB
Python
218 lines
8.2 KiB
Python
"""Tests for config validation hardening and log_level application (CONSOLIDATED-08)."""
|
|
|
|
import logging
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import msgspec
|
|
import pytest
|
|
from pydantic import ValidationError as PydanticValidationError
|
|
|
|
from core.config import Settings
|
|
from core.exceptions import ConfigurationError
|
|
|
|
|
|
# Helpers
|
|
|
|
def _make_settings(**overrides) -> Settings:
|
|
"""Build a Settings with sensible defaults, applying overrides."""
|
|
defaults = {
|
|
"lidarr_url": "http://lidarr:8686",
|
|
"jellyfin_url": "http://jellyfin:8096",
|
|
"lidarr_api_key": "test-key",
|
|
"config_file_path": Path("/tmp/musicseerr-test-config.json"),
|
|
}
|
|
defaults.update(overrides)
|
|
return Settings(**defaults)
|
|
|
|
|
|
def _write_config(path: Path, data: dict) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
with open(path, "wb") as f:
|
|
f.write(msgspec.json.encode(data))
|
|
|
|
|
|
# A. Config Validation — Critical errors raise
|
|
|
|
class TestValidateConfigCriticalErrors:
|
|
def test_valid_config_creates_settings(self) -> None:
|
|
settings = _make_settings()
|
|
assert settings.lidarr_url == "http://lidarr:8686"
|
|
|
|
def test_invalid_url_scheme_raises(self) -> None:
|
|
with pytest.raises((ConfigurationError, PydanticValidationError)):
|
|
_make_settings(lidarr_url="ftp://lidarr:8686")
|
|
|
|
def test_invalid_jellyfin_url_scheme_raises(self) -> None:
|
|
with pytest.raises((ConfigurationError, PydanticValidationError)):
|
|
_make_settings(jellyfin_url="ftp://jellyfin:8096")
|
|
|
|
def test_missing_api_key_warns_but_does_not_raise(self, caplog: pytest.LogCaptureFixture) -> None:
|
|
with caplog.at_level(logging.WARNING):
|
|
settings = _make_settings(lidarr_api_key="")
|
|
assert settings.lidarr_api_key == ""
|
|
assert any("LIDARR_API_KEY" in r.message for r in caplog.records)
|
|
|
|
def test_http_pool_mismatch_warns_but_does_not_raise(self, caplog: pytest.LogCaptureFixture) -> None:
|
|
with caplog.at_level(logging.WARNING):
|
|
settings = _make_settings(http_max_connections=10, http_max_keepalive=20)
|
|
assert settings.http_max_connections == 10
|
|
assert any("http_max_connections" in r.message for r in caplog.records)
|
|
|
|
|
|
# B. log_level field validator
|
|
|
|
class TestLogLevelValidator:
|
|
@pytest.mark.parametrize("level", ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"])
|
|
def test_valid_levels_accepted(self, level: str) -> None:
|
|
settings = _make_settings(log_level=level)
|
|
assert settings.log_level == level
|
|
|
|
def test_normalises_to_uppercase(self) -> None:
|
|
settings = _make_settings(log_level="debug")
|
|
assert settings.log_level == "DEBUG"
|
|
|
|
def test_invalid_level_raises(self) -> None:
|
|
with pytest.raises(PydanticValidationError, match="(?i)invalid log_level"):
|
|
_make_settings(log_level="VERBOSE")
|
|
|
|
def test_mixed_case_normalised(self) -> None:
|
|
settings = _make_settings(log_level="Warning")
|
|
assert settings.log_level == "WARNING"
|
|
|
|
|
|
# C. load_from_file — type validation
|
|
|
|
class TestLoadFromFileTypeValidation:
|
|
def test_wrong_type_raises_configuration_error(self, tmp_path: Path) -> None:
|
|
config_path = tmp_path / "config.json"
|
|
_write_config(config_path, {"port": "banana"})
|
|
|
|
settings = _make_settings(config_file_path=config_path)
|
|
with pytest.raises(ConfigurationError, match="type errors"):
|
|
settings.load_from_file()
|
|
|
|
def test_coercible_type_succeeds(self, tmp_path: Path) -> None:
|
|
config_path = tmp_path / "config.json"
|
|
_write_config(config_path, {"port": "8688"})
|
|
|
|
settings = _make_settings(config_file_path=config_path)
|
|
settings.load_from_file()
|
|
assert settings.port == 8688
|
|
|
|
def test_unknown_key_warns(self, tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
|
|
config_path = tmp_path / "config.json"
|
|
_write_config(config_path, {"totally_unknown_key": "value"})
|
|
|
|
settings = _make_settings(config_file_path=config_path)
|
|
with caplog.at_level(logging.WARNING):
|
|
settings.load_from_file()
|
|
assert any("Unknown config key" in r.message for r in caplog.records)
|
|
|
|
def test_invalid_url_in_file_raises(self, tmp_path: Path) -> None:
|
|
config_path = tmp_path / "config.json"
|
|
_write_config(config_path, {"lidarr_url": "ftp://bad-scheme:8686"})
|
|
|
|
settings = _make_settings(config_file_path=config_path)
|
|
with pytest.raises(ConfigurationError, match="(?i)critical configuration"):
|
|
settings.load_from_file()
|
|
|
|
def test_valid_config_file_loads(self, tmp_path: Path) -> None:
|
|
config_path = tmp_path / "config.json"
|
|
_write_config(config_path, {"port": 9999, "lidarr_api_key": "new-key"})
|
|
|
|
settings = _make_settings(config_file_path=config_path)
|
|
settings.load_from_file()
|
|
assert settings.port == 9999
|
|
assert settings.lidarr_api_key == "new-key"
|
|
|
|
def test_invalid_log_level_in_file_raises(self, tmp_path: Path) -> None:
|
|
config_path = tmp_path / "config.json"
|
|
_write_config(config_path, {"log_level": "verbose"})
|
|
|
|
settings = _make_settings(config_file_path=config_path)
|
|
with pytest.raises(ConfigurationError, match="(?i)invalid log_level"):
|
|
settings.load_from_file()
|
|
|
|
def test_log_level_normalised_through_file(self, tmp_path: Path) -> None:
|
|
config_path = tmp_path / "config.json"
|
|
_write_config(config_path, {"log_level": "debug"})
|
|
|
|
settings = _make_settings(config_file_path=config_path)
|
|
settings.load_from_file()
|
|
assert settings.log_level == "DEBUG"
|
|
|
|
def test_url_trailing_slash_stripped_through_file(self, tmp_path: Path) -> None:
|
|
config_path = tmp_path / "config.json"
|
|
_write_config(config_path, {"lidarr_url": "http://lidarr:8686/"})
|
|
|
|
settings = _make_settings(config_file_path=config_path)
|
|
settings.load_from_file()
|
|
assert settings.lidarr_url == "http://lidarr:8686"
|
|
|
|
def test_failed_load_does_not_partially_mutate(self, tmp_path: Path) -> None:
|
|
config_path = tmp_path / "config.json"
|
|
_write_config(config_path, {"port": 7777, "lidarr_url": "ftp://bad:1234"})
|
|
|
|
settings = _make_settings(config_file_path=config_path)
|
|
original_port = settings.port
|
|
with pytest.raises(ConfigurationError):
|
|
settings.load_from_file()
|
|
assert settings.port == original_port
|
|
|
|
|
|
# D. Log level application at startup
|
|
|
|
class TestLogLevelApplication:
|
|
def test_log_level_applied_to_root_logger(self) -> None:
|
|
settings = _make_settings(log_level="DEBUG")
|
|
configured_level = getattr(logging, settings.log_level, logging.INFO)
|
|
root = logging.getLogger()
|
|
original = root.level
|
|
try:
|
|
root.setLevel(configured_level)
|
|
assert root.getEffectiveLevel() == logging.DEBUG
|
|
finally:
|
|
root.setLevel(original)
|
|
|
|
def test_log_level_warning_applied(self) -> None:
|
|
settings = _make_settings(log_level="WARNING")
|
|
configured_level = getattr(logging, settings.log_level, logging.INFO)
|
|
root = logging.getLogger()
|
|
original = root.level
|
|
try:
|
|
root.setLevel(configured_level)
|
|
assert root.getEffectiveLevel() == logging.WARNING
|
|
finally:
|
|
root.setLevel(original)
|
|
|
|
|
|
# E. get_settings() cache safety
|
|
|
|
class TestGetSettingsCacheSafety:
|
|
def test_failed_load_does_not_poison_cache(self, tmp_path: Path) -> None:
|
|
import core.config as config_module
|
|
|
|
bad_path = tmp_path / "bad_config.json"
|
|
_write_config(bad_path, {"lidarr_url": "ftp://invalid"})
|
|
|
|
saved = config_module._settings
|
|
try:
|
|
config_module._settings = None
|
|
|
|
with patch.object(
|
|
Settings, "model_post_init", lambda self, _ctx: None
|
|
):
|
|
pass
|
|
|
|
with patch.dict(
|
|
"os.environ",
|
|
{"CONFIG_FILE_PATH": str(bad_path)},
|
|
):
|
|
with pytest.raises((ConfigurationError, Exception)):
|
|
config_module.get_settings()
|
|
|
|
assert config_module._settings is None
|
|
finally:
|
|
config_module._settings = saved
|