mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-08-12 00:19:48 +00:00
Feature: Share links (#3996)
* Implement share links Basic implementation of share links Make certain share link fields not editable, automatically grant permissions on migrate Updated styling, error messages from expired / deleted links frontend code linting, reversable sharelink migration testing coverage Update translation strings No links message * Consolidate file response methods * improvements to share links on mobile devices * Refactor share links file_version * Add docs for share links * Apply suggestions from code review * When filtering share links, use the timezone aware now() * Removes extra call to setup directories for usage in testing * FIx copied badge display on some browsers * Move copy to ngx-clipboard library --------- Co-authored-by: Trenton H <797416+stumpylog@users.noreply.github.com>
This commit is contained in:
@@ -15,6 +15,7 @@ from unittest.mock import MagicMock
|
||||
|
||||
import celery
|
||||
import pytest
|
||||
from dateutil import parser
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Group
|
||||
@@ -37,6 +38,7 @@ from documents.models import MatchingModel
|
||||
from documents.models import Note
|
||||
from documents.models import PaperlessTask
|
||||
from documents.models import SavedView
|
||||
from documents.models import ShareLink
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
@@ -2558,6 +2560,119 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_create_share_links(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- Existing document
|
||||
WHEN:
|
||||
- API request is made to generate a share_link
|
||||
- API request is made to view share_links on incorrect doc pk
|
||||
- Invalid method request is made to view share_links doc
|
||||
THEN:
|
||||
- Link is created with a slug and associated with document
|
||||
- 404
|
||||
- Error
|
||||
"""
|
||||
doc = Document.objects.create(
|
||||
title="test",
|
||||
mime_type="application/pdf",
|
||||
content="this is a document which will have notes added",
|
||||
)
|
||||
# never expires
|
||||
resp = self.client.post(
|
||||
"/api/share_links/",
|
||||
data={
|
||||
"document": doc.pk,
|
||||
},
|
||||
)
|
||||
self.assertEqual(resp.status_code, status.HTTP_201_CREATED)
|
||||
|
||||
resp = self.client.post(
|
||||
"/api/share_links/",
|
||||
data={
|
||||
"expiration": (timezone.now() + timedelta(days=7)).isoformat(),
|
||||
"document": doc.pk,
|
||||
"file_version": "original",
|
||||
},
|
||||
)
|
||||
self.assertEqual(resp.status_code, status.HTTP_201_CREATED)
|
||||
|
||||
response = self.client.get(
|
||||
f"/api/documents/{doc.pk}/share_links/",
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
resp_data = response.json()
|
||||
|
||||
self.assertEqual(len(resp_data), 2)
|
||||
|
||||
self.assertGreater(len(resp_data[1]["slug"]), 0)
|
||||
self.assertIsNone(resp_data[1]["expiration"])
|
||||
self.assertEqual(
|
||||
(parser.isoparse(resp_data[0]["expiration"]) - timezone.now()).days,
|
||||
6,
|
||||
)
|
||||
|
||||
sl1 = ShareLink.objects.get(slug=resp_data[1]["slug"])
|
||||
self.assertEqual(str(sl1), f"Share Link for {doc.title}")
|
||||
|
||||
response = self.client.post(
|
||||
f"/api/documents/{doc.pk}/share_links/",
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
|
||||
|
||||
response = self.client.get(
|
||||
"/api/documents/99/share_links/",
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||
|
||||
def test_share_links_permissions_aware(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- Existing document owned by user2 but with granted view perms for user1
|
||||
WHEN:
|
||||
- API request is made by user1 to view share links
|
||||
THEN:
|
||||
- Links only shown if user has permissions
|
||||
"""
|
||||
user1 = User.objects.create_user(username="test1")
|
||||
user1.user_permissions.add(*Permission.objects.all())
|
||||
user1.save()
|
||||
|
||||
user2 = User.objects.create_user(username="test2")
|
||||
user2.save()
|
||||
|
||||
doc = Document.objects.create(
|
||||
title="test",
|
||||
mime_type="application/pdf",
|
||||
content="this is a document which will have share links added",
|
||||
)
|
||||
doc.owner = user2
|
||||
doc.save()
|
||||
|
||||
self.client.force_authenticate(user1)
|
||||
|
||||
resp = self.client.get(
|
||||
f"/api/documents/{doc.pk}/share_links/",
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(resp.content, b"Insufficient permissions")
|
||||
self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
assign_perm("change_document", user1, doc)
|
||||
|
||||
resp = self.client.get(
|
||||
f"/api/documents/{doc.pk}/share_links/",
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||
|
||||
|
||||
class TestDocumentApiV2(DirectoriesMixin, APITestCase):
|
||||
def setUp(self):
|
||||
|
@@ -153,7 +153,7 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
|
||||
manifest = self._do_export(use_filename_format=use_filename_format)
|
||||
|
||||
self.assertEqual(len(manifest), 149)
|
||||
self.assertEqual(len(manifest), 154)
|
||||
|
||||
# dont include consumer or AnonymousUser users
|
||||
self.assertEqual(
|
||||
@@ -247,7 +247,7 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
self.assertEqual(Document.objects.get(id=self.d4.id).title, "wow_dec")
|
||||
self.assertEqual(GroupObjectPermission.objects.count(), 1)
|
||||
self.assertEqual(UserObjectPermission.objects.count(), 1)
|
||||
self.assertEqual(Permission.objects.count(), 108)
|
||||
self.assertEqual(Permission.objects.count(), 112)
|
||||
messages = check_sanity()
|
||||
# everything is alright after the test
|
||||
self.assertEqual(len(messages), 0)
|
||||
@@ -676,15 +676,15 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
os.path.join(self.dirs.media_dir, "documents"),
|
||||
)
|
||||
|
||||
self.assertEqual(ContentType.objects.count(), 27)
|
||||
self.assertEqual(Permission.objects.count(), 108)
|
||||
self.assertEqual(ContentType.objects.count(), 28)
|
||||
self.assertEqual(Permission.objects.count(), 112)
|
||||
|
||||
manifest = self._do_export()
|
||||
|
||||
with paperless_environment():
|
||||
self.assertEqual(
|
||||
len(list(filter(lambda e: e["model"] == "auth.permission", manifest))),
|
||||
108,
|
||||
112,
|
||||
)
|
||||
# add 1 more to db to show objects are not re-created by import
|
||||
Permission.objects.create(
|
||||
@@ -692,7 +692,7 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
codename="test_perm",
|
||||
content_type_id=1,
|
||||
)
|
||||
self.assertEqual(Permission.objects.count(), 109)
|
||||
self.assertEqual(Permission.objects.count(), 113)
|
||||
|
||||
# will cause an import error
|
||||
self.user.delete()
|
||||
@@ -701,5 +701,5 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
with self.assertRaises(IntegrityError):
|
||||
call_command("document_importer", "--no-progress-bar", self.target)
|
||||
|
||||
self.assertEqual(ContentType.objects.count(), 27)
|
||||
self.assertEqual(Permission.objects.count(), 109)
|
||||
self.assertEqual(ContentType.objects.count(), 28)
|
||||
self.assertEqual(Permission.objects.count(), 113)
|
||||
|
@@ -1,31 +1,23 @@
|
||||
import shutil
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import TestCase
|
||||
from django.test import override_settings
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
|
||||
from documents.models import Document
|
||||
from documents.models import ShareLink
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
|
||||
class TestViews(TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
# Provide a dummy static dir to silence whitenoise warnings
|
||||
cls.static_dir = tempfile.mkdtemp()
|
||||
|
||||
cls.override = override_settings(
|
||||
STATIC_ROOT=cls.static_dir,
|
||||
)
|
||||
cls.override.enable()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
shutil.rmtree(cls.static_dir, ignore_errors=True)
|
||||
cls.override.disable()
|
||||
|
||||
class TestViews(DirectoriesMixin, TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.user = User.objects.create_user("testuser")
|
||||
super().setUp()
|
||||
|
||||
def test_login_redirect(self):
|
||||
response = self.client.get("/")
|
||||
@@ -74,3 +66,69 @@ class TestViews(TestCase):
|
||||
response.context_data["main_js"],
|
||||
f"frontend/{language_actual}/main.js",
|
||||
)
|
||||
|
||||
def test_share_link_views(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- Share link created
|
||||
WHEN:
|
||||
- Valid request for share link is made
|
||||
- Invalid request for share link is made
|
||||
- Request for expired share link is made
|
||||
THEN:
|
||||
- Document is returned without need for login
|
||||
- User is redirected to login with error
|
||||
- User is redirected to login with error
|
||||
"""
|
||||
|
||||
_, filename = tempfile.mkstemp(dir=self.dirs.originals_dir)
|
||||
|
||||
content = b"This is a test"
|
||||
|
||||
with open(filename, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
doc = Document.objects.create(
|
||||
title="none",
|
||||
filename=os.path.basename(filename),
|
||||
mime_type="application/pdf",
|
||||
)
|
||||
|
||||
sharelink_permissions = Permission.objects.filter(
|
||||
codename__contains="sharelink",
|
||||
)
|
||||
self.user.user_permissions.add(*sharelink_permissions)
|
||||
self.user.save()
|
||||
|
||||
self.client.force_login(self.user)
|
||||
|
||||
self.client.post(
|
||||
"/api/share_links/",
|
||||
{
|
||||
"document": doc.pk,
|
||||
"file_version": "original",
|
||||
},
|
||||
)
|
||||
sl1 = ShareLink.objects.get(document=doc)
|
||||
|
||||
self.client.logout()
|
||||
|
||||
# Valid
|
||||
response = self.client.get(f"/share/{sl1.slug}")
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.content, content)
|
||||
|
||||
# Invalid
|
||||
response = self.client.get("/share/123notaslug", follow=True)
|
||||
response.render()
|
||||
self.assertEqual(response.request["PATH_INFO"], "/accounts/login/")
|
||||
self.assertContains(response, b"Share link was not found")
|
||||
|
||||
# Expired
|
||||
sl1.expiration = timezone.now() - timedelta(days=1)
|
||||
sl1.save()
|
||||
|
||||
response = self.client.get(f"/share/{sl1.slug}", follow=True)
|
||||
response.render()
|
||||
self.assertEqual(response.request["PATH_INFO"], "/accounts/login/")
|
||||
self.assertContains(response, b"Share link has expired")
|
||||
|
Reference in New Issue
Block a user