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 12:17:17 +02:00 committed by GitHub
parent b723f5d2ca
commit ce47cda948
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 67 additions and 1 deletions

View File

@ -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."""

View File

@ -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'<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)