diff --git a/infrastructure.md b/infrastructure.md index 3ca76de..73543c2 100644 --- a/infrastructure.md +++ b/infrastructure.md @@ -156,6 +156,32 @@ Main engine for anime series management with async support, progress callbacks, | AuthService | JWT authentication, rate limiting | | ConfigService | Configuration persistence with backups | +## Validation Utilities (`src/server/utils/validators.py`) + +Provides data validation functions for ensuring data integrity across the application. + +### Series Key Validation + +- **`validate_series_key(key)`**: Validates key format (URL-safe, lowercase, hyphens only) + - Valid: `"attack-on-titan"`, `"one-piece"`, `"86-eighty-six"` + - Invalid: `"Attack On Titan"`, `"attack_on_titan"`, `"attack on titan"` +- **`validate_series_key_or_folder(identifier, allow_folder=True)`**: Backward-compatible validation + - Returns tuple `(identifier, is_key)` where `is_key` indicates if it's a valid key format + - Set `allow_folder=False` to require strict key format + +### Other Validators + +| Function | Purpose | +| --------------------------- | ------------------------------------------- | +| `validate_series_name` | Series display name validation | +| `validate_episode_range` | Episode range validation (1-1000) | +| `validate_download_quality` | Quality setting (360p-1080p, best, worst) | +| `validate_language` | Language codes (ger-sub, ger-dub, etc.) | +| `validate_anime_url` | Aniworld.to/s.to URL validation | +| `validate_backup_name` | Backup filename validation | +| `validate_config_data` | Configuration data structure validation | +| `sanitize_filename` | Sanitize filenames for safe filesystem use | + ## Frontend ### Static Files diff --git a/instructions.md b/instructions.md index cc35d00..2dc0917 100644 --- a/instructions.md +++ b/instructions.md @@ -170,35 +170,7 @@ For each task completed: #### Task 4.5: Update Pydantic Models to Use Key ✅ (November 27, 2025) ---- - -#### Task 4.6: Update Validators to Use Key - -**File:** `src/server/utils/validators.py` - -**Objective:** Ensure validation functions validate `key` instead of `folder`. - -**Steps:** - -1. Open `src/server/utils/validators.py` -2. Review all validation functions -3. Add `validate_series_key()` function if not exists -4. Update any validators that check series identifiers -5. Ensure validators accept `key` format (URL-safe, lowercase with hyphens) -6. Add tests for key validation - -**Success Criteria:** - -- [ ] Validators validate `key` format -- [ ] No validators use `folder` for identification -- [ ] Validation functions well-documented -- [ ] All validator tests pass - -**Test Command:** - -```bash -conda run -n AniWorld python -m pytest tests/unit/ -k "validator" -v -``` +#### Task 4.6: Update Validators to Use Key ✅ (November 28, 2025) --- @@ -769,7 +741,7 @@ conda run -n AniWorld python -m pytest tests/integration/test_identifier_consist - [x] Task 4.3: Update Queue API Endpoints ✅ **Completed November 27, 2025** - [x] Task 4.4: Update WebSocket API Endpoints ✅ **Completed November 27, 2025** - [x] Task 4.5: Update Pydantic Models ✅ **Completed November 27, 2025** - - [ ] Task 4.6: Update Validators + - [x] Task 4.6: Update Validators ✅ **Completed November 28, 2025** - [ ] Task 4.7: Update Template Helpers - [ ] Phase 5: Frontend - [ ] Task 5.1: Update Frontend JavaScript diff --git a/src/server/utils/validators.py b/src/server/utils/validators.py index 09497ec..450d637 100644 --- a/src/server/utils/validators.py +++ b/src/server/utils/validators.py @@ -438,6 +438,107 @@ def validate_series_name(name: str) -> str: return name.strip() +def validate_series_key(key: str) -> str: + """ + Validate series key format. + + Series keys are unique, provider-assigned, URL-safe identifiers. + They should be lowercase, use hyphens for word separation, and contain + only alphanumeric characters and hyphens. + + Valid examples: + - "attack-on-titan" + - "one-piece" + - "naruto" + + Invalid examples: + - "Attack On Titan" (uppercase, spaces) + - "attack_on_titan" (underscores) + - "attack on titan" (spaces) + - "" (empty) + + Args: + key: Series key to validate + + Returns: + Validated key (trimmed) + + Raises: + ValueError: If key is invalid + """ + if not key or not isinstance(key, str): + raise ValueError("Series key must be a non-empty string") + + key = key.strip() + + if not key: + raise ValueError("Series key cannot be empty") + + if len(key) > 255: + raise ValueError("Series key must be 255 characters or less") + + # Key must be lowercase, alphanumeric with hyphens only + # Pattern: starts with letter/number, can contain letters, numbers, hyphens + # Cannot start or end with hyphen, no consecutive hyphens + if not re.match(r'^[a-z0-9]+(?:-[a-z0-9]+)*$', key): + raise ValueError( + "Series key must be lowercase, URL-safe, and use hyphens " + "for word separation (e.g., 'attack-on-titan'). " + "No spaces, underscores, or uppercase letters allowed." + ) + + return key + + +def validate_series_key_or_folder( + identifier: str, allow_folder: bool = True +) -> tuple[str, bool]: + """ + Validate an identifier that could be either a series key or folder. + + This function provides backward compatibility during the transition + from folder-based to key-based identification. + + Args: + identifier: The identifier to validate (key or folder) + allow_folder: Whether to allow folder-style identifiers (default: True) + + Returns: + Tuple of (validated_identifier, is_key) where is_key indicates + whether the identifier is a valid key format. + + Raises: + ValueError: If identifier is empty or invalid + """ + if not identifier or not isinstance(identifier, str): + raise ValueError("Identifier must be a non-empty string") + + identifier = identifier.strip() + + if not identifier: + raise ValueError("Identifier cannot be empty") + + # Try to validate as key first + try: + validate_series_key(identifier) + return identifier, True + except ValueError: + pass + + # If not a valid key, check if folder format is allowed + if not allow_folder: + raise ValueError( + f"Invalid series key format: '{identifier}'. " + "Keys must be lowercase with hyphens (e.g., 'attack-on-titan')." + ) + + # Validate as folder (more permissive) + if len(identifier) > 1000: + raise ValueError("Identifier too long (max 1000 characters)") + + return identifier, False + + def validate_backup_name(name: str) -> str: """ Validate backup file name. diff --git a/tests/unit/test_validators.py b/tests/unit/test_validators.py new file mode 100644 index 0000000..a402a99 --- /dev/null +++ b/tests/unit/test_validators.py @@ -0,0 +1,533 @@ +""" +Unit tests for data validation utilities. + +Tests the validators module in src/server/utils/validators.py. +""" + +import pytest + +from src.server.utils.validators import ( + ValidatorMixin, + validate_series_key, + validate_series_key_or_folder, + validate_series_name, + validate_episode_range, + validate_download_quality, + validate_language, + validate_download_priority, + validate_anime_url, + validate_backup_name, + validate_config_data, + sanitize_filename, + validate_jwt_token, + validate_ip_address, + validate_websocket_message, +) + + +class TestValidateSeriesKey: + """Tests for validate_series_key function.""" + + def test_valid_simple_key(self): + """Test valid simple key.""" + assert validate_series_key("naruto") == "naruto" + + def test_valid_hyphenated_key(self): + """Test valid hyphenated key.""" + assert validate_series_key("attack-on-titan") == "attack-on-titan" + + def test_valid_numeric_key(self): + """Test valid key with numbers.""" + assert validate_series_key("one-piece-2024") == "one-piece-2024" + + def test_valid_key_starting_with_number(self): + """Test valid key starting with number.""" + assert validate_series_key("86-eighty-six") == "86-eighty-six" + + def test_strips_whitespace(self): + """Test that whitespace is stripped.""" + assert validate_series_key(" naruto ") == "naruto" + + def test_empty_string_raises(self): + """Test empty string raises ValueError.""" + with pytest.raises(ValueError, match="non-empty string"): + validate_series_key("") + + def test_none_raises(self): + """Test None raises ValueError.""" + with pytest.raises(ValueError, match="non-empty string"): + validate_series_key(None) + + def test_whitespace_only_raises(self): + """Test whitespace-only string raises ValueError.""" + with pytest.raises(ValueError, match="cannot be empty"): + validate_series_key(" ") + + def test_uppercase_raises(self): + """Test uppercase letters raise ValueError.""" + with pytest.raises(ValueError, match="lowercase"): + validate_series_key("Attack-On-Titan") + + def test_spaces_raises(self): + """Test spaces raise ValueError.""" + with pytest.raises(ValueError, match="lowercase"): + validate_series_key("attack on titan") + + def test_underscores_raises(self): + """Test underscores raise ValueError.""" + with pytest.raises(ValueError, match="lowercase"): + validate_series_key("attack_on_titan") + + def test_special_characters_raises(self): + """Test special characters raise ValueError.""" + with pytest.raises(ValueError, match="lowercase"): + validate_series_key("attack@titan") + + def test_leading_hyphen_raises(self): + """Test leading hyphen raises ValueError.""" + with pytest.raises(ValueError, match="lowercase"): + validate_series_key("-attack-on-titan") + + def test_trailing_hyphen_raises(self): + """Test trailing hyphen raises ValueError.""" + with pytest.raises(ValueError, match="lowercase"): + validate_series_key("attack-on-titan-") + + def test_consecutive_hyphens_raises(self): + """Test consecutive hyphens raise ValueError.""" + with pytest.raises(ValueError, match="lowercase"): + validate_series_key("attack--on--titan") + + def test_key_too_long_raises(self): + """Test key exceeding 255 chars raises ValueError.""" + long_key = "a" * 256 + with pytest.raises(ValueError, match="255 characters"): + validate_series_key(long_key) + + def test_max_length_key(self): + """Test key at exactly 255 chars is valid.""" + max_key = "a" * 255 + assert validate_series_key(max_key) == max_key + + +class TestValidateSeriesKeyOrFolder: + """Tests for validate_series_key_or_folder function.""" + + def test_valid_key_returns_key_true(self): + """Test valid key returns (key, True).""" + result = validate_series_key_or_folder("attack-on-titan") + assert result == ("attack-on-titan", True) + + def test_valid_folder_returns_folder_false(self): + """Test valid folder returns (folder, False).""" + result = validate_series_key_or_folder("Attack on Titan (2013)") + assert result == ("Attack on Titan (2013)", False) + + def test_empty_string_raises(self): + """Test empty string raises ValueError.""" + with pytest.raises(ValueError, match="non-empty string"): + validate_series_key_or_folder("") + + def test_none_raises(self): + """Test None raises ValueError.""" + with pytest.raises(ValueError, match="non-empty string"): + validate_series_key_or_folder(None) + + def test_whitespace_only_raises(self): + """Test whitespace-only string raises ValueError.""" + with pytest.raises(ValueError, match="cannot be empty"): + validate_series_key_or_folder(" ") + + def test_folder_not_allowed_raises(self): + """Test folder format raises when not allowed.""" + with pytest.raises(ValueError, match="Invalid series key format"): + validate_series_key_or_folder( + "Attack on Titan (2013)", allow_folder=False + ) + + def test_key_allowed_when_folder_disabled(self): + """Test valid key works when folder is disabled.""" + result = validate_series_key_or_folder( + "attack-on-titan", allow_folder=False + ) + assert result == ("attack-on-titan", True) + + def test_strips_whitespace(self): + """Test that whitespace is stripped.""" + result = validate_series_key_or_folder(" attack-on-titan ") + assert result == ("attack-on-titan", True) + + def test_folder_too_long_raises(self): + """Test folder exceeding 1000 chars raises ValueError.""" + long_folder = "A" * 1001 + with pytest.raises(ValueError, match="too long"): + validate_series_key_or_folder(long_folder) + + +class TestValidateSeriesName: + """Tests for validate_series_name function.""" + + def test_valid_name(self): + """Test valid series name.""" + assert validate_series_name("Attack on Titan") == "Attack on Titan" + + def test_strips_whitespace(self): + """Test whitespace is stripped.""" + assert validate_series_name(" Naruto ") == "Naruto" + + def test_empty_raises(self): + """Test empty name raises ValueError.""" + with pytest.raises(ValueError, match="cannot be empty"): + validate_series_name("") + + def test_too_long_raises(self): + """Test name over 200 chars raises ValueError.""" + with pytest.raises(ValueError, match="too long"): + validate_series_name("A" * 201) + + def test_invalid_chars_raises(self): + """Test invalid characters raise ValueError.""" + with pytest.raises(ValueError, match="invalid character"): + validate_series_name("Attack: Titan") + + +class TestValidateEpisodeRange: + """Tests for validate_episode_range function.""" + + def test_valid_range(self): + """Test valid episode range.""" + assert validate_episode_range(1, 10) == (1, 10) + + def test_same_start_end(self): + """Test start equals end is valid.""" + assert validate_episode_range(5, 5) == (5, 5) + + def test_start_less_than_one_raises(self): + """Test start less than 1 raises ValueError.""" + with pytest.raises(ValueError, match="at least 1"): + validate_episode_range(0, 10) + + def test_end_less_than_start_raises(self): + """Test end less than start raises ValueError.""" + with pytest.raises(ValueError, match="greater than or equal"): + validate_episode_range(10, 5) + + def test_range_too_large_raises(self): + """Test range over 1000 raises ValueError.""" + with pytest.raises(ValueError, match="too large"): + validate_episode_range(1, 1002) + + +class TestValidateDownloadQuality: + """Tests for validate_download_quality function.""" + + @pytest.mark.parametrize("quality", [ + "360p", "480p", "720p", "1080p", "best", "worst" + ]) + def test_valid_qualities(self, quality): + """Test all valid quality values.""" + assert validate_download_quality(quality) == quality + + def test_invalid_quality_raises(self): + """Test invalid quality raises ValueError.""" + with pytest.raises(ValueError, match="Invalid quality"): + validate_download_quality("4k") + + +class TestValidateLanguage: + """Tests for validate_language function.""" + + @pytest.mark.parametrize("language", [ + "ger-sub", "ger-dub", "eng-sub", "eng-dub", "jpn" + ]) + def test_valid_languages(self, language): + """Test all valid language values.""" + assert validate_language(language) == language + + def test_invalid_language_raises(self): + """Test invalid language raises ValueError.""" + with pytest.raises(ValueError, match="Invalid language"): + validate_language("spanish") + + +class TestValidateDownloadPriority: + """Tests for validate_download_priority function.""" + + def test_valid_priority_min(self): + """Test minimum priority is valid.""" + assert validate_download_priority(0) == 0 + + def test_valid_priority_max(self): + """Test maximum priority is valid.""" + assert validate_download_priority(10) == 10 + + def test_negative_priority_raises(self): + """Test negative priority raises ValueError.""" + with pytest.raises(ValueError, match="between 0 and 10"): + validate_download_priority(-1) + + def test_priority_too_high_raises(self): + """Test priority over 10 raises ValueError.""" + with pytest.raises(ValueError, match="between 0 and 10"): + validate_download_priority(11) + + +class TestValidateAnimeUrl: + """Tests for validate_anime_url function.""" + + def test_valid_aniworld_url(self): + """Test valid aniworld.to URL.""" + url = "https://aniworld.to/anime/stream/attack-on-titan" + assert validate_anime_url(url) == url + + def test_valid_s_to_url(self): + """Test valid s.to URL.""" + url = "https://s.to/serie/stream/naruto" + assert validate_anime_url(url) == url + + def test_empty_url_raises(self): + """Test empty URL raises ValueError.""" + with pytest.raises(ValueError, match="cannot be empty"): + validate_anime_url("") + + def test_invalid_domain_raises(self): + """Test invalid domain raises ValueError.""" + with pytest.raises(ValueError, match="aniworld.to or s.to"): + validate_anime_url("https://example.com/anime") + + +class TestValidateBackupName: + """Tests for validate_backup_name function.""" + + def test_valid_backup_name(self): + """Test valid backup name.""" + assert validate_backup_name("backup-2024.json") == "backup-2024.json" + + def test_empty_raises(self): + """Test empty name raises ValueError.""" + with pytest.raises(ValueError, match="cannot be empty"): + validate_backup_name("") + + def test_invalid_chars_raises(self): + """Test invalid characters raise ValueError.""" + with pytest.raises(ValueError, match="only contain"): + validate_backup_name("backup name.json") + + def test_no_json_extension_raises(self): + """Test missing .json raises ValueError.""" + with pytest.raises(ValueError, match="end with .json"): + validate_backup_name("backup.txt") + + +class TestValidateConfigData: + """Tests for validate_config_data function.""" + + def test_valid_config(self): + """Test valid config data.""" + data = { + "download_directory": "/downloads", + "concurrent_downloads": 3 + } + assert validate_config_data(data) == data + + def test_missing_keys_raises(self): + """Test missing required keys raises ValueError.""" + with pytest.raises(ValueError, match="missing required keys"): + validate_config_data({"download_directory": "/downloads"}) + + def test_invalid_concurrent_downloads_raises(self): + """Test invalid concurrent_downloads raises ValueError.""" + with pytest.raises(ValueError, match="between 1 and 10"): + validate_config_data({ + "download_directory": "/downloads", + "concurrent_downloads": 15 + }) + + +class TestSanitizeFilename: + """Tests for sanitize_filename function.""" + + def test_valid_filename(self): + """Test valid filename unchanged.""" + assert sanitize_filename("episode-01.mp4") == "episode-01.mp4" + + def test_removes_invalid_chars(self): + """Test invalid characters are replaced.""" + result = sanitize_filename("file<>name.mp4") + assert "<" not in result + assert ">" not in result + + def test_strips_dots_spaces(self): + """Test leading/trailing dots and spaces removed.""" + assert sanitize_filename(" .filename. ") == "filename" + + def test_empty_becomes_unnamed(self): + """Test empty filename becomes 'unnamed'.""" + assert sanitize_filename("") == "unnamed" + + +class TestValidateJwtToken: + """Tests for validate_jwt_token function.""" + + def test_valid_token_format(self): + """Test valid JWT token format.""" + token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" # noqa: E501 + assert validate_jwt_token(token) == token + + def test_empty_raises(self): + """Test empty token raises ValueError.""" + with pytest.raises(ValueError, match="non-empty string"): + validate_jwt_token("") + + def test_invalid_format_raises(self): + """Test invalid format raises ValueError.""" + with pytest.raises(ValueError, match="Invalid JWT"): + validate_jwt_token("not-a-jwt-token") + + +class TestValidateIpAddress: + """Tests for validate_ip_address function.""" + + def test_valid_ipv4(self): + """Test valid IPv4 address.""" + assert validate_ip_address("192.168.1.1") == "192.168.1.1" + + def test_valid_ipv4_localhost(self): + """Test localhost IPv4.""" + assert validate_ip_address("127.0.0.1") == "127.0.0.1" + + def test_empty_raises(self): + """Test empty IP raises ValueError.""" + with pytest.raises(ValueError, match="non-empty string"): + validate_ip_address("") + + def test_invalid_ip_raises(self): + """Test invalid IP raises ValueError.""" + with pytest.raises(ValueError, match="Invalid IP"): + validate_ip_address("not-an-ip") + + +class TestValidateWebsocketMessage: + """Tests for validate_websocket_message function.""" + + def test_valid_message(self): + """Test valid WebSocket message.""" + msg = {"type": "download_progress", "data": {}} + assert validate_websocket_message(msg) == msg + + def test_missing_type_raises(self): + """Test missing type raises ValueError.""" + with pytest.raises(ValueError, match="missing required keys"): + validate_websocket_message({"data": {}}) + + def test_invalid_type_raises(self): + """Test invalid type raises ValueError.""" + with pytest.raises(ValueError, match="Invalid message type"): + validate_websocket_message({"type": "invalid_type"}) + + +class TestValidatorMixin: + """Tests for ValidatorMixin class methods.""" + + def test_validate_password_strength_valid(self): + """Test valid password passes.""" + password = "SecurePass123!" + assert ValidatorMixin.validate_password_strength(password) == password + + def test_validate_password_too_short_raises(self): + """Test short password raises ValueError.""" + with pytest.raises(ValueError, match="8 characters"): + ValidatorMixin.validate_password_strength("Short1!") + + def test_validate_password_no_uppercase_raises(self): + """Test no uppercase raises ValueError.""" + with pytest.raises(ValueError, match="uppercase"): + ValidatorMixin.validate_password_strength("lowercase123!") + + def test_validate_password_no_lowercase_raises(self): + """Test no lowercase raises ValueError.""" + with pytest.raises(ValueError, match="lowercase"): + ValidatorMixin.validate_password_strength("UPPERCASE123!") + + def test_validate_password_no_digit_raises(self): + """Test no digit raises ValueError.""" + with pytest.raises(ValueError, match="digit"): + ValidatorMixin.validate_password_strength("NoDigitsHere!") + + def test_validate_password_no_special_raises(self): + """Test no special char raises ValueError.""" + with pytest.raises(ValueError, match="special character"): + ValidatorMixin.validate_password_strength("NoSpecial123") + + def test_validate_url_valid(self): + """Test valid URL.""" + url = "https://example.com/path" + assert ValidatorMixin.validate_url(url) == url + + def test_validate_url_invalid_raises(self): + """Test invalid URL raises ValueError.""" + with pytest.raises(ValueError, match="Invalid URL"): + ValidatorMixin.validate_url("not-a-url") + + def test_validate_port_valid(self): + """Test valid port.""" + assert ValidatorMixin.validate_port(8080) == 8080 + + def test_validate_port_invalid_raises(self): + """Test invalid port raises ValueError.""" + with pytest.raises(ValueError, match="between 1 and 65535"): + ValidatorMixin.validate_port(70000) + + def test_validate_positive_integer_valid(self): + """Test valid positive integer.""" + assert ValidatorMixin.validate_positive_integer(5) == 5 + + def test_validate_positive_integer_zero_raises(self): + """Test zero raises ValueError.""" + with pytest.raises(ValueError, match="must be positive"): + ValidatorMixin.validate_positive_integer(0) + + def test_validate_non_negative_integer_valid(self): + """Test valid non-negative integer.""" + assert ValidatorMixin.validate_non_negative_integer(0) == 0 + + def test_validate_non_negative_integer_negative_raises(self): + """Test negative raises ValueError.""" + with pytest.raises(ValueError, match="cannot be negative"): + ValidatorMixin.validate_non_negative_integer(-1) + + def test_validate_string_length_valid(self): + """Test valid string length.""" + result = ValidatorMixin.validate_string_length("test", 1, 10) + assert result == "test" + + def test_validate_string_length_too_short_raises(self): + """Test too short raises ValueError.""" + with pytest.raises(ValueError, match="at least"): + ValidatorMixin.validate_string_length("ab", min_length=5) + + def test_validate_string_length_too_long_raises(self): + """Test too long raises ValueError.""" + with pytest.raises(ValueError, match="at most"): + ValidatorMixin.validate_string_length("abcdefgh", max_length=5) + + def test_validate_choice_valid(self): + """Test valid choice.""" + result = ValidatorMixin.validate_choice("a", ["a", "b", "c"]) + assert result == "a" + + def test_validate_choice_invalid_raises(self): + """Test invalid choice raises ValueError.""" + with pytest.raises(ValueError, match="must be one of"): + ValidatorMixin.validate_choice("d", ["a", "b", "c"]) + + def test_validate_dict_keys_valid(self): + """Test valid dict with required keys.""" + data = {"a": 1, "b": 2} + result = ValidatorMixin.validate_dict_keys(data, ["a", "b"]) + assert result == data + + def test_validate_dict_keys_missing_raises(self): + """Test missing keys raises ValueError.""" + with pytest.raises(ValueError, match="missing required keys"): + ValidatorMixin.validate_dict_keys({"a": 1}, ["a", "b"])