ixg_platform/apps/documents/tests.py
ixgadmin cdefbd010c Serve document and certificate files through a permission-checked download
Media was unreachable from either portal, which is why the certificate button on
the buyer compliance page returned page-not-found and no document row had
anything to click. The cause was structural: the portal and ops hosts proxy
location / to the app with a path prefix, so a stored /media/... URL arrives as
/portal/media/... and matches no route.

Add a download view per portal behind the same role gate as the page that links
to it — buyer-visible documents and published certificates for buyers, anything
the ops list shows for operations — resolving the stored path safely under
MEDIA_ROOT so a traversal or an absolute path is refused. Point the seven
templates that list files at the new routes.

Also add a portal-wide render sweep: nothing in the suite was fetching the ops
or portal pages, so a view could lose its template_name and 500 in front of a
user with every test still green.
2026-09-17 12:44:52 +00:00

209 lines
8.5 KiB
Python

"""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))