mirror of
https://github.com/inventree/InvenTree.git
synced 2026-07-21 22:23:03 +00:00
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:
@@ -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."""
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user