mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-07-30 23:55:59 +00:00
188 lines
7.5 KiB
Python
188 lines
7.5 KiB
Python
"""
|
|
Tests for NFC Unicode normalization in generate_filename / FilePathTemplate.render().
|
|
|
|
NFC `ü` (UTF-8: c3 bc) and NFD `ü` (UTF-8: 75 cc 88) are visually identical but
|
|
produce different byte sequences. On Linux (ext4, ZFS) these are distinct filenames.
|
|
All paths produced by the templating system must be NFC-normalized.
|
|
"""
|
|
|
|
import unicodedata
|
|
|
|
import pytest
|
|
|
|
from documents.file_handling import generate_filename
|
|
from documents.models import CustomField
|
|
from documents.models import CustomFieldInstance
|
|
from documents.tests.factories import CorrespondentFactory
|
|
from documents.tests.factories import DocumentFactory
|
|
from documents.tests.factories import StoragePathFactory
|
|
from documents.tests.factories import TagFactory
|
|
|
|
|
|
@pytest.mark.django_db
|
|
class TestGenerateFilenameNFCNormalization:
|
|
@pytest.mark.parametrize(
|
|
"raw,display",
|
|
[
|
|
(unicodedata.normalize("NFD", "Gemüse"), "Gemüse"),
|
|
(unicodedata.normalize("NFD", "Café"), "Café"),
|
|
(unicodedata.normalize("NFD", "naïve"), "naïve"),
|
|
],
|
|
)
|
|
def test_nfd_title_normalized_to_nfc(self, settings, raw, display):
|
|
"""NFD title must produce NFC path bytes."""
|
|
settings.FILENAME_FORMAT = "{{ title }}"
|
|
nfc = unicodedata.normalize("NFC", display)
|
|
assert raw != nfc # confirm byte-level difference
|
|
|
|
doc = DocumentFactory(title=raw, mime_type="application/pdf")
|
|
result = generate_filename(doc)
|
|
|
|
assert str(result) == f"{nfc}.pdf"
|
|
assert str(result).encode() == f"{nfc}.pdf".encode()
|
|
|
|
def test_nfd_correspondent_normalized_to_nfc(self, settings):
|
|
"""NFD correspondent name must produce NFC path component."""
|
|
settings.FILENAME_FORMAT = "{{ correspondent }}/{{ title }}"
|
|
nfd = unicodedata.normalize("NFD", "Müller")
|
|
nfc = unicodedata.normalize("NFC", "Müller")
|
|
|
|
correspondent = CorrespondentFactory(name=nfd)
|
|
doc = DocumentFactory(
|
|
title="invoice",
|
|
correspondent=correspondent,
|
|
mime_type="application/pdf",
|
|
)
|
|
result = generate_filename(doc)
|
|
|
|
assert str(result) == f"{nfc}/invoice.pdf"
|
|
assert str(result).encode() == f"{nfc}/invoice.pdf".encode()
|
|
|
|
def test_nfd_storage_path_normalized_to_nfc(self, settings):
|
|
"""NFD literal in StoragePath.path template must produce NFC path bytes."""
|
|
settings.FILENAME_FORMAT = None
|
|
nfd = unicodedata.normalize("NFD", "Büro")
|
|
nfc = unicodedata.normalize("NFC", "Büro")
|
|
|
|
# StoragePath.path is used directly as the format/template string.
|
|
# Literal NFD characters in the template must survive rendering as NFC.
|
|
sp = StoragePathFactory(path=f"{nfd}/{{{{ title }}}}")
|
|
doc = DocumentFactory(title="doc", storage_path=sp, mime_type="application/pdf")
|
|
result = generate_filename(doc)
|
|
|
|
assert str(result).encode() == f"{nfc}/doc.pdf".encode()
|
|
|
|
def test_nfd_raw_document_title_normalized_to_nfc(self, settings):
|
|
"""NFD title accessed via document.title (unsanitized context) must also be NFC."""
|
|
settings.FILENAME_FORMAT = "{{ document.title }}"
|
|
nfd = unicodedata.normalize("NFD", "Café")
|
|
nfc = unicodedata.normalize("NFC", "Café")
|
|
|
|
doc = DocumentFactory(title=nfd, mime_type="application/pdf")
|
|
result = generate_filename(doc)
|
|
|
|
assert str(result) == f"{nfc}.pdf"
|
|
assert str(result).encode() == f"{nfc}.pdf".encode()
|
|
|
|
|
|
@pytest.mark.django_db
|
|
class TestContextBuilderNFCNormalization:
|
|
"""
|
|
Defense-in-depth: context builder functions must NFC-normalize string inputs
|
|
before passing them to sanitize_filename(). Task 1 already normalizes the
|
|
final rendered path via clean_filepath(), so these tests may already pass;
|
|
they exist as regression guards for the context-builder layer.
|
|
"""
|
|
|
|
def test_nfd_tag_name_normalized_in_tag_list(self, settings):
|
|
"""NFD tag name must appear as NFC bytes in the {{ tag_list }} shorthand."""
|
|
settings.FILENAME_FORMAT = "{{ tag_list }}/{{ title }}"
|
|
nfd = unicodedata.normalize("NFD", "Büro")
|
|
nfc = unicodedata.normalize("NFC", "Büro")
|
|
assert nfd != nfc # confirm they differ at byte level
|
|
|
|
tag = TagFactory(name=nfd)
|
|
doc = DocumentFactory(title="doc", mime_type="application/pdf")
|
|
doc.tags.set([tag])
|
|
|
|
result = generate_filename(doc)
|
|
|
|
assert str(result).encode() == f"{nfc}/doc.pdf".encode()
|
|
|
|
def test_nfd_original_name_normalized_to_nfc(self, settings):
|
|
settings.FILENAME_FORMAT = "{{ original_name }}"
|
|
nfd = unicodedata.normalize("NFD", "Rechnung März")
|
|
nfc = unicodedata.normalize("NFC", "Rechnung März")
|
|
|
|
doc = DocumentFactory(
|
|
original_filename=f"{nfd}.pdf",
|
|
mime_type="application/pdf",
|
|
)
|
|
result = generate_filename(doc)
|
|
|
|
assert str(result).encode() == f"{nfc}.pdf".encode()
|
|
|
|
def test_nfd_custom_field_string_value_normalized(self, settings):
|
|
"""NFD value in a STRING-type custom field must appear as NFC in the context."""
|
|
settings.FILENAME_FORMAT = (
|
|
"{{ custom_fields['Location']['value'] }}/{{ title }}"
|
|
)
|
|
nfd_value = unicodedata.normalize("NFD", "Düsseldorf")
|
|
nfc_value = unicodedata.normalize("NFC", "Düsseldorf")
|
|
assert nfd_value != nfc_value
|
|
|
|
doc = DocumentFactory(title="report", mime_type="application/pdf")
|
|
cf = CustomField.objects.create(
|
|
name="Location",
|
|
data_type=CustomField.FieldDataType.STRING,
|
|
)
|
|
CustomFieldInstance.objects.create(
|
|
document=doc,
|
|
field=cf,
|
|
value_text=nfd_value,
|
|
)
|
|
|
|
result = generate_filename(doc)
|
|
|
|
assert str(result).encode() == f"{nfc_value}/report.pdf".encode()
|
|
|
|
def test_nfd_custom_field_name_normalized_as_key(self, settings):
|
|
"""NFD characters in a custom field name must appear as NFC in the context dict key."""
|
|
nfd_name = unicodedata.normalize("NFD", "Größe")
|
|
nfc_name = unicodedata.normalize("NFC", "Größe")
|
|
assert nfd_name != nfc_name
|
|
|
|
settings.FILENAME_FORMAT = f"{{% if custom_fields['{nfc_name}'] %}}{{{{ custom_fields['{nfc_name}']['value'] }}}}/{{{{ title }}}}{{% else %}}{{{{ title }}}}{{% endif %}}"
|
|
|
|
doc = DocumentFactory(title="letter", mime_type="application/pdf")
|
|
cf = CustomField.objects.create(
|
|
name=nfd_name,
|
|
data_type=CustomField.FieldDataType.STRING,
|
|
)
|
|
CustomFieldInstance.objects.create(
|
|
document=doc,
|
|
field=cf,
|
|
value_text="Berlin",
|
|
)
|
|
|
|
result = generate_filename(doc)
|
|
|
|
# If field name key is NFC-normalized, the template condition succeeds
|
|
# and result is "Berlin/letter.pdf"; otherwise it falls back to "letter.pdf"
|
|
assert str(result) == "Berlin/letter.pdf"
|
|
|
|
def test_nfd_tag_name_list_normalized_to_nfc(self, settings):
|
|
"""NFD tag names in tag_name_list must appear as NFC bytes when iterated."""
|
|
settings.FILENAME_FORMAT = (
|
|
"{% for t in tag_name_list %}{{ t }}{% endfor %}/{{ title }}"
|
|
)
|
|
nfd = unicodedata.normalize("NFD", "Büro")
|
|
nfc = unicodedata.normalize("NFC", "Büro")
|
|
assert nfd != nfc # confirm byte-level difference
|
|
|
|
doc = DocumentFactory(title="doc", mime_type="application/pdf")
|
|
doc.tags.add(TagFactory(name=nfd))
|
|
result = generate_filename(doc)
|
|
|
|
assert str(result).encode() == f"{nfc}/doc.pdf".encode()
|