Files
WYGIWYH/app/apps/import_app/tests/test_paths.py
T
Herculino Trotta e0c1e4d5ff fix(import): resolve symlinks when checking the upload path
The temp root is resolve()d in settings but the file path was only
abspath()ed, so a symlink inside the temp folder could point outside it
and still pass the check. Resolve both sides the same way.

Also drop the unused /temp folder from the prod image.
2026-11-08 03:09:04 -03:00

162 lines
5.8 KiB
Python

"""Tests for the folders import code reads from and writes to."""
import json
import os
import runpy
import shutil
import tempfile
from pathlib import Path
from unittest.mock import patch
from django.conf import settings
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import SimpleTestCase, override_settings
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
from apps.import_app.models import ImportRun
from apps.import_app.services import PresetService
from apps.import_app.services.v1 import ImportService
from apps.import_app.tests.test_view_permissions import (
HTMX,
ImportObjectPermissionTestCase,
)
def load_settings(**env):
"""Evaluates the settings module from scratch under the given env vars."""
with patch.dict(os.environ, env):
return runpy.run_path(str(Path(settings.BASE_DIR) / "WYGIWYH" / "settings.py"))
class PathSettingsTests(SimpleTestCase):
def test_defaults_match_the_docker_image_layout(self):
loaded = load_settings(IMPORT_TEMP_ROOT="", CACHE_ROOT="")
base_dir = loaded["BASE_DIR"]
self.assertEqual(loaded["IMPORT_TEMP_ROOT"], base_dir / "temp")
self.assertEqual(loaded["IMPORT_PRESETS_ROOT"], base_dir / "import_presets")
self.assertEqual(
loaded["CACHES"]["default"]["LOCATION"],
Path("/var/tmp/django_cache").resolve(),
)
self.assertEqual(
loaded["CACHES"]["update_check"]["LOCATION"],
Path("/var/tmp/django_cache_update_check").resolve(),
)
def test_env_vars_move_temp_and_cache_folders(self):
with tempfile.TemporaryDirectory() as root:
root = Path(root).resolve()
loaded = load_settings(
IMPORT_TEMP_ROOT=str(root / "uploads"), CACHE_ROOT=str(root / "cache")
)
self.assertEqual(loaded["IMPORT_TEMP_ROOT"], root / "uploads")
self.assertEqual(
loaded["CACHES"]["default"]["LOCATION"], root / "cache" / "django_cache"
)
self.assertEqual(
loaded["CACHES"]["update_check"]["LOCATION"],
root / "cache" / "django_cache_update_check",
)
class PresetServiceTests(SimpleTestCase):
def test_lists_the_presets_shipped_with_the_app(self):
folder_names = {
preset["folder_name"] for preset in PresetService.get_all_presets()
}
self.assertIn("qif_standard", folder_names)
def test_reads_presets_from_configured_folder(self):
with tempfile.TemporaryDirectory() as root:
preset = Path(root) / "my_bank"
preset.mkdir()
(preset / "manifest.json").write_text(json.dumps({"name": "My Bank"}))
(preset / "config.yml").write_text("settings: {}")
with override_settings(IMPORT_PRESETS_ROOT=Path(root)):
presets = PresetService.get_all_presets()
self.assertEqual([p["name"] for p in presets], ["My Bank"])
class ImportTempRootTests(ImportObjectPermissionTestCase):
def setUp(self):
super().setUp()
self.temp_root = Path(tempfile.mkdtemp()).resolve()
self.addCleanup(shutil.rmtree, self.temp_root, ignore_errors=True)
temp_root_override = override_settings(IMPORT_TEMP_ROOT=self.temp_root)
temp_root_override.enable()
self.addCleanup(temp_root_override.disable)
@patch("apps.import_app.views.process_import.defer")
def test_web_upload_is_stored_in_temp_root(self, mock_defer):
self.login(self.owner)
response = self.client.post(
reverse("import_run_add", kwargs={"profile_id": self.private_profile.id}),
{"file": SimpleUploadedFile("web.csv", b"date,amount\n")},
**HTMX,
)
self.assertEqual(response.status_code, 204)
self.assertEqual(
mock_defer.call_args.kwargs["file_path"], str(self.temp_root / "web.csv")
)
self.assertTrue((self.temp_root / "web.csv").is_file())
@patch("apps.api.views.imports.process_import.defer")
def test_api_upload_is_stored_in_temp_root(self, mock_defer):
client = APIClient()
client.force_authenticate(user=self.owner)
response = client.post(
"/api/import/import/",
{
"profile_id": self.private_profile.id,
"file": SimpleUploadedFile("api.csv", b"date,amount\n"),
},
format="multipart",
)
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
self.assertEqual(
mock_defer.call_args.kwargs["file_path"], str(self.temp_root / "api.csv")
)
self.assertTrue((self.temp_root / "api.csv").is_file())
def _service(self):
run = ImportRun.objects.create(
profile=self.private_profile, file_name="file.csv", owner=self.owner
)
return ImportService(run)
def test_service_accepts_files_inside_temp_root(self):
path = str(self.temp_root / "file.csv")
self.assertEqual(self._service()._validate_file_path(path), path)
def test_service_rejects_files_outside_temp_root(self):
service = self._service()
for path in (
"/etc/passwd",
self.temp_root / ".." / "file.csv",
# Shares the temp root as a string prefix, but is a different folder
f"{self.temp_root}-other/file.csv",
):
with self.subTest(path=path), self.assertRaises(ValueError):
service._validate_file_path(str(path))
def test_service_rejects_symlinks_pointing_outside_temp_root(self):
link = self.temp_root / "link.csv"
try:
link.symlink_to("/etc/passwd")
except OSError:
self.skipTest("symlinks not available")
with self.assertRaises(ValueError):
self._service()._validate_file_path(str(link))