Read report/label templates in binary mode (#12432)

* Read report/label templates in binary mode

file_from_template read the template in text mode and wrapped the string
in a ContentFile. The ContentFile size then reflected the character count,
which differs from the UTF-8 byte length for any template that contains
multi-byte characters. Strict S3 implementations such as Backblaze B2
validate the Content-Length header and reject the upload with an
IncompleteBody error. AWS S3 tolerates the mismatch, so the problem is
not visible there.

inventree_transfer_order_report.html is 1458 characters but 1460 bytes.
Its upload fails during create_default_reports, the transaction rolls
back, and the default template is recreated and fails again on every
startup.

Use Path.read_bytes() so the ContentFile size matches the uploaded bytes
and the file handle is closed. Add a regression test asserting the size
equals the file's byte length.

* Test file_from_template across text encodings
This commit is contained in:
Alex K
2026-07-21 20:17:17 +10:00
committed by GitHub
parent b723f5d2ca
commit ce47cda948
2 changed files with 67 additions and 1 deletions
+1 -1
View File
@@ -96,7 +96,7 @@ class ReportConfig(AppConfig):
raise FileNotFoundError( raise FileNotFoundError(
'Template file %s does not exist in %s', file_name, file '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): def create_default_labels(self):
"""Create default label templates.""" """Create default label templates."""
+66
View File
@@ -1,7 +1,9 @@
"""Unit testing for the various report models.""" """Unit testing for the various report models."""
import os import os
import tempfile
from io import StringIO from io import StringIO
from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from django.apps import apps from django.apps import apps
@@ -18,6 +20,7 @@ import report.models as report_models
from build.models import Build from build.models import Build
from common.models import Attachment from common.models import Attachment
from common.settings import set_global_setting from common.settings import set_global_setting
from InvenTree.config import get_base_dir
from InvenTree.unit_test import AdminTestCase, InvenTreeAPITestCase from InvenTree.unit_test import AdminTestCase, InvenTreeAPITestCase
from order.models import PurchaseOrder, ReturnOrder, SalesOrder from order.models import PurchaseOrder, ReturnOrder, SalesOrder
from part.models import Part from part.models import Part
@@ -1073,3 +1076,66 @@ class URLFetcherTest(TestCase):
with patch('weasyprint.urls.URLFetcher.fetch', return_value={}): with patch('weasyprint.urls.URLFetcher.fetch', return_value={}):
self.fetcher.fetch('data:image/png;base64,abc123') self.fetcher.fetch('data:image/png;base64,abc123')
self.fetcher.fetch('data:text/css;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'<html><body>{text}</body></html>'
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)