"""Downloads must be gated the same way the page that links to them is. Two classes of failure matter here and neither is caught by "the link renders": a file that cannot be served at all (the state the portals were in), and a file served to someone who should not have it. Both are covered below, plus the path handling that turns a stored value into a real file. """ import os import shutil import tempfile from decimal import Decimal from pathlib import Path from django.contrib.auth import get_user_model from django.test import Client, TestCase, override_settings from apps.batches.models import ProcessingBatch from .downloads import resolve_media_path from .models import CertificateOfAnalysis, Document User = get_user_model() PDF_BYTES = b"%PDF-1.4\n% synthetic document for tests\n%%EOF\n" def make_user(email, role): # Mirrors the Keycloak OIDC backend, which creates all users as is_staff=True. return User.objects.create_user( email=email, password="pass1234", role=role, full_name=email.split("@")[0], is_staff=True, ) class DownloadTestCase(TestCase): """Fixtures: a real temp media root, plus one user per role.""" def setUp(self): self.media_root = Path(tempfile.mkdtemp(prefix="ixg-download-test-")) self.addCleanup(shutil.rmtree, self.media_root, True) self.settings_override = override_settings(MEDIA_ROOT=self.media_root) self.settings_override.enable() self.addCleanup(self.settings_override.disable) self.buyer = make_user("buyer@ixg.local", "buyer") self.ops = make_user("ops@ixg.local", "ops") self.admin = make_user("admin@ixg.local", "admin") self.batch = ProcessingBatch.objects.create( batch_reference="IXG-BATCH-TEST-1", grade="A", weight_kg=Decimal("1000.00"), origin_facility="ASI - Katsina State, Nigeria", created_by=self.ops, ) self.buyer_doc = self.make_document("packing-list.pdf", Document.Visibility.BUYER) self.ops_doc = self.make_document("lab-report.pdf", Document.Visibility.OPS) self.admin_doc = self.make_document("internal-note.pdf", Document.Visibility.ADMIN) self.published_coa = self.make_coa("coa-published.pdf", CertificateOfAnalysis.Status.PUBLISHED) self.pending_coa = self.make_coa("coa-pending.pdf", CertificateOfAnalysis.Status.PENDING_APPROVAL) def write_file(self, name): """Put real bytes on disk under MEDIA_ROOT and return the stored file_url.""" rel = f"ixg-documents/{self.batch.batch_reference.lower()}/{name}" target = self.media_root / rel target.parent.mkdir(parents=True, exist_ok=True) target.write_bytes(PDF_BYTES) return f"/media/{rel}" def make_document(self, name, visibility): return Document.objects.create( batch=self.batch, doc_type=Document.DocType.OTHER, file_url=self.write_file(name), file_name=name, visibility=visibility, uploaded_by=self.ops, ) def make_coa(self, name, status): return CertificateOfAnalysis.objects.create( batch=self.batch, file_url=self.write_file(name), file_name=name, status=status, uploaded_by=self.ops, ) def get(self, user, url, host): c = Client(SERVER_NAME=host) c.force_login(user) return c.get(url) @staticmethod def body(response): if getattr(response, "streaming", False): return b"".join(response.streaming_content) return response.content class BuyerDownloadTests(DownloadTestCase): def test_buyer_downloads_a_buyer_visible_document(self): r = self.get(self.buyer, f"/portal/documents/{self.buyer_doc.id}/download/", "portal.ixgltd.com") self.assertEqual(r.status_code, 200) self.assertEqual(self.body(r), PDF_BYTES) self.assertIn("packing-list.pdf", r.headers.get("Content-Disposition", "")) def test_buyer_cannot_download_an_operations_document(self): r = self.get(self.buyer, f"/portal/documents/{self.ops_doc.id}/download/", "portal.ixgltd.com") self.assertEqual(r.status_code, 404) def test_buyer_cannot_download_an_admin_document(self): r = self.get(self.buyer, f"/portal/documents/{self.admin_doc.id}/download/", "portal.ixgltd.com") self.assertEqual(r.status_code, 404) def test_buyer_downloads_a_published_certificate(self): r = self.get(self.buyer, f"/portal/certificates/{self.published_coa.id}/download/", "portal.ixgltd.com") self.assertEqual(r.status_code, 200) self.assertEqual(self.body(r), PDF_BYTES) def test_buyer_cannot_download_a_certificate_awaiting_release(self): r = self.get(self.buyer, f"/portal/certificates/{self.pending_coa.id}/download/", "portal.ixgltd.com") self.assertEqual(r.status_code, 404) def test_anonymous_visitor_is_refused(self): c = Client(SERVER_NAME="portal.ixgltd.com") r = c.get(f"/portal/documents/{self.buyer_doc.id}/download/") self.assertIn(r.status_code, (302, 403)) def test_ops_user_is_refused_on_the_buyer_route(self): r = self.get(self.ops, f"/portal/documents/{self.buyer_doc.id}/download/", "portal.ixgltd.com") self.assertEqual(r.status_code, 403) class OpsDownloadTests(DownloadTestCase): def test_ops_downloads_a_buyer_visible_document(self): r = self.get(self.ops, f"/ops/documents/{self.buyer_doc.id}/download/", "ops.ixgltd.com") self.assertEqual(r.status_code, 200) self.assertEqual(self.body(r), PDF_BYTES) def test_ops_downloads_an_operations_document(self): r = self.get(self.ops, f"/ops/documents/{self.ops_doc.id}/download/", "ops.ixgltd.com") self.assertEqual(r.status_code, 200) def test_ops_can_open_a_certificate_awaiting_release(self): """A reviewer needs the pending certificate most of all.""" r = self.get(self.ops, f"/ops/certificates/{self.pending_coa.id}/download/", "ops.ixgltd.com") self.assertEqual(r.status_code, 200) self.assertEqual(self.body(r), PDF_BYTES) def test_buyer_is_refused_on_the_ops_route(self): r = self.get(self.buyer, f"/ops/documents/{self.ops_doc.id}/download/", "ops.ixgltd.com") self.assertIn(r.status_code, (302, 403)) class MissingAndHostileFileTests(DownloadTestCase): def test_missing_file_is_not_found_rather_than_a_server_error(self): doc = Document.objects.create( batch=self.batch, doc_type=Document.DocType.OTHER, file_url="/media/ixg-documents/nowhere/nothing-here.pdf", file_name="nothing-here.pdf", visibility=Document.Visibility.BUYER, uploaded_by=self.ops, ) r = self.get(self.buyer, f"/portal/documents/{doc.id}/download/", "portal.ixgltd.com") self.assertEqual(r.status_code, 404) def test_empty_file_url_is_not_found(self): doc = Document.objects.create( batch=self.batch, doc_type=Document.DocType.OTHER, file_url="", file_name="empty.pdf", visibility=Document.Visibility.BUYER, uploaded_by=self.ops, ) r = self.get(self.buyer, f"/portal/documents/{doc.id}/download/", "portal.ixgltd.com") self.assertEqual(r.status_code, 404) def test_a_traversal_out_of_media_root_is_refused(self): doc = Document.objects.create( batch=self.batch, doc_type=Document.DocType.OTHER, file_url="/media/../../config/settings.py", file_name="escaped.pdf", visibility=Document.Visibility.BUYER, uploaded_by=self.ops, ) r = self.get(self.buyer, f"/portal/documents/{doc.id}/download/", "portal.ixgltd.com") self.assertEqual(r.status_code, 404) class ResolveMediaPathTests(DownloadTestCase): def test_resolves_a_normal_media_url(self): path = resolve_media_path(self.buyer_doc.file_url) self.assertTrue(path and path.is_file()) def test_refuses_paths_outside_media_url(self): self.assertIsNone(resolve_media_path("/etc/passwd")) self.assertIsNone(resolve_media_path("/app/config/settings.py")) def test_refuses_traversal(self): self.assertIsNone(resolve_media_path("/media/../config/settings.py")) def test_refuses_empty_values(self): self.assertIsNone(resolve_media_path("")) self.assertIsNone(resolve_media_path(None))