Compare commits

..
Author SHA1 Message Date
Trenton Holmes 34f2385df4 Fix: resolve silent duplication from rebase onto fix/bulk_edit
Rebasing onto origin/fix/bulk_edit (PR #14083, which independently
bounds the edit_pdf doc index via manual checks) applied cleanly with
no reported conflicts, but left two copies of the same "doc index is
out of bounds" check back to back in both EditPdfDocumentsSerializer
.validate and BulkEditSerializer._validate_parameters_edit_pdf --
#14083's own `< 0 or >= len(operations)` check is now fully redundant
here since PdfEditOperationSerializer.doc already has min_value=0.
Removed the redundant second check in both methods.

Also split the merged test_edit_pdf_invalid_params subtest: #14083's
loop asserted the same "doc index is out of bounds" message for both
doc=-1 and doc=2**32, but with min_value=0 in place, -1 is now
rejected earlier by the field itself with a different message.
2026-09-13 15:06:52 -07:00
Trenton HolmesandClaude Sonnet 5 1ff18656b7 Fix: type edit_pdf operations via a nested serializer
operations was a plain ListField(required=True) with no child=, so
each element was untyped, and both EditPdfDocumentsSerializer.validate
and BulkEditSerializer._validate_parameters_edit_pdf hand-checked
page/rotate/doc with isinstance(). This let a negative doc index
through silently (used as a wrapping Python list index instead of
being rejected), an empty operations list crashed
`max(op.get("doc", 0) for op in operations)` with update_document=True,
and an out-of-range doc index could drive pikepdf.new() to allocate an
unbounded number of objects.

Added PdfEditOperationSerializer (page: IntegerField(min_value=1),
rotate/doc: IntegerField, doc: min_value=0) and used it as
ListField(child=..., allow_empty=False) in both places, plus a
doc-index bound of len(operations) shared by both validate() methods.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 15:02:31 -07:00
GitHub Actions 57e0a17571 Auto translate strings 2026-09-13 15:02:31 -07:00
shamoon 3fc85c82e1 Chore: include Apply AI Suggestions in the tasks UI filter dropdown (#14093) 2026-09-13 15:02:31 -07:00
GitHub Actions 54ce5f9f61 Auto translate strings 2026-09-13 15:02:31 -07:00
shamoon f5a7ab062e Fix: update some api global perms inconsistencies (#14086) 2026-09-13 15:02:31 -07:00
GitHub Actions 9a317844c2 Auto translate strings 2026-09-13 15:02:31 -07:00
shamoon fc4107d390 Fix: ignore nested action IDs on WF create (#14084) 2026-09-13 15:02:31 -07:00
shamoon 4acc0bed54 Chore: read-only deleted_at 2026-09-13 15:02:31 -07:00
shamoon 9425f53d5c fix: validate PDF output doc indexes in bulk edit 2026-09-12 15:20:40 -07:00
5 changed files with 269 additions and 279 deletions
+16 -7
View File
@@ -899,17 +899,26 @@ def edit_pdf(
pdf_docs: list[pikepdf.Pdf] = []
try:
if not operations:
raise ValueError("Output document index is out of bounds")
max_idx = max(op.get("doc", 0) for op in operations)
if update_document and max_idx > 0:
logger.error(
"Update requested but multiple output documents specified",
)
raise ValueError("Multiple output documents specified")
if any(
op.get("doc", 0) < 0 or op.get("doc", 0) >= len(operations)
for op in operations
):
raise ValueError("Output document index is out of bounds")
with pikepdf.open(pair.source_doc.source_path) as src:
# prepare output documents
max_idx = max(op.get("doc", 0) for op in operations)
pdf_docs = [pikepdf.new() for _ in range(max_idx + 1)]
if update_document and len(pdf_docs) > 1:
logger.error(
"Update requested but multiple output documents specified",
)
raise ValueError("Multiple output documents specified")
for op in operations:
dst = pdf_docs[op.get("doc", 0)]
page = src.pages[op["page"] - 1]
+76 -71
View File
@@ -210,9 +210,6 @@ class MatchingModelSerializer(serializers.ModelSerializer[Any]):
return match
PERMISSION_ACTIONS = ("view", "change")
class SetPermissionsMixin:
def _validate_user_ids(self, user_ids):
users = User.objects.none()
@@ -235,9 +232,12 @@ class SetPermissionsMixin:
return groups
def validate_set_permissions(self, set_permissions=None):
permissions_dict = {action: {} for action in PERMISSION_ACTIONS}
permissions_dict = {
"view": {},
"change": {},
}
if set_permissions is not None:
for action in PERMISSION_ACTIONS:
for action in ["view", "change"]:
if action in set_permissions:
if "users" in set_permissions[action]:
users = set_permissions[action]["users"]
@@ -265,31 +265,41 @@ class SerializerWithPerms(serializers.Serializer[dict[str, Any]]):
super().__init__(*args, **kwargs)
class _PermissionSetSerializer(serializers.Serializer[dict[str, Any]]):
users = serializers.ListField(
child=serializers.IntegerField(),
required=False,
allow_null=True,
)
groups = serializers.ListField(
child=serializers.IntegerField(),
required=False,
allow_null=True,
)
class SetPermissionsSerializer(serializers.Serializer[dict[str, Any]]):
view = _PermissionSetSerializer(required=False)
change = _PermissionSetSerializer(required=False)
def to_internal_value(self, data):
if isinstance(data, dict):
unknown_keys = set(data) - set(PERMISSION_ACTIONS)
if unknown_keys:
raise serializers.ValidationError(
{key: "Unknown permission action." for key in sorted(unknown_keys)},
)
return super().to_internal_value(data)
@extend_schema_field(
field={
"type": "object",
"properties": {
"view": {
"type": "object",
"properties": {
"users": {
"type": "array",
"items": {"type": "integer"},
},
"groups": {
"type": "array",
"items": {"type": "integer"},
},
},
},
"change": {
"type": "object",
"properties": {
"users": {
"type": "array",
"items": {"type": "integer"},
},
"groups": {
"type": "array",
"items": {"type": "integer"},
},
},
},
},
},
)
class SetPermissionsSerializer(serializers.DictField):
pass
class OwnedObjectSerializer(
@@ -460,6 +470,7 @@ class OwnedObjectSerializer(
set_permissions = SetPermissionsSerializer(
label="Set permissions",
allow_empty=True,
required=False,
write_only=True,
)
@@ -1738,8 +1749,18 @@ class MergeDocumentsAsVersionsSerializer(DocumentListSerializer):
return attrs
class PdfEditOperationSerializer(serializers.Serializer[dict[str, int]]):
page = serializers.IntegerField(min_value=1)
rotate = serializers.IntegerField(required=False)
doc = serializers.IntegerField(required=False, min_value=0)
class EditPdfDocumentsSerializer(DocumentListSerializer, SourceModeValidationMixin):
operations = serializers.ListField(required=True)
operations = serializers.ListField(
child=PdfEditOperationSerializer(),
required=True,
allow_empty=False,
)
delete_original = serializers.BooleanField(required=False, default=False)
update_document = serializers.BooleanField(required=False, default=False)
include_metadata = serializers.BooleanField(required=False, default=True)
@@ -1757,18 +1778,9 @@ class EditPdfDocumentsSerializer(DocumentListSerializer, SourceModeValidationMix
)
operations = attrs["operations"]
if not isinstance(operations, list):
raise serializers.ValidationError("operations must be a list")
for op in operations:
if not isinstance(op, dict):
raise serializers.ValidationError("invalid operation entry")
if "page" not in op or not isinstance(op["page"], int):
raise serializers.ValidationError("page must be an integer")
if "rotate" in op and not isinstance(op["rotate"], int):
raise serializers.ValidationError("rotate must be an integer")
if "doc" in op and not isinstance(op["doc"], int):
raise serializers.ValidationError("doc must be an integer")
if any(op.get("doc", 0) >= len(operations) for op in operations):
raise serializers.ValidationError("doc index is out of bounds")
if attrs["update_document"]:
max_idx = max(op.get("doc", 0) for op in operations)
@@ -1780,7 +1792,7 @@ class EditPdfDocumentsSerializer(DocumentListSerializer, SourceModeValidationMix
doc = Document.objects.get(id=documents[0])
if doc.page_count:
for op in operations:
if op["page"] < 1 or op["page"] > doc.page_count:
if op["page"] > doc.page_count:
raise serializers.ValidationError(
f"Page {op['page']} is out of bounds for document with {doc.page_count} pages.",
)
@@ -2034,13 +2046,8 @@ class BulkEditSerializer(
def _validate_parameters_set_permissions(self, parameters) -> None:
if "set_permissions" not in parameters:
raise serializers.ValidationError("set_permissions not specified")
set_permissions = parameters["set_permissions"]
if set_permissions is not None:
set_permissions = SetPermissionsSerializer().run_validation(
set_permissions,
)
parameters["set_permissions"] = self.validate_set_permissions(
set_permissions,
parameters["set_permissions"],
)
if "owner" in parameters and parameters["owner"] is not None:
self._validate_owner(parameters["owner"])
@@ -2116,17 +2123,15 @@ class BulkEditSerializer(
def _validate_parameters_edit_pdf(self, parameters, document_id) -> None:
if "operations" not in parameters:
raise serializers.ValidationError("operations not specified")
if not isinstance(parameters["operations"], list):
raise serializers.ValidationError("operations must be a list")
for op in parameters["operations"]:
if not isinstance(op, dict):
raise serializers.ValidationError("invalid operation entry")
if "page" not in op or not isinstance(op["page"], int):
raise serializers.ValidationError("page must be an integer")
if "rotate" in op and not isinstance(op["rotate"], int):
raise serializers.ValidationError("rotate must be an integer")
if "doc" in op and not isinstance(op["doc"], int):
raise serializers.ValidationError("doc must be an integer")
operations_field = serializers.ListField(
child=PdfEditOperationSerializer(),
allow_empty=False,
)
parameters["operations"] = operations_field.run_validation(
parameters["operations"],
)
operations = parameters["operations"]
if "update_document" in parameters:
if not isinstance(parameters["update_document"], bool):
raise serializers.ValidationError("update_document must be a boolean")
@@ -2138,8 +2143,11 @@ class BulkEditSerializer(
else:
parameters["include_metadata"] = True
if any(op.get("doc", 0) >= len(operations) for op in operations):
raise serializers.ValidationError("doc index is out of bounds")
if parameters["update_document"]:
max_idx = max(op.get("doc", 0) for op in parameters["operations"])
max_idx = max(op.get("doc", 0) for op in operations)
if max_idx > 0:
raise serializers.ValidationError(
"update_document only allowed with a single output document",
@@ -2148,8 +2156,8 @@ class BulkEditSerializer(
doc = Document.objects.get(id=document_id)
# doc existence is already validated
if doc.page_count:
for op in parameters["operations"]:
if op["page"] < 1 or op["page"] > doc.page_count:
for op in operations:
if op["page"] > doc.page_count:
raise serializers.ValidationError(
f"Page {op['page']} is out of bounds for document with {doc.page_count} pages.",
)
@@ -2995,8 +3003,9 @@ class BulkEditObjectsSerializer(SerializerWithPerms, SetPermissionsMixin):
allow_null=True,
)
permissions = SetPermissionsSerializer(
permissions = serializers.DictField(
label="Set permissions",
allow_empty=False,
required=False,
write_only=True,
)
@@ -3032,8 +3041,8 @@ class BulkEditObjectsSerializer(SerializerWithPerms, SetPermissionsMixin):
)
return objects
def _validate_permissions(self, permissions) -> dict:
return self.validate_set_permissions(
def _validate_permissions(self, permissions) -> None:
self.validate_set_permissions(
permissions,
)
@@ -3057,11 +3066,7 @@ class BulkEditObjectsSerializer(SerializerWithPerms, SetPermissionsMixin):
if operation == "set_permissions":
permissions = attrs.get("permissions")
if permissions is not None:
if not permissions:
raise serializers.ValidationError(
"permissions must not be empty",
)
attrs["permissions"] = self._validate_permissions(permissions)
self._validate_permissions(permissions)
return attrs
+166 -98
View File
@@ -1165,98 +1165,6 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertIn(b"set_permissions not specified", response.content)
m.assert_not_called()
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
def test_set_permissions_rejects_non_dict_value(self, m) -> None:
self.setup_mock(m, "set_permissions")
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc2.id],
"method": "set_permissions",
"parameters": {"set_permissions": False},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(b"Expected a dictionary", response.content)
m.assert_not_called()
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
def test_set_permissions_rejects_non_list_users(self, m) -> None:
self.setup_mock(m, "set_permissions")
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc2.id],
"method": "set_permissions",
"parameters": {
"set_permissions": {"view": {"users": False}},
},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(b"Expected a list", response.content)
m.assert_not_called()
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
def test_set_permissions_rejects_unknown_action(self, m) -> None:
self.setup_mock(m, "set_permissions")
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc2.id],
"method": "set_permissions",
"parameters": {
"set_permissions": {"not_a_real_action": {"users": [1]}},
},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(b"Unknown permission action", response.content)
m.assert_not_called()
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
def test_set_permissions_null_is_a_noop(self, m) -> None:
"""
A `set_permissions: null` value is a deliberate no-op (e.g. a
request that only updates `owner`), not a validation error --
this must keep working even though every other non-dict value is
now rejected.
"""
self.setup_mock(m, "set_permissions")
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc2.id],
"method": "set_permissions",
"parameters": {
"set_permissions": None,
"owner": self.user.id,
},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
m.assert_called_once()
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
def test_set_permissions_merge(self, m) -> None:
self.setup_mock(m, "set_permissions")
@@ -1392,7 +1300,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.client.force_authenticate(user=user1)
permissions = {
"view": {"users": [user1.id]},
"owner": user1.id,
}
response = self.client.post(
@@ -1741,6 +1649,24 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test_legacy_bulk_edit_rejects_out_of_bounds_pdf_doc_index(self) -> None:
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc2.id],
"method": "edit_pdf",
"parameters": {
"operations": [{"page": 1, "doc": 2**32}],
},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(b"doc index is out of bounds", response.content)
@mock.patch("documents.views.bulk_edit.edit_pdf")
def test_edit_pdf(self, m) -> None:
self.setup_mock(m, "edit_pdf")
@@ -1802,7 +1728,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(b"invalid operation entry", response.content)
self.assertIn(b"Expected a dictionary", response.content)
response = self.client.post(
"/api/documents/edit_pdf/",
@@ -1815,7 +1741,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(b"page must be an integer", response.content)
self.assertIn(b"valid integer is required", response.content)
response = self.client.post(
"/api/documents/edit_pdf/",
@@ -1828,7 +1754,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(b"rotate must be an integer", response.content)
self.assertIn(b"valid integer is required", response.content)
response = self.client.post(
"/api/documents/edit_pdf/",
@@ -1841,7 +1767,29 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(b"doc must be an integer", response.content)
self.assertIn(b"valid integer is required", response.content)
# A negative doc index is rejected by PdfEditOperationSerializer's
# own min_value=0 field constraint, before the "doc index is out
# of bounds" object-level check (against len(operations)) ever
# runs -- hence the different expected message per case.
for doc_index, expected_message in (
(-1, b"greater than or equal to 0"),
(2**32, b"doc index is out of bounds"),
):
with self.subTest(doc_index=doc_index):
response = self.client.post(
"/api/documents/edit_pdf/",
json.dumps(
{
"documents": [self.doc2.id],
"operations": [{"page": 1, "doc": doc_index}],
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(expected_message, response.content)
response = self.client.post(
"/api/documents/edit_pdf/",
@@ -1849,7 +1797,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
{
"documents": [self.doc2.id],
"update_document": True,
"operations": [{"page": 1, "doc": 1}, {"page": 2, "doc": 2}],
"operations": [{"page": 1, "doc": 0}, {"page": 2, "doc": 1}],
},
),
content_type="application/json",
@@ -1874,6 +1822,86 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(b"Invalid source_mode", response.content)
@mock.patch("documents.views.bulk_edit.edit_pdf")
def test_edit_pdf_rejects_empty_operations(self, m) -> None:
"""
An empty operations list previously reached bulk_edit.edit_pdf()
and crashed with `ValueError: max() iterable argument is empty`
(via `max(op.get("doc", 0) for op in operations)`) whenever
update_document was true. Must now be rejected up front.
"""
self.setup_mock(m, "edit_pdf")
response = self.client.post(
"/api/documents/edit_pdf/",
json.dumps(
{
"documents": [self.doc2.id],
"operations": [],
"update_document": True,
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
m.assert_not_called()
@mock.patch("documents.views.bulk_edit.edit_pdf")
def test_edit_pdf_rejects_negative_doc_index(self, m) -> None:
"""
A negative `doc` index was previously silently accepted and used
as a wrapping Python list index instead of being rejected.
"""
self.setup_mock(m, "edit_pdf")
response = self.client.post(
"/api/documents/edit_pdf/",
json.dumps(
{
"documents": [self.doc2.id],
"operations": [{"page": 1, "doc": -1}],
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
m.assert_not_called()
@mock.patch("documents.views.bulk_edit.edit_pdf")
def test_edit_pdf_rejects_out_of_bounds_doc_index(self, m) -> None:
"""
A `doc` index far larger than the number of operations previously
drove `pdf_docs = [pikepdf.new() for _ in range(max_idx + 1)]` to
attempt allocating an enormous number of real objects.
"""
self.setup_mock(m, "edit_pdf")
response = self.client.post(
"/api/documents/edit_pdf/",
json.dumps(
{
"documents": [self.doc2.id],
"operations": [{"page": 1, "doc": 2**33}],
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
m.assert_not_called()
@mock.patch("documents.views.bulk_edit.edit_pdf")
def test_edit_pdf_rejects_non_positive_page(self, m) -> None:
self.setup_mock(m, "edit_pdf")
response = self.client.post(
"/api/documents/edit_pdf/",
json.dumps(
{
"documents": [self.doc2.id],
"operations": [{"page": 0}],
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
m.assert_not_called()
@mock.patch("documents.views.bulk_edit.edit_pdf")
def test_edit_pdf_page_out_of_bounds(self, m) -> None:
self.setup_mock(m, "edit_pdf")
@@ -1891,6 +1919,46 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertIn(b"out of bounds", response.content)
m.assert_not_called()
@mock.patch("documents.serialisers.bulk_edit.edit_pdf")
def test_bulk_edit_edit_pdf_rejects_empty_operations(self, m) -> None:
"""
Same validation gap as test_edit_pdf_rejects_empty_operations, but
via the legacy generic /api/documents/bulk_edit/ method="edit_pdf"
path, which hand-parses `parameters["operations"]` independently
in BulkEditSerializer._validate_parameters_edit_pdf.
"""
self.setup_mock(m, "edit_pdf")
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc2.id],
"method": "edit_pdf",
"parameters": {"operations": [], "update_document": True},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
m.assert_not_called()
@mock.patch("documents.serialisers.bulk_edit.edit_pdf")
def test_bulk_edit_edit_pdf_rejects_out_of_bounds_doc_index(self, m) -> None:
self.setup_mock(m, "edit_pdf")
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc2.id],
"method": "edit_pdf",
"parameters": {"operations": [{"page": 1, "doc": 2**33}]},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
m.assert_not_called()
@mock.patch("documents.views.bulk_edit.edit_pdf")
def test_edit_pdf_insufficient_permissions(self, m) -> None:
self.doc1.owner = User.objects.get(username="temp_admin")
-103
View File
@@ -1499,109 +1499,6 @@ class TestBulkEditObjectPermissions(APITestCase):
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertTrue(Tag.objects.filter(pk=self.t1.id).exists())
def test_bulk_object_set_permissions_rejects_empty_permissions(self) -> None:
"""
GIVEN:
- Existing objects
WHEN:
- bulk_edit_objects API endpoint is called with set_permissions
operation and an empty permissions dict
THEN:
- Validation fails rather than silently applying a no-op
"""
response = self.client.post(
"/api/bulk_edit_objects/",
json.dumps(
{
"objects": [self.t1.id],
"object_type": "tags",
"operation": "set_permissions",
"permissions": {},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test_bulk_object_set_permissions_rejects_non_dict_permissions(self) -> None:
"""
GIVEN:
- Existing objects
WHEN:
- bulk_edit_objects API endpoint is called with set_permissions
operation and a non-dict permissions value
THEN:
- Validation fails rather than crashing
"""
response = self.client.post(
"/api/bulk_edit_objects/",
json.dumps(
{
"objects": [self.t1.id],
"object_type": "tags",
"operation": "set_permissions",
"permissions": False,
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test_bulk_object_set_permissions_rejects_unknown_action(self) -> None:
"""
GIVEN:
- Existing objects
WHEN:
- bulk_edit_objects API endpoint is called with set_permissions
operation and an unrecognized permission action name
THEN:
- Validation fails rather than silently no-oping
"""
response = self.client.post(
"/api/bulk_edit_objects/",
json.dumps(
{
"objects": [self.t1.id],
"object_type": "tags",
"operation": "set_permissions",
"permissions": {"not_a_real_action": {"users": [self.user1.id]}},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test_bulk_object_set_permissions_null_users_is_a_noop(self) -> None:
"""
GIVEN:
- Existing objects
WHEN:
- bulk_edit_objects API endpoint is called with set_permissions
operation and an explicit null for users/groups on an action
THEN:
- Request succeeds and is treated as "no users/groups for this
action", not a crash -- the normalized (id-checked) dict
returned by validate_set_permissions must actually be used,
not discarded in favor of the raw un-normalized input.
"""
response = self.client.post(
"/api/bulk_edit_objects/",
json.dumps(
{
"objects": [self.t1.id],
"object_type": "tags",
"operation": "set_permissions",
"permissions": {"view": {"users": None}},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_bulk_edit_object_permissions_validation(self) -> None:
"""
GIVEN:
+11
View File
@@ -1642,6 +1642,17 @@ class TestPDFActions(DirectoriesMixin, TestCase):
mock_group.assert_not_called()
mock_consume_file.assert_not_called()
@mock.patch("pikepdf.open")
def test_edit_pdf_rejects_out_of_bounds_output_index(self, mock_open) -> None:
with self.assertLogs("paperless.bulk_edit", level="ERROR"):
with self.assertRaisesRegex(ValueError, "index is out of bounds"):
bulk_edit.edit_pdf(
[self.doc2.id],
[{"page": 1, "doc": 2**32}],
)
mock_open.assert_not_called()
@mock.patch("documents.bulk_edit.update_document_content_maybe_archive_file.delay")
@mock.patch("documents.tasks.consume_file.apply_async")
@mock.patch("documents.bulk_edit.tempfile.mkdtemp")