Fix: validate legacy bulk_edit owner/rotate/split parameters (#14120)

BulkEditSerializer's hand-parsed validators only caught the exceptions
their happy paths raised, so wrong-typed input produced a 500 or was
passed through to the task:

- owner: a nonexistent or wrong-typed id raised an uncaught error, and a
  boolean was accepted (Django coerces True to pk 1). It is now validated
  with PrimaryKeyRelatedField and the validated pk is passed on.
- rotate: null raised TypeError, while true, "90" and 45 were accepted and
  failed later in QPDF. Degrees are now an IntegerField plus a
  multiple-of-90 check, passing an int on. The dedicated rotate endpoint
  and edit_pdf operations share the same multiple-of-90 check.
- split: null raised AttributeError, "0" silently became the last page,
  "3-1" gave an empty group that crashed the task, and a range like
  "1-5000000" was expanded into a list during the request with no upper
  bound. Each range is now checked against 1 <= start <= end <= page_count
  before it is built.
This commit is contained in:
Trenton H
2026-09-15 19:23:37 +00:00
committed by GitHub
parent 73de6b17bf
commit 683f2ac250
2 changed files with 254 additions and 36 deletions
+211
View File
@@ -1200,6 +1200,48 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertIn(expected_message, response.content)
m.assert_not_called()
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
def test_set_permissions_rejects_invalid_owner(self, m) -> None:
"""
GIVEN:
- A set_permissions bulk edit with an owner that is a nonexistent
id, a boolean, a list, a dict, or a non-numeric string
WHEN:
- API to bulk edit is called
THEN:
- API returns HTTP 400
- set_permissions is not called
"""
self.setup_mock(m, "set_permissions")
for bad_owner in (
999999,
True,
["not", "an", "id"],
{"nested": "dict"},
"not-a-number",
):
with self.subTest(owner=bad_owner):
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc2.id],
"method": "set_permissions",
"parameters": {
"set_permissions": {
"view": {"users": [self.user.id]},
},
"owner": bad_owner,
},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(b"Specified owner cannot be found", 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:
"""
@@ -1236,6 +1278,37 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
{"view": {}, "change": {}},
)
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
def test_set_permissions_passes_validated_owner_id(self, m) -> None:
"""
GIVEN:
- A set_permissions bulk edit with the owner id given as a string
WHEN:
- API to bulk edit is called
THEN:
- set_permissions receives the owner as an integer id
"""
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": [self.user.id]}},
"owner": str(self.user.id),
},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
m.assert_called_once()
self.assertEqual(m.call_args.kwargs["owner"], self.user.id)
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
def test_set_permissions_merge(self, m) -> None:
self.setup_mock(m, "set_permissions")
@@ -1507,8 +1580,146 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
response = self.client.post(
"/api/documents/rotate/",
json.dumps(
{
"documents": [self.doc2.id, self.doc3.id],
"degrees": 45,
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(b"degrees must be a multiple of 90", response.content)
m.assert_not_called()
@mock.patch("documents.serialisers.bulk_edit.rotate")
def test_bulk_edit_rotate_rejects_invalid_degrees(self, m) -> None:
"""
GIVEN:
- A legacy rotate bulk edit with degrees that are null, not an
integer, or not a multiple of 90
WHEN:
- API to bulk edit is called
THEN:
- API returns HTTP 400
- rotate is not called
"""
self.setup_mock(m, "rotate")
for degrees, expected_message in (
(None, b"invalid rotation degrees"),
(True, b"invalid rotation degrees"),
("foo", b"invalid rotation degrees"),
(90.5, b"invalid rotation degrees"),
(45, b"degrees must be a multiple of 90"),
):
with self.subTest(degrees=degrees):
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc2.id],
"method": "rotate",
"parameters": {"degrees": degrees},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(expected_message, response.content)
m.assert_not_called()
@mock.patch("documents.serialisers.bulk_edit.rotate")
def test_bulk_edit_rotate_passes_integer_degrees(self, m) -> None:
"""
GIVEN:
- A legacy rotate bulk edit with degrees given as a string
WHEN:
- API to bulk edit is called
THEN:
- rotate receives the degrees as an integer
"""
self.setup_mock(m, "rotate")
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc2.id],
"method": "rotate",
"parameters": {"degrees": "-90"},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
m.assert_called_once()
self.assertEqual(m.call_args.kwargs["degrees"], -90)
@mock.patch("documents.serialisers.bulk_edit.split")
def test_bulk_edit_split_rejects_invalid_pages(self, m) -> None:
"""
GIVEN:
- A legacy split bulk edit of a 5 page document with pages that
are not a string, not numeric, zero, a reversed range, or past
the last page
WHEN:
- API to bulk edit is called
THEN:
- API returns HTTP 400
- split is not called
"""
self.setup_mock(m, "split")
for pages in (None, "", "a", "1-2-3", "0", "3-1", "1-6", "1-5000000"):
with self.subTest(pages=pages):
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc2.id],
"method": "split",
"parameters": {"pages": pages},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(b"invalid pages specified", response.content)
m.assert_not_called()
@mock.patch("documents.serialisers.bulk_edit.split")
def test_bulk_edit_split_parses_pages(self, m) -> None:
"""
GIVEN:
- A legacy split bulk edit with single pages and ranges
WHEN:
- API to bulk edit is called
THEN:
- split receives one page list per comma separated group
"""
self.setup_mock(m, "split")
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc2.id],
"method": "split",
"parameters": {"pages": "1,2-4,5"},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
m.assert_called_once()
self.assertEqual(m.call_args.kwargs["pages"], [[1], [2, 3, 4], [5]])
@mock.patch("documents.views.bulk_edit.rotate")
def test_rotate_insufficient_permissions(self, m) -> None:
self.doc1.owner = User.objects.get(username="temp_admin")