mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-11 02:27:13 +00:00
Fix: consistently use root doc for version action permissions (#14384)
This commit is contained in:
1 parent
474c630aa4
commit
a35bd6e238
6 files changed
+320
-47
No files matched your search
@@ -8,6 +8,7 @@ from auditlog.models import LogEntry # type: ignore[import-untyped]
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import TestCase as DjangoTestCase
|
||||
from django.test import override_settings
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
@@ -16,13 +17,17 @@ from documents.data_models import DocumentSource
|
||||
from documents.filters import EffectiveContentFilter
|
||||
from documents.filters import TitleContentFilter
|
||||
from documents.models import Document
|
||||
from documents.models import Note
|
||||
from documents.models import ShareLink
|
||||
from documents.versioning import annotate_effective_content
|
||||
from documents.views import DocumentSelectionMixin
|
||||
from paperless_testing.dirs import DirectoriesMixin
|
||||
from paperless_testing.factories import DocumentFactory
|
||||
from paperless_testing.factories import UserFactory
|
||||
from paperless_testing.http import read_streaming_response
|
||||
from paperless_testing.permissions import grant_all_global
|
||||
from paperless_testing.permissions import grant_global
|
||||
from paperless_testing.permissions import grant_object
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
@@ -1043,3 +1048,152 @@ class TestBulkSelectionExcludesVersions(DjangoTestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(selected, [root.id])
|
||||
|
||||
|
||||
class TestVersionActionPermissions(DirectoriesMixin, APITestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.user = UserFactory()
|
||||
grant_all_global(self.user)
|
||||
self.client.force_authenticate(self.user)
|
||||
self.root = DocumentFactory(owner=UserFactory())
|
||||
self.version = DocumentFactory(root_document=self.root, owner=None)
|
||||
|
||||
@override_settings(AUDIT_LOG_ENABLED=True)
|
||||
def test_actions_reject_stale_version_ownership(self):
|
||||
note = Note.objects.create(document=self.version, note="Version note")
|
||||
for owner in (None, self.user):
|
||||
self.version.owner = owner
|
||||
self.version.save(update_fields=["owner"])
|
||||
for action in (
|
||||
"notes",
|
||||
"suggestions",
|
||||
"ai_suggestions",
|
||||
"history",
|
||||
"share_links",
|
||||
):
|
||||
with self.subTest(owner=owner, action=action):
|
||||
response = self.client.get(
|
||||
f"/api/documents/{self.version.pk}/{action}/",
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
response = self.client.post(
|
||||
f"/api/documents/{self.version.pk}/notes/",
|
||||
{"note": "New note"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
response = self.client.delete(
|
||||
f"/api/documents/{self.version.pk}/notes/?id={note.pk}",
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
response = self.client.post(
|
||||
"/api/share_links/",
|
||||
{"document": self.version.pk, "file_version": "original"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
response = self.client.post(
|
||||
"/api/share_link_bundles/",
|
||||
{"document_ids": [self.version.pk], "file_version": "original"},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
response = self.client.post(
|
||||
"/api/documents/email/",
|
||||
{
|
||||
"documents": [self.version.pk],
|
||||
"addresses": "recipient@example.com",
|
||||
"subject": "Version",
|
||||
"message": "Version",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
with (
|
||||
mock.patch("documents.views.AIConfig") as ai_config,
|
||||
mock.patch("documents.views.stream_chat_with_documents") as chat,
|
||||
):
|
||||
ai_config.return_value.ai_enabled = True
|
||||
response = self.client.post(
|
||||
"/api/documents/chat/",
|
||||
{"q": "Version?", "document_id": self.version.pk},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
chat.assert_not_called()
|
||||
self.assertTrue(Note.objects.filter(pk=note.pk).exists())
|
||||
self.assertFalse(ShareLink.objects.exists())
|
||||
|
||||
@mock.patch("documents.views.build_share_link_bundle.apply_async")
|
||||
def test_root_permissions_allow_sharing_a_private_version(self, build_mock):
|
||||
self.version.owner = UserFactory()
|
||||
self.version.save(update_fields=["owner"])
|
||||
grant_object(self.user, self.root, "view_document", "change_document")
|
||||
note = Note.objects.create(document=self.version, note="Version note")
|
||||
response = self.client.get(f"/api/documents/{self.version.pk}/notes/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data[0]["id"], note.pk)
|
||||
response = self.client.post(
|
||||
"/api/share_links/",
|
||||
{"document": self.version.pk, "file_version": "original"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertEqual(ShareLink.objects.get().document_id, self.version.pk)
|
||||
response = self.client.get(f"/api/documents/{self.version.pk}/share_links/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(len(response.data), 1)
|
||||
response = self.client.post(
|
||||
"/api/share_link_bundles/",
|
||||
{"document_ids": [self.version.pk], "file_version": "original"},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 201)
|
||||
build_mock.assert_called_once()
|
||||
|
||||
def test_root_view_permission_does_not_allow_note_changes(self):
|
||||
grant_object(self.user, self.root, "view_document")
|
||||
note = Note.objects.create(document=self.version, note="Version note")
|
||||
response = self.client.get(f"/api/documents/{self.version.pk}/notes/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
response = self.client.post(
|
||||
f"/api/documents/{self.version.pk}/notes/",
|
||||
{"note": "New note"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
response = self.client.delete(
|
||||
f"/api/documents/{self.version.pk}/notes/?id={note.pk}",
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertTrue(Note.objects.filter(pk=note.pk).exists())
|
||||
|
||||
@override_settings(AUDIT_LOG_ENABLED=True)
|
||||
def test_history_uses_root_ownership(self):
|
||||
self.root.owner = self.user
|
||||
self.root.save(update_fields=["owner"])
|
||||
self.version.owner = UserFactory()
|
||||
self.version.save(update_fields=["owner"])
|
||||
response = self.client.get(f"/api/documents/{self.version.pk}/history/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_selection_data_rejects_stale_version_ownership(self):
|
||||
for owner in (None, self.user):
|
||||
self.version.owner = owner
|
||||
self.version.save(update_fields=["owner"])
|
||||
with self.subTest(owner=owner):
|
||||
response = self.client.post(
|
||||
"/api/documents/selection_data/",
|
||||
{"documents": [self.version.pk]},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_selection_data_allows_private_version_of_permitted_root(self):
|
||||
self.version.owner = UserFactory()
|
||||
self.version.save(update_fields=["owner"])
|
||||
grant_object(self.user, self.root, "view_document")
|
||||
other = DocumentFactory(owner=self.user)
|
||||
response = self.client.post(
|
||||
"/api/documents/selection_data/",
|
||||
{"documents": [self.version.pk, other.pk]},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
@@ -6,6 +6,7 @@ from rest_framework.test import APITestCase
|
||||
|
||||
from documents.models import Document
|
||||
from paperless_testing.dirs import DirectoriesMixin
|
||||
from paperless_testing.factories import DocumentFactory
|
||||
from paperless_testing.factories import UserFactory
|
||||
from paperless_testing.permissions import grant_all_global
|
||||
|
||||
@@ -279,3 +280,53 @@ class TestTrashAPI(DirectoriesMixin, APITestCase):
|
||||
Document.objects.filter(root_document=root).values_list("id", flat=True),
|
||||
[version.pk for version in versions],
|
||||
)
|
||||
|
||||
def test_api_trash_version_follows_root_owner(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A deleted version of user2's document, owned by nobody
|
||||
- A deleted version of the user's document, owned by user2
|
||||
WHEN:
|
||||
- The user lists the trash and tries to restore or empty the versions
|
||||
THEN:
|
||||
- Only the version of the user's own document is listed
|
||||
- The other version can't be restored or emptied
|
||||
- The version of the user's own document can be restored
|
||||
"""
|
||||
user2 = UserFactory(username="user2")
|
||||
other_version = DocumentFactory(
|
||||
root_document=DocumentFactory(owner=user2),
|
||||
version_index=1,
|
||||
)
|
||||
other_version.delete()
|
||||
own_version = DocumentFactory(
|
||||
owner=user2,
|
||||
root_document=DocumentFactory(owner=self.user),
|
||||
version_index=1,
|
||||
)
|
||||
own_version.delete()
|
||||
|
||||
resp = self.client.get("/api/trash/")
|
||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(
|
||||
[doc["id"] for doc in resp.data["results"]],
|
||||
[own_version.pk],
|
||||
)
|
||||
|
||||
for action in ("restore", "empty"):
|
||||
with self.subTest(action=action):
|
||||
resp = self.client.post(
|
||||
"/api/trash/",
|
||||
{"action": action, "documents": [other_version.pk]},
|
||||
)
|
||||
self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
|
||||
self.assertTrue(
|
||||
Document.deleted_objects.filter(pk=other_version.pk).exists(),
|
||||
)
|
||||
|
||||
resp = self.client.post(
|
||||
"/api/trash/",
|
||||
{"action": "restore", "documents": [own_version.pk]},
|
||||
)
|
||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||
self.assertTrue(Document.objects.filter(pk=own_version.pk).exists())
|
||||
@@ -90,10 +90,13 @@ class ShareLinkBundleAPITests(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn("document_ids", response.data)
|
||||
|
||||
@mock.patch("documents.views.permitted_document_ids", return_value=set())
|
||||
def test_create_bundle_rejects_insufficient_permissions(self, perms_mock) -> None:
|
||||
def test_create_bundle_rejects_insufficient_permissions(self) -> None:
|
||||
requester = UserFactory(username="bundle_creator")
|
||||
grant_global(requester, "add_sharelinkbundle", "view_document")
|
||||
self.client.force_authenticate(requester)
|
||||
document = DocumentFactory(owner=UserFactory(username="document_owner"))
|
||||
payload = {
|
||||
"document_ids": [self.document.pk],
|
||||
"document_ids": [self.document.pk, document.pk],
|
||||
"file_version": ShareLink.FileVersion.ARCHIVE,
|
||||
"expiration_days": 7,
|
||||
}
|
||||
@@ -101,8 +104,8 @@ class ShareLinkBundleAPITests(DirectoriesMixin, APITestCase):
|
||||
response = self.client.post(self.ENDPOINT, payload, format="json")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn("document_ids", response.data)
|
||||
perms_mock.assert_called()
|
||||
self.assertIn(str(document.pk), str(response.data["document_ids"]))
|
||||
self.assertFalse(ShareLinkBundle.objects.exists())
|
||||
|
||||
@mock.patch("documents.views.build_share_link_bundle.apply_async")
|
||||
def test_rebuild_bundle_resets_state(self, delay_mock) -> None:
|
||||
|
||||
Reference in new issue
Block a user