|
| 1 | +""" |
| 2 | +Unit tests for website.utils.upload_validators (issue #6). |
| 3 | +
|
| 4 | +Pure-logic tests: each validator is a function over an uploaded file, so these |
| 5 | +use SimpleTestCase + SimpleUploadedFile with crafted bytes — no DB, runs in ms. |
| 6 | +
|
| 7 | +Each category covers four cases: |
| 8 | + * accept a file whose extension AND bytes are valid, |
| 9 | + * reject a disallowed extension, |
| 10 | + * reject a renamed payload (allowed extension, wrong/dangerous bytes), |
| 11 | + * plus category-specific cases (HEIC guidance, .fig/.sketch for raw_file). |
| 12 | +""" |
| 13 | + |
| 14 | +from django.core.exceptions import ValidationError |
| 15 | +from django.core.files.uploadedfile import SimpleUploadedFile |
| 16 | +from django.test import SimpleTestCase |
| 17 | + |
| 18 | +from website.utils.upload_validators import ( |
| 19 | + validate_image_upload, |
| 20 | + validate_pdf_upload, |
| 21 | + validate_raw_file_upload, |
| 22 | + validate_video_upload, |
| 23 | +) |
| 24 | + |
| 25 | + |
| 26 | +# --- Sample file headers --------------------------------------------------- |
| 27 | + |
| 28 | +PNG = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR" + b"\x00" * 16 |
| 29 | +JPEG = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01" + b"\x00" * 16 |
| 30 | +GIF = b"GIF89a\x01\x00\x01\x00\x80\x00\x00" + b"\x00" * 16 |
| 31 | +WEBP = b"RIFF\x24\x00\x00\x00WEBPVP8 " + b"\x00" * 16 |
| 32 | +PDF = b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n" + b"\x00" * 16 |
| 33 | +MP4 = b"\x00\x00\x00\x18ftypmp42\x00\x00\x00\x00" + b"\x00" * 16 |
| 34 | +WEBM = b"\x1aE\xdf\xa3\x01\x00\x00\x00" + b"\x00" * 16 |
| 35 | +ZIP = b"PK\x03\x04\x14\x00\x00\x00\x08\x00" + b"\x00" * 16 # pptx/docx/key/sketch/zip |
| 36 | +OLE = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1" + b"\x00" * 16 # legacy ppt/doc |
| 37 | +FIG = b"fig-kiwi\x0f\x00\x00\x00\x01\x02\x03" + b"\x00" * 16 # proprietary binary |
| 38 | +HTML = b"<!DOCTYPE html>\n<html><body>hi</body></html>" |
| 39 | +SVG = b'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>' |
| 40 | + |
| 41 | + |
| 42 | +def _upload(name, content): |
| 43 | + return SimpleUploadedFile(name, content) |
| 44 | + |
| 45 | + |
| 46 | +class _CommittedFile: |
| 47 | + """ |
| 48 | + Minimal stand-in for an already-stored FieldFile (``_committed = True``), |
| 49 | + i.e. an existing file on an unchanged record. Validators should skip it. |
| 50 | + """ |
| 51 | + |
| 52 | + def __init__(self, name): |
| 53 | + self.name = name |
| 54 | + self._committed = True |
| 55 | + |
| 56 | + def seek(self, *a): |
| 57 | + raise AssertionError("a committed file should not be read by validators") |
| 58 | + |
| 59 | + def read(self, *a): |
| 60 | + raise AssertionError("a committed file should not be read by validators") |
| 61 | + |
| 62 | + |
| 63 | +class ImageValidatorTests(SimpleTestCase): |
| 64 | + def test_accepts_valid_images(self): |
| 65 | + for name, content in [ |
| 66 | + ("a.png", PNG), ("a.jpg", JPEG), ("a.jpeg", JPEG), |
| 67 | + ("a.gif", GIF), ("a.webp", WEBP), |
| 68 | + ]: |
| 69 | + with self.subTest(name=name): |
| 70 | + validate_image_upload(_upload(name, content)) # no raise |
| 71 | + |
| 72 | + def test_rejects_disallowed_extension(self): |
| 73 | + with self.assertRaises(ValidationError): |
| 74 | + validate_image_upload(_upload("a.svg", SVG)) |
| 75 | + with self.assertRaises(ValidationError): |
| 76 | + validate_image_upload(_upload("a.html", HTML)) |
| 77 | + |
| 78 | + def test_rejects_renamed_payload(self): |
| 79 | + # Allowed extension, but the bytes are HTML, not an image. |
| 80 | + with self.assertRaises(ValidationError) as ctx: |
| 81 | + validate_image_upload(_upload("evil.png", HTML)) |
| 82 | + self.assertEqual(ctx.exception.code, "invalid_image_content") |
| 83 | + |
| 84 | + def test_heic_gets_guiding_message(self): |
| 85 | + for name in ("photo.heic", "photo.HEIC", "photo.heif"): |
| 86 | + with self.subTest(name=name): |
| 87 | + with self.assertRaises(ValidationError) as ctx: |
| 88 | + validate_image_upload(_upload(name, PNG)) |
| 89 | + self.assertEqual(ctx.exception.code, "heic_not_supported") |
| 90 | + |
| 91 | + |
| 92 | +class PdfValidatorTests(SimpleTestCase): |
| 93 | + def test_accepts_valid_pdf(self): |
| 94 | + validate_pdf_upload(_upload("paper.pdf", PDF)) |
| 95 | + |
| 96 | + def test_rejects_disallowed_extension(self): |
| 97 | + with self.assertRaises(ValidationError): |
| 98 | + validate_pdf_upload(_upload("paper.exe", PDF)) |
| 99 | + |
| 100 | + def test_rejects_renamed_payload(self): |
| 101 | + with self.assertRaises(ValidationError) as ctx: |
| 102 | + validate_pdf_upload(_upload("evil.pdf", HTML)) |
| 103 | + self.assertEqual(ctx.exception.code, "invalid_pdf_content") |
| 104 | + |
| 105 | + |
| 106 | +class VideoValidatorTests(SimpleTestCase): |
| 107 | + def test_accepts_valid_videos(self): |
| 108 | + validate_video_upload(_upload("clip.mp4", MP4)) |
| 109 | + validate_video_upload(_upload("clip.mov", MP4)) |
| 110 | + validate_video_upload(_upload("clip.webm", WEBM)) |
| 111 | + |
| 112 | + def test_rejects_disallowed_extension(self): |
| 113 | + with self.assertRaises(ValidationError): |
| 114 | + validate_video_upload(_upload("clip.avi", MP4)) |
| 115 | + |
| 116 | + def test_rejects_renamed_payload(self): |
| 117 | + with self.assertRaises(ValidationError) as ctx: |
| 118 | + validate_video_upload(_upload("evil.mp4", HTML)) |
| 119 | + self.assertEqual(ctx.exception.code, "invalid_video_content") |
| 120 | + |
| 121 | + |
| 122 | +class RawFileValidatorTests(SimpleTestCase): |
| 123 | + def test_accepts_known_source_formats(self): |
| 124 | + for name, content in [ |
| 125 | + ("talk.pptx", ZIP), ("talk.key", ZIP), ("doc.docx", ZIP), |
| 126 | + ("src.zip", ZIP), ("legacy.ppt", OLE), ("paper.pdf", PDF), |
| 127 | + ]: |
| 128 | + with self.subTest(name=name): |
| 129 | + validate_raw_file_upload(_upload(name, content)) # no raise |
| 130 | + |
| 131 | + def test_accepts_proprietary_design_files(self): |
| 132 | + # .fig / .sketch are accepted by extension; the denylist content check |
| 133 | + # passes any non-web-executable bytes, so we don't need their signatures. |
| 134 | + validate_raw_file_upload(_upload("poster.fig", FIG)) |
| 135 | + validate_raw_file_upload(_upload("poster.sketch", ZIP)) |
| 136 | + |
| 137 | + def test_rejects_disallowed_extension(self): |
| 138 | + with self.assertRaises(ValidationError): |
| 139 | + validate_raw_file_upload(_upload("page.html", HTML)) |
| 140 | + with self.assertRaises(ValidationError): |
| 141 | + validate_raw_file_upload(_upload("image.svg", SVG)) |
| 142 | + |
| 143 | + def test_rejects_web_executable_content_via_rename(self): |
| 144 | + # Allowed extension (.fig) but HTML bytes -> caught by the denylist. |
| 145 | + with self.assertRaises(ValidationError) as ctx: |
| 146 | + validate_raw_file_upload(_upload("evil.fig", HTML)) |
| 147 | + self.assertEqual(ctx.exception.code, "invalid_raw_content") |
| 148 | + |
| 149 | + |
| 150 | +class ExistingFileGateTests(SimpleTestCase): |
| 151 | + """An already-stored file (unchanged record edit) is skipped, even if its |
| 152 | + extension/content would fail today's rules. Re-validating it adds no |
| 153 | + security and would break editing legacy records.""" |
| 154 | + |
| 155 | + def test_committed_files_are_not_validated(self): |
| 156 | + # Each of these would fail if validated as a new upload; the gate |
| 157 | + # short-circuits before the extension/content checks (and before any |
| 158 | + # read of the file, which _CommittedFile asserts against). |
| 159 | + validate_image_upload(_CommittedFile("legacy.bmp")) |
| 160 | + validate_pdf_upload(_CommittedFile("legacy.txt")) |
| 161 | + validate_video_upload(_CommittedFile("legacy.avi")) |
| 162 | + validate_raw_file_upload(_CommittedFile("legacy.tex")) |
0 commit comments