mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-16 21:08:12 +00:00
get_public_filename relied on sanitize_filename to truncate, which cuts from the end and so dropped the counter and extension of long titles. Multi-byte titles could also exceed the 255 byte filesystem limit. Shorten the title by UTF-8 bytes before appending the counter and extension. The exporter reserves room for its longer derived thumbnail and archive suffixes.
332 lines
10 KiB
Python
332 lines
10 KiB
Python
import shutil
|
|
import tempfile
|
|
from datetime import date
|
|
from pathlib import Path
|
|
from unittest import mock
|
|
|
|
import pytest
|
|
from django.test import TestCase
|
|
from django.test import override_settings
|
|
from faker import Faker
|
|
from pytest_mock import MockerFixture
|
|
|
|
from documents.models import Correspondent
|
|
from documents.models import Document
|
|
from documents.tasks import empty_trash
|
|
from paperless_testing.factories import DocumentFactory
|
|
|
|
|
|
class TestDocument(TestCase):
|
|
def setUp(self) -> None:
|
|
self.originals_dir = tempfile.mkdtemp()
|
|
self.thumb_dir = tempfile.mkdtemp()
|
|
|
|
self.overrides = override_settings(
|
|
ORIGINALS_DIR=self.originals_dir,
|
|
THUMBNAIL_DIR=self.thumb_dir,
|
|
)
|
|
|
|
self.overrides.enable()
|
|
|
|
def tearDown(self) -> None:
|
|
shutil.rmtree(self.originals_dir)
|
|
shutil.rmtree(self.thumb_dir)
|
|
self.overrides.disable()
|
|
|
|
def test_file_deletion(self) -> None:
|
|
document = Document.objects.create(
|
|
correspondent=Correspondent.objects.create(name="Test0"),
|
|
title="Title",
|
|
content="content",
|
|
checksum="checksum",
|
|
mime_type="application/pdf",
|
|
)
|
|
|
|
file_path = document.source_path
|
|
thumb_path = document.thumbnail_path
|
|
|
|
Path(file_path).touch()
|
|
Path(thumb_path).touch()
|
|
|
|
with mock.patch(
|
|
"documents.signals.handlers.Path.unlink",
|
|
autospec=True,
|
|
) as mock_unlink:
|
|
document.delete()
|
|
empty_trash([document.pk])
|
|
|
|
target_paths: set[str] = {str(file_path), str(thumb_path)}
|
|
|
|
actual_deletions = [
|
|
call
|
|
for call in mock_unlink.call_args_list
|
|
if str(call.args[0]) in target_paths
|
|
]
|
|
|
|
self.assertEqual(len(actual_deletions), 2)
|
|
|
|
def test_document_soft_delete(self) -> None:
|
|
document = Document.objects.create(
|
|
correspondent=Correspondent.objects.create(name="Test0"),
|
|
title="Title",
|
|
content="content",
|
|
checksum="checksum",
|
|
mime_type="application/pdf",
|
|
)
|
|
|
|
file_path = document.source_path
|
|
thumb_path = document.thumbnail_path
|
|
|
|
Path(file_path).touch()
|
|
Path(thumb_path).touch()
|
|
|
|
target_paths: set[str] = {str(file_path), str(thumb_path)}
|
|
|
|
with mock.patch(
|
|
"documents.signals.handlers.Path.unlink",
|
|
autospec=True,
|
|
) as mock_unlink:
|
|
document.delete()
|
|
self.assertEqual(mock_unlink.call_count, 0)
|
|
|
|
self.assertEqual(Document.objects.count(), 0)
|
|
|
|
document.restore(strict=False)
|
|
self.assertEqual(Document.objects.count(), 1)
|
|
|
|
document.delete()
|
|
empty_trash([document.pk])
|
|
|
|
actual_deletions = [
|
|
call
|
|
for call in mock_unlink.call_args_list
|
|
if str(call.args[0]) in target_paths
|
|
]
|
|
|
|
self.assertEqual(len(actual_deletions), 2)
|
|
|
|
def test_delete_root_deletes_versions(self) -> None:
|
|
root = Document.objects.create(
|
|
correspondent=Correspondent.objects.create(name="Test0"),
|
|
title="Head",
|
|
content="content",
|
|
checksum="checksum",
|
|
mime_type="application/pdf",
|
|
)
|
|
version = Document.objects.create(
|
|
root_document=root,
|
|
correspondent=root.correspondent,
|
|
title="Version",
|
|
content="content",
|
|
checksum="checksum2",
|
|
mime_type="application/pdf",
|
|
)
|
|
|
|
root.delete()
|
|
|
|
self.assertEqual(Document.objects.count(), 0)
|
|
self.assertEqual(Document.deleted_objects.count(), 2)
|
|
|
|
root.restore(strict=False)
|
|
|
|
self.assertTrue(Document.objects.filter(pk=version.pk).exists())
|
|
|
|
def test_file_name(self) -> None:
|
|
doc = Document(
|
|
mime_type="application/pdf",
|
|
title="test",
|
|
created=date(2020, 12, 25),
|
|
)
|
|
self.assertEqual(doc.get_public_filename(), "2020-12-25 test.pdf")
|
|
|
|
def test_file_name_jpg(self) -> None:
|
|
doc = Document(
|
|
mime_type="image/jpeg",
|
|
title="test",
|
|
created=date(2020, 12, 25),
|
|
)
|
|
self.assertEqual(doc.get_public_filename(), "2020-12-25 test.jpg")
|
|
|
|
def test_file_name_unknown(self) -> None:
|
|
doc = Document(
|
|
mime_type="application/zip",
|
|
title="test",
|
|
created=date(2020, 12, 25),
|
|
)
|
|
self.assertEqual(doc.get_public_filename(), "2020-12-25 test.zip")
|
|
|
|
def test_file_name_invalid_type(self) -> None:
|
|
doc = Document(
|
|
mime_type="image/jpegasd",
|
|
title="test",
|
|
created=date(2020, 12, 25),
|
|
)
|
|
self.assertEqual(doc.get_public_filename(), "2020-12-25 test")
|
|
|
|
def test_version_file_name_uses_root_document_metadata(self) -> None:
|
|
root_correspondent = Correspondent.objects.create(name="Root correspondent")
|
|
version_correspondent = Correspondent.objects.create(
|
|
name="Version correspondent",
|
|
)
|
|
root = Document.objects.create(
|
|
mime_type="application/pdf",
|
|
title="Root title",
|
|
created=date(2020, 12, 25),
|
|
correspondent=root_correspondent,
|
|
)
|
|
version = Document.objects.create(
|
|
mime_type="application/pdf",
|
|
title="Version title",
|
|
created=date(1990, 1, 1),
|
|
correspondent=version_correspondent,
|
|
root_document=root,
|
|
version_index=1,
|
|
)
|
|
|
|
self.assertEqual(
|
|
version.get_public_filename(),
|
|
"2020-12-25 Root correspondent Root title.pdf",
|
|
)
|
|
|
|
root.title = "Updated root title"
|
|
root.save(update_fields=("title",))
|
|
version.refresh_from_db()
|
|
|
|
self.assertEqual(
|
|
version.get_public_filename(),
|
|
"2020-12-25 Root correspondent Updated root title.pdf",
|
|
)
|
|
|
|
def test_suggestion_content_uses_latest_version_content_for_root_documents(
|
|
self,
|
|
) -> None:
|
|
root = Document.objects.create(
|
|
title="root",
|
|
checksum="root",
|
|
mime_type="application/pdf",
|
|
content="outdated root content",
|
|
)
|
|
version = Document.objects.create(
|
|
title="v1",
|
|
checksum="v1",
|
|
mime_type="application/pdf",
|
|
root_document=root,
|
|
content="latest version content",
|
|
)
|
|
|
|
self.assertEqual(root.suggestion_content, version.content)
|
|
|
|
def test_content_length_is_per_document_row_for_versions(self) -> None:
|
|
root = Document.objects.create(
|
|
title="root",
|
|
checksum="root",
|
|
mime_type="application/pdf",
|
|
content="abc",
|
|
)
|
|
version = Document.objects.create(
|
|
title="v1",
|
|
checksum="v1",
|
|
mime_type="application/pdf",
|
|
root_document=root,
|
|
content="abcdefgh",
|
|
)
|
|
|
|
root.refresh_from_db()
|
|
version.refresh_from_db()
|
|
|
|
self.assertEqual(root.content_length, 3)
|
|
self.assertEqual(version.content_length, 8)
|
|
|
|
|
|
def test_suggestion_content() -> None:
|
|
"""
|
|
Check that the document for suggestion is cropped, only if it exceeds the length limit.
|
|
"""
|
|
fake_text = Faker().text(max_nb_chars=1201000)
|
|
|
|
# Do not crop content under 1.2M chars
|
|
content_under_limit = fake_text[:1200000]
|
|
doc = Document(
|
|
title="test",
|
|
created=date(2025, 6, 1),
|
|
content=content_under_limit,
|
|
)
|
|
assert doc.suggestion_content == content_under_limit
|
|
|
|
# If over the limit, crop to 1M char (800K from the beginning, 200K from the end)
|
|
content_over_limit = fake_text[:1200001]
|
|
expected_cropped_content = (
|
|
content_over_limit[:800000] + " " + content_over_limit[-200000:]
|
|
)
|
|
doc.content = content_over_limit
|
|
assert doc.suggestion_content == expected_cropped_content
|
|
|
|
|
|
class TestPublicFilenameLength:
|
|
@pytest.fixture(autouse=True)
|
|
def _no_parser_registry(self, mocker: MockerFixture) -> None:
|
|
# The parser registry reads ApplicationConfiguration from the database
|
|
extensions = {"application/pdf": ".pdf", "image/jpeg": ".jpg"}
|
|
mocker.patch(
|
|
"documents.models.get_default_file_extension",
|
|
side_effect=extensions.__getitem__,
|
|
)
|
|
|
|
LIMIT = Document.MAX_PUBLIC_FILENAME_BYTES
|
|
|
|
@pytest.mark.parametrize(
|
|
("title", "archive", "counter", "max_bytes", "tail"),
|
|
[
|
|
pytest.param("a" * 300, False, 0, LIMIT, ".jpg", id="ascii"),
|
|
pytest.param("a" * 300, True, 0, LIMIT, ".pdf", id="archive"),
|
|
pytest.param("ä" * 200, False, 0, LIMIT, ".jpg", id="umlaut"),
|
|
pytest.param("ä" * 200, False, 1, LIMIT, "_01.jpg", id="count"),
|
|
pytest.param("a" * 300, False, 1, 100, "_01.jpg", id="max"),
|
|
],
|
|
)
|
|
def test_long_title_keeps_tail_within_limit(
|
|
self,
|
|
title: str,
|
|
*,
|
|
archive: bool,
|
|
counter: int,
|
|
max_bytes: int,
|
|
tail: str,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A document whose title alone is longer than the byte limit
|
|
WHEN:
|
|
- The public file name is generated
|
|
THEN:
|
|
- The name ends with the counter and extension
|
|
- The name fits in the limit
|
|
"""
|
|
name = DocumentFactory.build(
|
|
title=title,
|
|
mime_type="image/jpeg",
|
|
).get_public_filename(
|
|
archive=archive,
|
|
counter=counter,
|
|
max_bytes=max_bytes,
|
|
)
|
|
|
|
assert name.endswith(tail)
|
|
assert len(name.encode()) <= max_bytes
|
|
|
|
def test_long_title_counters_give_distinct_names(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A document whose title alone is longer than the byte limit
|
|
WHEN:
|
|
- Public file names are generated with increasing counters
|
|
THEN:
|
|
- Each counter produces a distinct name
|
|
"""
|
|
doc = DocumentFactory.build(title="ä" * 200, mime_type="application/pdf")
|
|
|
|
names = [doc.get_public_filename(counter=counter) for counter in range(3)]
|
|
|
|
assert len(set(names)) == 3
|
|
assert all(len(n.encode()) <= self.LIMIT for n in names)
|