diff --git a/src/backend/InvenTree/report/apps.py b/src/backend/InvenTree/report/apps.py index 1dfc507553..adec77df93 100644 --- a/src/backend/InvenTree/report/apps.py +++ b/src/backend/InvenTree/report/apps.py @@ -96,7 +96,7 @@ class ReportConfig(AppConfig): raise FileNotFoundError( 'Template file %s does not exist in %s', file_name, file ) - return ContentFile(file.open('r').read(), os.path.basename(file_name)) + return ContentFile(file.read_bytes(), os.path.basename(file_name)) def create_default_labels(self): """Create default label templates.""" diff --git a/src/backend/InvenTree/report/tests.py b/src/backend/InvenTree/report/tests.py index a1a3433cb9..780245420c 100644 --- a/src/backend/InvenTree/report/tests.py +++ b/src/backend/InvenTree/report/tests.py @@ -1,7 +1,9 @@ """Unit testing for the various report models.""" import os +import tempfile from io import StringIO +from pathlib import Path from unittest.mock import patch from django.apps import apps @@ -18,6 +20,7 @@ import report.models as report_models from build.models import Build from common.models import Attachment from common.settings import set_global_setting +from InvenTree.config import get_base_dir from InvenTree.unit_test import AdminTestCase, InvenTreeAPITestCase from order.models import PurchaseOrder, ReturnOrder, SalesOrder from part.models import Part @@ -1073,3 +1076,66 @@ class URLFetcherTest(TestCase): with patch('weasyprint.urls.URLFetcher.fetch', return_value={}): self.fetcher.fetch('data:image/png;base64,abc123') self.fetcher.fetch('data:text/css;base64,abc123') + + +class DefaultTemplateFileTest(TestCase): + """Unit tests for building the default report and label template files.""" + + def test_file_size_matches_bytes(self): + """file_from_template must size the ContentFile by byte length. + + Reading the template in text mode makes the size a character count, + which is smaller than the byte length for multi-byte content and breaks + uploads to S3 backends that enforce Content-Length. + """ + config = apps.get_app_config('report') + filename = 'inventree_transfer_order_report.html' + content_file = config.file_from_template('report', filename) + path = get_base_dir().joinpath('report', 'templates', 'report', filename) + self.assertEqual(content_file.size, path.stat().st_size) + + def test_file_from_template_is_encoding_agnostic(self): + """file_from_template must preserve the exact bytes for any encoding. + + A text-mode read sizes the ContentFile by character count and needs the + correct decoder, so non-English content is both mis-sized and at risk of + corruption. Reading the raw bytes keeps the file identical to what is on + disk, so the size always matches the Content-Length used for S3 uploads. + The samples below cover non-ASCII text in a few encodings, including + multi-byte content where a character count would not equal the byte + length. + """ + config = apps.get_app_config('report') + + # (encoding, text, whether the encoding uses multi-byte characters). + # The utf cases hold characters that span more than one byte, so a + # character count would not equal the byte length; latin-1 stays + # single-byte and is included to show the read is not utf-8 specific. + cases = [ + ('utf-8', 'Système de café ≤ 你好 в наявності 📦', True), + ('utf-16', 'Système de café ≤ 你好 в наявності 📦', True), + ('latin-1', 'Système de café àéîõü', False), + ] + + for encoding, text, multibyte in cases: + with self.subTest(encoding=encoding): + body = f'{text}' + raw = body.encode(encoding) + + if multibyte: + # A character count would differ from the byte length here + self.assertNotEqual(len(raw), len(body)) + + with tempfile.TemporaryDirectory() as tmp: + base = Path(tmp) + template_dir = base.joinpath('report', 'templates', 'report') + template_dir.mkdir(parents=True) + filename = f'encoding_{encoding}.html' + template_dir.joinpath(filename).write_bytes(raw) + + with patch('report.apps.get_base_dir', return_value=base): + content_file = config.file_from_template('report', filename) + + # Size and content must match the raw bytes exactly + self.assertEqual(content_file.size, len(raw)) + self.assertEqual(content_file.read(), raw)