Files
Aniworld/tests/unit/test_settings_validation.py

507 lines
19 KiB
Python

"""Unit tests for Settings configuration and validation.
Tests cover:
- Environment variable parsing
- Default values application
- Settings validation
- CORS origins parsing
- Secret generation
- Configuration properties
"""
import os
from unittest.mock import patch
import pytest
from pydantic import ValidationError
from src.config.settings import Settings, settings
class TestSettingsDefaults:
"""Tests for default settings values."""
def test_jwt_secret_key_generated(self):
"""Test that JWT secret key is auto-generated if not provided."""
s = Settings()
assert s.jwt_secret_key is not None
assert len(s.jwt_secret_key) > 0
def test_jwt_secret_key_unique(self):
"""Test that each Settings instance gets unique JWT key."""
s1 = Settings()
s2 = Settings()
assert s1.jwt_secret_key != s2.jwt_secret_key
def test_password_salt_default(self):
"""Test default password salt."""
s = Settings()
assert s.password_salt == "default-salt"
def test_master_password_hash_default_none(self):
"""Test master password hash defaults to None."""
s = Settings()
assert s.master_password_hash is None
def test_master_password_default_none(self):
"""Test master password defaults to None."""
s = Settings()
assert s.master_password is None
def test_token_expiry_hours_default(self):
"""Test default token expiry hours."""
s = Settings()
assert s.token_expiry_hours == 24
def test_anime_directory_default(self):
"""Test anime directory defaults to empty string."""
s = Settings()
assert s.anime_directory == ""
def test_log_level_default(self):
"""Test default log level."""
s = Settings()
assert s.log_level == "INFO"
def test_database_url_default(self):
"""Test default database URL."""
s = Settings()
assert s.database_url == "sqlite:///./data/aniworld.db"
def test_cors_origins_default(self):
"""Test default CORS origins."""
s = Settings()
assert s.cors_origins == "http://localhost:3000"
def test_api_rate_limit_default(self):
"""Test default API rate limit."""
s = Settings()
assert s.api_rate_limit == 100
def test_default_provider_default(self):
"""Test default provider."""
s = Settings()
assert s.default_provider == "aniworld.to"
def test_provider_timeout_default(self):
"""Test default provider timeout."""
s = Settings()
assert s.provider_timeout == 30
def test_retry_attempts_default(self):
"""Test default retry attempts."""
s = Settings()
assert s.retry_attempts == 3
class TestNFOSettingsDefaults:
"""Tests for NFO-related settings defaults."""
def test_tmdb_api_key_default_none(self):
"""Test TMDB API key defaults to None."""
s = Settings()
assert s.tmdb_api_key is None
def test_nfo_auto_create_default(self):
"""Test NFO auto-create defaults to False."""
s = Settings()
assert s.nfo_auto_create is False
def test_nfo_update_on_scan_default(self):
"""Test NFO update on scan defaults to False."""
s = Settings()
assert s.nfo_update_on_scan is False
def test_nfo_download_poster_default(self):
"""Test NFO download poster defaults to True."""
s = Settings()
assert s.nfo_download_poster is True
def test_nfo_download_logo_default(self):
"""Test NFO download logo defaults to True."""
s = Settings()
assert s.nfo_download_logo is True
def test_nfo_download_fanart_default(self):
"""Test NFO download fanart defaults to True."""
s = Settings()
assert s.nfo_download_fanart is True
def test_nfo_image_size_default(self):
"""Test NFO image size defaults to 'original'."""
s = Settings()
assert s.nfo_image_size == "original"
def test_nfo_prefer_fsk_rating_default(self):
"""Test NFO prefer FSK rating defaults to True."""
s = Settings()
assert s.nfo_prefer_fsk_rating is True
class TestEnvironmentVariableParsing:
"""Tests for environment variable parsing."""
def test_jwt_secret_key_from_env(self):
"""Test loading JWT secret key from environment."""
with patch.dict(os.environ, {"JWT_SECRET_KEY": "test-secret-key"}):
s = Settings()
assert s.jwt_secret_key == "test-secret-key"
def test_password_salt_from_env(self):
"""Test loading password salt from environment."""
with patch.dict(os.environ, {"PASSWORD_SALT": "custom-salt"}):
s = Settings()
assert s.password_salt == "custom-salt"
def test_master_password_hash_from_env(self):
"""Test loading master password hash from environment."""
with patch.dict(os.environ, {"MASTER_PASSWORD_HASH": "hash123"}):
s = Settings()
assert s.master_password_hash == "hash123"
def test_master_password_from_env(self):
"""Test loading master password from environment."""
with patch.dict(os.environ, {"MASTER_PASSWORD": "dev-password"}):
s = Settings()
assert s.master_password == "dev-password"
def test_token_expiry_hours_from_env(self):
"""Test loading token expiry from environment."""
with patch.dict(os.environ, {"SESSION_TIMEOUT_HOURS": "48"}):
s = Settings()
assert s.token_expiry_hours == 48
def test_anime_directory_from_env(self):
"""Test loading anime directory from environment."""
with patch.dict(os.environ, {"ANIME_DIRECTORY": "/media/anime"}):
s = Settings()
assert s.anime_directory == "/media/anime"
def test_log_level_from_env(self):
"""Test loading log level from environment."""
with patch.dict(os.environ, {"LOG_LEVEL": "DEBUG"}):
s = Settings()
assert s.log_level == "DEBUG"
def test_database_url_from_env(self):
"""Test loading database URL from environment."""
with patch.dict(os.environ, {"DATABASE_URL": "postgresql://localhost/aniworld"}):
s = Settings()
assert s.database_url == "postgresql://localhost/aniworld"
def test_cors_origins_from_env(self):
"""Test loading CORS origins from environment."""
with patch.dict(os.environ, {"CORS_ORIGINS": "http://example.com"}):
s = Settings()
assert s.cors_origins == "http://example.com"
def test_api_rate_limit_from_env(self):
"""Test loading API rate limit from environment."""
with patch.dict(os.environ, {"API_RATE_LIMIT": "200"}):
s = Settings()
assert s.api_rate_limit == 200
def test_default_provider_from_env(self):
"""Test loading default provider from environment."""
with patch.dict(os.environ, {"DEFAULT_PROVIDER": "custom.provider"}):
s = Settings()
assert s.default_provider == "custom.provider"
def test_provider_timeout_from_env(self):
"""Test loading provider timeout from environment."""
with patch.dict(os.environ, {"PROVIDER_TIMEOUT": "60"}):
s = Settings()
assert s.provider_timeout == 60
def test_retry_attempts_from_env(self):
"""Test loading retry attempts from environment."""
with patch.dict(os.environ, {"RETRY_ATTEMPTS": "5"}):
s = Settings()
assert s.retry_attempts == 5
def test_tmdb_api_key_from_env(self):
"""Test loading TMDB API key from environment."""
with patch.dict(os.environ, {"TMDB_API_KEY": "tmdb-key-123"}):
s = Settings()
assert s.tmdb_api_key == "tmdb-key-123"
class TestNFOEnvironmentVariables:
"""Tests for NFO settings from environment."""
def test_nfo_auto_create_from_env_true(self):
"""Test loading NFO auto create as true from environment."""
with patch.dict(os.environ, {"NFO_AUTO_CREATE": "true"}):
s = Settings()
assert s.nfo_auto_create is True
def test_nfo_auto_create_from_env_false(self):
"""Test loading NFO auto create as false from environment."""
with patch.dict(os.environ, {"NFO_AUTO_CREATE": "false"}):
s = Settings()
assert s.nfo_auto_create is False
def test_nfo_update_on_scan_from_env(self):
"""Test loading NFO update on scan from environment."""
with patch.dict(os.environ, {"NFO_UPDATE_ON_SCAN": "true"}):
s = Settings()
assert s.nfo_update_on_scan is True
def test_nfo_download_poster_from_env_false(self):
"""Test loading NFO download poster as false."""
with patch.dict(os.environ, {"NFO_DOWNLOAD_POSTER": "false"}):
s = Settings()
assert s.nfo_download_poster is False
def test_nfo_download_logo_from_env_false(self):
"""Test loading NFO download logo as false."""
with patch.dict(os.environ, {"NFO_DOWNLOAD_LOGO": "false"}):
s = Settings()
assert s.nfo_download_logo is False
def test_nfo_download_fanart_from_env_false(self):
"""Test loading NFO download fanart as false."""
with patch.dict(os.environ, {"NFO_DOWNLOAD_FANART": "false"}):
s = Settings()
assert s.nfo_download_fanart is False
def test_nfo_image_size_from_env(self):
"""Test loading NFO image size from environment."""
with patch.dict(os.environ, {"NFO_IMAGE_SIZE": "w500"}):
s = Settings()
assert s.nfo_image_size == "w500"
def test_nfo_prefer_fsk_rating_from_env_false(self):
"""Test loading NFO prefer FSK rating as false."""
with patch.dict(os.environ, {"NFO_PREFER_FSK_RATING": "false"}):
s = Settings()
assert s.nfo_prefer_fsk_rating is False
class TestAllowedOriginsProperty:
"""Tests for allowed_origins property."""
def test_allowed_origins_single(self):
"""Test parsing single CORS origin."""
with patch.dict(os.environ, {"CORS_ORIGINS": "http://localhost:3000"}):
s = Settings()
origins = s.allowed_origins
assert origins == ["http://localhost:3000"]
def test_allowed_origins_multiple(self):
"""Test parsing multiple CORS origins."""
with patch.dict(os.environ, {"CORS_ORIGINS": "http://localhost:3000,http://example.com"}):
s = Settings()
origins = s.allowed_origins
assert len(origins) == 2
assert "http://localhost:3000" in origins
assert "http://example.com" in origins
def test_allowed_origins_with_spaces(self):
"""Test parsing CORS origins with extra spaces."""
with patch.dict(os.environ, {"CORS_ORIGINS": "http://localhost:3000 , http://example.com "}):
s = Settings()
origins = s.allowed_origins
assert len(origins) == 2
assert "http://localhost:3000" in origins
assert "http://example.com" in origins
def test_allowed_origins_wildcard_safe_fallback(self):
"""Test that wildcard falls back to safe defaults."""
with patch.dict(os.environ, {"CORS_ORIGINS": "*"}):
s = Settings()
origins = s.allowed_origins
assert "http://localhost:3000" in origins
assert "http://localhost:8000" in origins
# Should not allow all origins
assert "*" not in origins
def test_allowed_origins_empty_string(self):
"""Test parsing empty CORS origins."""
with patch.dict(os.environ, {"CORS_ORIGINS": ""}):
s = Settings()
origins = s.allowed_origins
assert origins == []
def test_allowed_origins_whitespace_only(self):
"""Test parsing whitespace-only CORS origins."""
with patch.dict(os.environ, {"CORS_ORIGINS": " "}):
s = Settings()
origins = s.allowed_origins
assert origins == []
def test_allowed_origins_filters_empty_items(self):
"""Test that empty items are filtered from comma-separated list."""
with patch.dict(os.environ, {"CORS_ORIGINS": "http://localhost:3000,,http://example.com,"}):
s = Settings()
origins = s.allowed_origins
assert len(origins) == 2
assert "http://localhost:3000" in origins
assert "http://example.com" in origins
class TestSettingsValidation:
"""Tests for settings validation."""
def test_invalid_token_expiry_hours_type(self):
"""Test that invalid token expiry type raises validation error."""
with patch.dict(os.environ, {"SESSION_TIMEOUT_HOURS": "not-a-number"}):
with pytest.raises(ValidationError):
Settings()
def test_invalid_api_rate_limit_type(self):
"""Test that invalid rate limit type raises validation error."""
with patch.dict(os.environ, {"API_RATE_LIMIT": "not-a-number"}):
with pytest.raises(ValidationError):
Settings()
def test_invalid_provider_timeout_type(self):
"""Test that invalid timeout type raises validation error."""
with patch.dict(os.environ, {"PROVIDER_TIMEOUT": "not-a-number"}):
with pytest.raises(ValidationError):
Settings()
def test_invalid_retry_attempts_type(self):
"""Test that invalid retry attempts type raises validation error."""
with patch.dict(os.environ, {"RETRY_ATTEMPTS": "not-a-number"}):
with pytest.raises(ValidationError):
Settings()
def test_invalid_boolean_value(self):
"""Test that invalid boolean raises validation error."""
with patch.dict(os.environ, {"NFO_AUTO_CREATE": "not-a-boolean"}):
with pytest.raises(ValidationError):
Settings()
class TestGlobalSettingsInstance:
"""Tests for global settings instance."""
def test_settings_instance_exists(self):
"""Test that global settings instance is created."""
assert settings is not None
assert isinstance(settings, Settings)
def test_settings_has_jwt_secret_key(self):
"""Test that global settings has JWT secret key."""
assert settings.jwt_secret_key is not None
assert len(settings.jwt_secret_key) > 0
def test_settings_has_default_database_url(self):
"""Test that global settings has default database URL."""
# Will be default unless overridden
assert settings.database_url is not None
class TestSettingsExtraIgnored:
"""Tests for extra fields handling."""
def test_unknown_env_vars_ignored(self):
"""Test that unknown environment variables are ignored."""
with patch.dict(os.environ, {"UNKNOWN_SETTING": "value", "JWT_SECRET_KEY": "test-key"}):
s = Settings()
assert s.jwt_secret_key == "test-key"
# Should not raise error for unknown setting
assert not hasattr(s, "UNKNOWN_SETTING")
assert not hasattr(s, "unknown_setting")
class TestSettingsEdgeCases:
"""Tests for edge cases in settings."""
def test_numeric_string_values(self):
"""Test that numeric strings are correctly parsed."""
with patch.dict(os.environ, {
"API_RATE_LIMIT": "250",
"PROVIDER_TIMEOUT": "45",
"RETRY_ATTEMPTS": "7"
}):
s = Settings()
assert s.api_rate_limit == 250
assert s.provider_timeout == 45
assert s.retry_attempts == 7
def test_boolean_string_variations(self):
"""Test different boolean string representations."""
# Test true variations
with patch.dict(os.environ, {"NFO_AUTO_CREATE": "1"}):
s = Settings()
assert s.nfo_auto_create is True
with patch.dict(os.environ, {"NFO_AUTO_CREATE": "yes"}):
s = Settings()
assert s.nfo_auto_create is True
# Test false variations
with patch.dict(os.environ, {"NFO_AUTO_CREATE": "0"}):
s = Settings()
assert s.nfo_auto_create is False
with patch.dict(os.environ, {"NFO_AUTO_CREATE": "no"}):
s = Settings()
assert s.nfo_auto_create is False
def test_empty_anime_directory_valid(self):
"""Test that empty anime directory is valid."""
with patch.dict(os.environ, {"ANIME_DIRECTORY": ""}):
s = Settings()
assert s.anime_directory == ""
def test_path_with_spaces(self):
"""Test that paths with spaces are preserved."""
with patch.dict(os.environ, {"ANIME_DIRECTORY": "/media/my anime"}):
s = Settings()
assert s.anime_directory == "/media/my anime"
def test_url_formats(self):
"""Test various database URL formats."""
urls = [
"sqlite:///./data/aniworld.db",
"postgresql://user:pass@localhost/db",
"mysql://localhost:3306/aniworld",
]
for url in urls:
with patch.dict(os.environ, {"DATABASE_URL": url}):
s = Settings()
assert s.database_url == url
def test_log_level_case_insensitive(self):
"""Test that log level accepts different cases."""
levels = ["DEBUG", "debug", "Info", "WARNING", "ERROR"]
for level in levels:
with patch.dict(os.environ, {"LOG_LEVEL": level}):
s = Settings()
assert s.log_level == level
class TestSecurityConsiderations:
"""Tests for security-related settings behavior."""
def test_jwt_secret_is_different_each_time(self):
"""Test that JWT secret is not predictable."""
secrets_generated = set()
for _ in range(10):
s = Settings()
secrets_generated.add(s.jwt_secret_key)
# All should be unique
assert len(secrets_generated) == 10
def test_master_password_warning_documented(self):
"""Test that master_password field has warning documentation."""
# Check that the field metadata includes warning
field_info = Settings.model_fields["master_password"]
description = field_info.description
assert description is not None
assert "DEVELOPMENT ONLY" in description
assert "NEVER" in description.upper()
def test_both_password_fields_can_be_none(self):
"""Test that both password fields being None is valid."""
s = Settings()
assert s.master_password is None
assert s.master_password_hash is None
# This is valid - password will be set during setup