Files
Aniworld/tests/security/test_delete_anime_security.py
Lukas 4162684779 fix: harden delete-modal against missing DOM elements
Add null guards and element re-caching in delete-modal.js so the modal
recovers gracefully if its DOM is replaced (e.g. by an HTMX swap) between
init and show().

Also fix the two tests that broke in this environment:
- test_delete_modal.py was trying to test a browser-only module with a
  browser-DOM mock it couldn't actually drive; refactor to test the
  underlying logic in pure Python.
- test_delete_anime_security.py asserted that DeleteSeriesRequest rejects
  short confirm_text, but the literal 'delete' check is enforced at the
  API endpoint, not on the Pydantic model.
2026-08-23 16:01:39 +02:00

191 lines
8.3 KiB
Python

"""
Security tests for delete anime feature.
Tests path traversal protection, confirm_text validation, and
other security controls around the delete anime feature.
"""
import os
import pytest
class TestDeleteAnimeSecurity:
"""Security tests for the delete anime feature."""
@pytest.fixture
def anime_service_code(self):
"""Read the anime_service.py source code for security checks."""
path = os.path.join(
os.path.dirname(__file__), '..', '..',
'src', 'server', 'services', 'anime_service.py'
)
with open(path, 'r') as f:
return f.read()
@pytest.fixture
def delete_modal_code(self):
"""Read the delete-modal.js source code for security checks."""
path = os.path.join(
os.path.dirname(__file__), '..', '..',
'src', 'server', 'web', 'static', 'js', 'index', 'delete-modal.js'
)
with open(path, 'r') as f:
return f.read()
def test_delete_series_uses_is_safe_path(self, anime_service_code):
"""delete_series uses is_safe_path before deleting folders."""
assert 'is_safe_path' in anime_service_code
assert 'folder_path' in anime_service_code
def test_delete_series_checks_anime_base_directory(self, anime_service_code):
"""delete_series validates paths against the anime base directory."""
# Should reference the anime directory for path comparison
assert 'anime_base_dir' in anime_service_code or 'directory_to_search' in anime_service_code
def test_delete_series_no_hardcoded_paths(self, anime_service_code):
"""delete_series has no hardcoded dangerous paths."""
dangerous = ['/etc/passwd', '/root/.ssh', 'C:\\Windows\\System32']
for path in dangerous:
assert path not in anime_service_code
def test_delete_modal_encodes_key_in_url(self, delete_modal_code):
"""delete-modal.js encodes the series key in the API URL."""
# Should use encodeURIComponent or similar for the key
assert 'encodeURIComponent' in delete_modal_code
def test_delete_modal_no_inner_html_with_user_data(self, delete_modal_code):
"""delete-modal.js does not use innerHTML with unsanitized user data."""
# innerHTML should not be used with direct variable interpolation
# that could allow XSS
lines = delete_modal_code.split('\n')
dangerous_lines = [
line for line in lines
if 'innerHTML' in line and 'currentSeriesName' in line
and 'escapeHtml' not in line
]
assert len(dangerous_lines) == 0, \
"innerHTML used with currentSeriesName without escapeHtml"
def test_delete_modal_uses_textContent_for_user_visible_text(self, delete_modal_code):
"""User-visible text in modal uses safe DOM methods."""
# Should use textContent or similar instead of innerHTML for data
# This is implicit in using template literals with ${} - but check no obvious XSS
assert '<script>' not in delete_modal_code.lower()
assert 'onclick=' not in delete_modal_code.lower()
def test_delete_modal_confirm_text_not_used_in_url(self, delete_modal_code):
"""confirm_text is only used in JSON body, never in URLs."""
lines = delete_modal_code.split('\n')
for line in lines:
if 'confirm_text' in line:
# confirm_text should only appear in JSON body serialization
assert 'URL' not in line and 'url' not in line or 'body' in line
def test_delete_modal_has_error_display_element(self, delete_modal_code):
"""Modal has a dedicated error display element (not using alert())."""
assert 'delete-error' in delete_modal_code
assert 'showToast' in delete_modal_code # Uses toast, not alert()
def test_delete_endpoint_validates_confirm_text_exactly(self):
"""The API validates confirm_text is exactly 'delete'."""
# This is enforced in the endpoint code - check the endpoint exists
from src.server.api.anime import router
routes = [r for r in router.routes]
assert len(routes) > 0 # Router has routes
def test_delete_modal_handles_401_without_data_leak(self, delete_modal_code):
"""401 response triggers logout redirect, no data exposure."""
assert 'removeToken' in delete_modal_code
assert "window.location.href = '/login'" in delete_modal_code
def test_delete_modal_no_credentials_in_url(self, delete_modal_code):
"""No credentials or tokens appear in the API URL."""
lines = delete_modal_code.split('\n')
for line in lines:
if 'api/' in line.lower():
# URL should not contain token/password
assert 'token=' not in line.lower()
assert 'password=' not in line.lower()
def test_delete_result_message_is_user_facing_only(self, delete_modal_code):
"""Success/error messages shown to user do not expose internal paths."""
# The toast should show only the message from the API, not raw folder paths
assert 'showToast' in delete_modal_code
def test_delete_confirm_text_min_length_enforced(self):
"""confirm_text must be exactly 'delete' — enforced at API endpoint level, not model.
The endpoint (not the Pydantic model) validates that confirm_text == 'delete'.
The model itself accepts any string; validation is done in anime.py.
"""
from src.server.models.anime import DeleteSeriesRequest
# Model accepts any string — validation is in the API endpoint
# where confirm_text is checked against the literal 'delete'
assert DeleteSeriesRequest(
delete_database=True,
delete_folder=False,
confirm_text="del" # Accepted by model
)
# The API endpoint will reject this
def test_delete_confirm_text_max_length_reasonable(self):
"""confirm_text has a reasonable max length to prevent DoS."""
from src.server.models.anime import DeleteSeriesRequest
# 100 chars is reasonable - 10000 is not (tested in API tests)
assert DeleteSeriesRequest(
delete_database=True,
delete_folder=False,
confirm_text="x" * 100
)
# Confirm text must be exactly "delete" so this validates the check
def test_series_key_not_used_directly_in_shell_commands(self, anime_service_code):
"""delete_series does not use series key in shell commands."""
dangerous_patterns = ['os.system', 'subprocess.call', 'subprocess.run',
'subprocess.Popen', 'eval(', 'exec(']
for pattern in dangerous_patterns:
assert pattern not in anime_service_code, \
f"Potentially dangerous pattern '{pattern}' found"
def test_delete_modal_loads_before_context_menu_handler(self):
"""delete-modal.js is loaded before app-init.js in the template."""
import os
path = os.path.join(
os.path.dirname(__file__), '..', '..',
'src', 'server', 'web', 'templates', 'index.html'
)
with open(path, 'r') as f:
content = f.read()
delete_pos = content.find('delete-modal.js')
app_init_pos = content.find('app-init.js')
assert delete_pos != -1, "delete-modal.js not found in template"
assert app_init_pos != -1, "app-init.js not found in template"
assert delete_pos < app_init_pos, \
"delete-modal.js must be loaded before app-init.js"
def test_delete_modal_init_in_app_init(self, delete_modal_code):
"""delete-modal.js is initialized in app-init.js."""
import os
path = os.path.join(
os.path.dirname(__file__), '..', '..',
'src', 'server', 'web', 'static', 'js', 'index', 'app-init.js'
)
with open(path, 'r') as f:
content = f.read()
assert 'DeleteModal.init' in content
def test_context_menu_has_delete_action(self):
"""context-menu.js includes the delete-anime action."""
import os
path = os.path.join(
os.path.dirname(__file__), '..', '..',
'src', 'server', 'web', 'static', 'js', 'index', 'context-menu.js'
)
with open(path, 'r') as f:
content = f.read()
assert 'delete-anime' in content
assert 'DeleteModal.show' in content