""" Unit tests for filesystem utilities. Tests the sanitize_folder_name function and related filesystem utilities. """ import os import tempfile import pytest from src.server.utils.filesystem import ( MAX_FOLDER_NAME_LENGTH, create_safe_folder, is_safe_path, sanitize_folder_name, ) class TestSanitizeFolderName: """Test sanitize_folder_name function.""" def test_simple_name(self): """Test sanitizing a simple name with no special characters.""" assert sanitize_folder_name("Attack on Titan") == "Attack on Titan" def test_name_with_colon(self): """Test sanitizing name with colon.""" result = sanitize_folder_name("Attack on Titan: Final Season") assert ":" not in result assert result == "Attack on Titan Final Season" def test_name_with_question_mark(self): """Test sanitizing name with question mark.""" result = sanitize_folder_name("What If...?") assert "?" not in result # Trailing dots are stripped assert result == "What If" def test_name_with_multiple_special_chars(self): """Test sanitizing name with multiple special characters.""" result = sanitize_folder_name('Test: "Episode" <1> | Part?') # All invalid chars should be removed assert ":" not in result assert '"' not in result assert "<" not in result assert ">" not in result assert "|" not in result assert "?" not in result def test_name_with_forward_slash(self): """Test sanitizing name with forward slash.""" result = sanitize_folder_name("Attack/Titan") assert "/" not in result def test_name_with_backslash(self): """Test sanitizing name with backslash.""" result = sanitize_folder_name("Attack\\Titan") assert "\\" not in result def test_unicode_characters_preserved(self): """Test that Unicode characters are preserved.""" # Japanese title result = sanitize_folder_name("進撃の巨人") assert result == "進撃の巨人" def test_mixed_unicode_and_special(self): """Test mixed Unicode and special characters.""" result = sanitize_folder_name("Re:ゼロ") assert ":" not in result assert "ゼロ" in result def test_leading_dots_removed(self): """Test that leading dots are removed.""" result = sanitize_folder_name("...Hidden Folder") assert not result.startswith(".") def test_trailing_dots_removed(self): """Test that trailing dots are removed.""" result = sanitize_folder_name("Folder Name...") assert not result.endswith(".") def test_leading_spaces_removed(self): """Test that leading spaces are removed.""" result = sanitize_folder_name(" Attack on Titan") assert result == "Attack on Titan" def test_trailing_spaces_removed(self): """Test that trailing spaces are removed.""" result = sanitize_folder_name("Attack on Titan ") assert result == "Attack on Titan" def test_multiple_spaces_collapsed(self): """Test that multiple consecutive spaces are collapsed.""" result = sanitize_folder_name("Attack on Titan") assert result == "Attack on Titan" def test_null_byte_removed(self): """Test that null byte is removed.""" result = sanitize_folder_name("Attack\x00Titan") assert "\x00" not in result def test_newline_removed(self): """Test that newline is removed.""" result = sanitize_folder_name("Attack\nTitan") assert "\n" not in result def test_tab_removed(self): """Test that tab is removed.""" result = sanitize_folder_name("Attack\tTitan") assert "\t" not in result def test_none_raises_error(self): """Test that None raises ValueError.""" with pytest.raises(ValueError, match="cannot be None"): sanitize_folder_name(None) def test_empty_string_raises_error(self): """Test that empty string raises ValueError.""" with pytest.raises(ValueError, match="cannot be empty"): sanitize_folder_name("") def test_whitespace_only_raises_error(self): """Test that whitespace-only string raises ValueError.""" with pytest.raises(ValueError, match="cannot be empty"): sanitize_folder_name(" ") def test_only_invalid_chars_raises_error(self): """Test that string with only invalid characters raises ValueError.""" with pytest.raises(ValueError, match="only invalid characters"): sanitize_folder_name("???:::***") def test_max_length_truncation(self): """Test that long names are truncated.""" long_name = "A" * 300 result = sanitize_folder_name(long_name) assert len(result) <= MAX_FOLDER_NAME_LENGTH def test_max_length_custom(self): """Test custom max length.""" result = sanitize_folder_name("Attack on Titan", max_length=10) assert len(result) <= 10 def test_truncation_at_word_boundary(self): """Test that truncation happens at word boundary when possible.""" result = sanitize_folder_name( "The Very Long Anime Title That Needs Truncation", max_length=25 ) # Should truncate at word boundary assert len(result) <= 25 assert not result.endswith(" ") def test_custom_replacement_character(self): """Test custom replacement character.""" result = sanitize_folder_name("Test:Name", replacement="_") assert ":" not in result assert "Test_Name" == result def test_asterisk_removed(self): """Test that asterisk is removed.""" result = sanitize_folder_name("Attack*Titan") assert "*" not in result def test_pipe_removed(self): """Test that pipe is removed.""" result = sanitize_folder_name("Attack|Titan") assert "|" not in result def test_real_anime_titles(self): """Test real anime titles with special characters.""" # Test that invalid filesystem characters are removed # Note: semicolon is NOT an invalid filesystem character test_cases = [ ("Re:Zero", ":"), # colon should be removed ("86: Eighty-Six", ":"), # colon should be removed ("Fate/Stay Night", "/"), # slash should be removed ("Sword Art Online: Alicization", ":"), # colon should be removed ("What If...?", "?"), # question mark should be removed ] for input_name, forbidden_char in test_cases: result = sanitize_folder_name(input_name) assert forbidden_char not in result, f"'{forbidden_char}' should be removed from '{input_name}'" class TestIsSafePath: """Test is_safe_path function.""" def test_valid_subpath(self): """Test that valid subpath returns True.""" assert is_safe_path("/anime", "/anime/Attack on Titan") def test_exact_match(self): """Test that exact match returns True.""" assert is_safe_path("/anime", "/anime") def test_path_traversal_rejected(self): """Test that path traversal is rejected.""" assert not is_safe_path("/anime", "/anime/../etc/passwd") def test_parent_directory_rejected(self): """Test that parent directory is rejected.""" assert not is_safe_path("/anime/series", "/anime") def test_sibling_directory_rejected(self): """Test that sibling directory is rejected.""" assert not is_safe_path("/anime", "/movies/film") def test_nested_subpath(self): """Test deeply nested valid subpath.""" assert is_safe_path( "/anime", "/anime/Attack on Titan/Season 1/Episode 1" ) class TestCreateSafeFolder: """Test create_safe_folder function.""" def test_creates_folder_with_sanitized_name(self): """Test that folder is created with sanitized name.""" with tempfile.TemporaryDirectory() as tmpdir: path = create_safe_folder(tmpdir, "Attack: Titan?") assert os.path.isdir(path) assert ":" not in os.path.basename(path) assert "?" not in os.path.basename(path) def test_returns_full_path(self): """Test that full path is returned.""" with tempfile.TemporaryDirectory() as tmpdir: path = create_safe_folder(tmpdir, "Test Folder") assert path.startswith(tmpdir) assert "Test Folder" in path def test_exist_ok_true(self): """Test that existing folder doesn't raise with exist_ok=True.""" with tempfile.TemporaryDirectory() as tmpdir: # Create first time path1 = create_safe_folder(tmpdir, "Test Folder") # Create second time - should not raise path2 = create_safe_folder(tmpdir, "Test Folder", exist_ok=True) assert path1 == path2 def test_rejects_path_traversal(self): """Test that path traversal is rejected after sanitization.""" with tempfile.TemporaryDirectory() as tmpdir: # After sanitization, "../../../etc" becomes "etc" (dots removed) # So this test verifies the folder is created safely # The sanitization removes the path traversal attempt path = create_safe_folder(tmpdir, "../../../etc") # The folder should be created within tmpdir, not escape it assert is_safe_path(tmpdir, path) # Folder name should be "etc" after sanitization (dots stripped) assert os.path.basename(path) == "etc" class TestSanitizeFolderNameEdgeCases: """Test edge cases for sanitize_folder_name.""" def test_control_characters_removed(self): """Test that control characters are removed.""" # ASCII control characters result = sanitize_folder_name("Test\x01\x02\x03Name") assert "\x01" not in result assert "\x02" not in result assert "\x03" not in result def test_carriage_return_removed(self): """Test that carriage return is removed.""" result = sanitize_folder_name("Test\rName") assert "\r" not in result def test_unicode_normalization(self): """Test that Unicode is normalized.""" # Composed vs decomposed forms result = sanitize_folder_name("café") # Should be normalized to NFC form assert result == "café" def test_emoji_handling(self): """Test handling of emoji characters.""" result = sanitize_folder_name("Anime 🎬 Title") # Emoji should be preserved (valid Unicode) assert "🎬" in result or "Anime" in result def test_single_character_name(self): """Test single character name.""" result = sanitize_folder_name("A") assert result == "A" def test_numbers_preserved(self): """Test that numbers are preserved.""" result = sanitize_folder_name("86: Eighty-Six (2021)") assert "86" in result assert "2021" in result