share ticket and ticket message

This commit is contained in:
javad committed 2026-10-04 16:42:19 +03:30
1 parent 39e64854b8
commit 79fe1cddbb
35 files changed
+1742 -95

No files matched your search

+239
View File
@@ -0,0 +1,239 @@
import pytest
import re
from django.contrib.auth.models import Permission
from django.core.exceptions import ValidationError
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client
from django.urls import reverse
from django.utils import timezone
from apps.accounts.models import User
from apps.attachments.models import Attachment
from apps.messaging.models import Message
from apps.notifications.models import NotificationJob
from apps.staff.models import OrganizationalUnit, StaffProfile
from apps.tickets.models import MessageShare, TicketAccessGrant, TicketCategory, TicketEvent
from apps.tickets.policies import TicketPolicy
from apps.tickets.selectors import staff_ticket_rows
from apps.tickets.services import create_ticket
from apps.tickets.sharing import revoke_staff_share, share_staff_message, share_staff_ticket
pytestmark = pytest.mark.django_db
def make_sharing_case():
sales = OrganizationalUnit.objects.create(name="فروش اشتراک", slug="sharing-sales")
finance = OrganizationalUnit.objects.create(name="مالی اشتراک", slug="sharing-finance")
users = {}
for index, name in enumerate(("sender", "recipient", "outsider"), 1):
user = User.objects.create_user(username=f"sharing-{name}", mobile=f"0912777000{index}", password="SharingTest123!", account_type="STAFF")
StaffProfile.objects.create(user=user, display_name=f"کارشناس {name}", primary_unit=sales if name == "sender" else finance)
users[name] = user
users["sender"].user_permissions.add(Permission.objects.get(content_type__app_label="tickets", codename="share_ticket"))
customer = User.objects.create_user(username="sharing-customer", mobile="09127770004", account_type="CUSTOMER")
category = TicketCategory.objects.create(unit=sales, title="اشتراک", slug="sharing-category")
ticket = create_ticket(customer=customer, category=category, selected_expert=users["sender"], subject="موضوع محرمانه کل تیکت", body="مکاتبه محرمانه قبل")
selected = Message.objects.create(ticket=ticket, author=customer, body="رسید منتخب برای بررسی مالی")
Message.objects.create(ticket=ticket, author=users["sender"], body="مکاتبه محرمانه بعد", is_internal=True)
return {**users, "customer": customer, "ticket": ticket, "selected": selected}
@pytest.fixture
def sharing_case():
return make_sharing_case()
def attachment_for(message, content=b"private receipt", filename="receipt.pdf"):
return Attachment.objects.create(message=message, file=SimpleUploadedFile(filename, content), original_name=filename,
stored_name=filename, relative_path=filename, attachment_type="PDF", mime_type="application/pdf", extension=".pdf", file_size=len(content), uploaded_by=message.author)
def test_ticket_sharing_records_first_open_updates_and_revocation(client, sharing_case):
c = sharing_case
url = reverse("staff_tickets:share", args=[c["ticket"].public_id])
client.force_login(c["sender"])
response = client.post(url, {"expert": c["recipient"].staff_profile.pk, "permission_level": "VIEW_ONLY", "note": "بررسی کل پرونده"})
assert response.status_code == 302
grant = TicketAccessGrant.objects.get(ticket=c["ticket"], user=c["recipient"])
assert grant.first_viewed_at is None and grant.view_count == 0
client.force_login(c["recipient"])
assert client.get(reverse("staff_tickets:shares")).status_code == 302
grant.refresh_from_db()
assert grant.first_viewed_at is None # Listing is not evidence of opening.
detail = reverse("staff_tickets:detail", args=[c["ticket"].public_id])
assert client.get(detail).status_code == 200
assert client.get(detail).status_code == 200
grant.refresh_from_db()
first_view = grant.first_viewed_at
assert first_view and grant.last_viewed_at >= first_view and grant.view_count == 2
assert c["ticket"].events.filter(event_type=TicketEvent.Type.SHARE_OPENED).count() == 1
client.force_login(c["sender"])
audit = client.get(detail).content.decode()
assert "دیده‌شده" in audit and "کارشناس recipient" in audit and "بررسی کل پرونده" in audit
client.post(url, {"expert": c["recipient"].staff_profile.pk, "permission_level": "REPLY", "note": "توضیح جدید"})
grant.refresh_from_db()
assert grant.permission_level == "REPLY" and grant.first_viewed_at == first_view
assert c["ticket"].events.filter(event_type=TicketEvent.Type.SHARED).count() == 2
client.post(reverse("staff_tickets:revoke", args=[c["ticket"].public_id, c["recipient"].public_id]))
grant.refresh_from_db()
assert grant.revoked_at and grant.revoked_by == c["sender"]
assert not TicketPolicy.can_view(c["recipient"], c["ticket"])
new = share_staff_ticket(c["ticket"], c["sender"], c["recipient"], "VIEW_ONLY")
assert new.pk != grant.pk and new.first_viewed_at is None
def test_one_message_share_never_grants_ticket_or_neighbor_access(client, sharing_case):
c = sharing_case
client.force_login(c["customer"])
customer_url = reverse("tickets:detail", args=[c["ticket"].public_id])
def stable_html(response):
# Django masks CSRF tokens with fresh randomness on every render.
return re.sub(r'(?<=["\x27])[A-Za-z0-9]{64}(?=["\x27])', 'CSRF_TOKEN', response.content.decode())
before = stable_html(client.get(customer_url))
jobs_before = NotificationJob.objects.count()
grant = share_staff_message(c["selected"], c["sender"], c["recipient"], "بررسی رسید")
assert not TicketAccessGrant.objects.filter(ticket=c["ticket"], user=c["recipient"]).exists()
assert not TicketPolicy.can_view(c["recipient"], c["ticket"])
assert not staff_ticket_rows(c["recipient"]).filter(pk=c["ticket"].pk).exists()
assert NotificationJob.objects.count() == jobs_before
assert stable_html(client.get(customer_url)) == before
client.force_login(c["recipient"])
assert client.get(reverse("staff_tickets:shares_count")).json()["unread_shares"] == 1
inbox = client.get(reverse("staff_tickets:list"), {"tab": "shared"}).content.decode()
assert c["ticket"].subject in inbox
assert grant.first_viewed_at is None
assert client.get(reverse("staff_tickets:shared_message", args=[grant.public_id])).status_code == 302
detail = reverse("staff_tickets:detail", args=[c["ticket"].public_id])
page = client.get(detail)
body = page.content.decode()
assert c["selected"].body in body and "بررسی رسید" in body
assert "مکاتبه محرمانه قبل" not in body and "مکاتبه محرمانه بعد" not in body
assert c["ticket"].subject in body and c["customer"].mobile in body
assert 'id="ticket-actions"' in body and 'id="customer-info"' in body
assert reverse("messages:revisions", args=[c["selected"].public_id]) not in body
read_url = reverse("staff_tickets:read_shares", args=[c["ticket"].public_id])
grant.refresh_from_db()
assert grant.first_viewed_at is None
assert client.post(read_url, {"message_ids": [str(c["selected"].public_id)]}).json()["unread_shares"] == 0
Message.objects.create(ticket=c["ticket"], author=c["sender"], body="مکاتبه آینده محرمانه")
assert "مکاتبه آینده محرمانه" not in client.get(detail).content.decode()
grant.refresh_from_db()
assert grant.first_viewed_at and grant.view_count == 1
assert client.get(reverse("staff_tickets:shares_count")).json()["unread_shares"] == 0
assert c["ticket"].events.filter(event_type=TicketEvent.Type.MESSAGE_SHARE_OPENED).count() == 1
for route in ("detail", "messages", "poll"):
assert client.get(reverse(f"staff_tickets:{route}", args=[c["ticket"].public_id])).status_code == 200
assert client.post(reverse("staff_tickets:reply", args=[c["ticket"].public_id]), {"body": "unauthorized reply"}).status_code in (302, 403)
assert not c["ticket"].ticket_messages.filter(body="unauthorized reply").exists()
@pytest.mark.parametrize("download", [False, True])
def test_shared_attachment_is_scoped_to_message_and_revoked_immediately(client, sharing_case, settings, tmp_path, download):
settings.MEDIA_ROOT = tmp_path
settings.USE_NGINX_MEDIA = False
c = sharing_case
chosen = attachment_for(c["selected"])
other = attachment_for(c["ticket"].ticket_messages.first(), b"other secret", "other.pdf")
grant = share_staff_message(c["selected"], c["sender"], c["recipient"])
route = "shared_message_attachment_download" if download else "shared_message_attachment"
url = reverse(f"staff_tickets:{route}", args=[grant.public_id, chosen.public_id])
client.force_login(c["recipient"])
response = client.get(url)
assert response.status_code == 200 and b"".join(response.streaming_content) == b"private receipt"
assert "no-store" in response["Cache-Control"]
assert client.get(reverse(f"staff_tickets:{route}", args=[grant.public_id, other.public_id])).status_code == 404
assert client.get(reverse("attachments:view", args=[chosen.public_id])).status_code == 403
client.force_login(c["outsider"])
assert client.get(url).status_code == 403
revoke_staff_share(grant, c["sender"])
client.force_login(c["recipient"])
assert client.get(url).status_code == 403
assert client.get(reverse("staff_tickets:shared_message", args=[grant.public_id])).status_code == 403
def test_deleted_message_and_inactive_recipient_cannot_open_share(client, sharing_case):
c = sharing_case
grant = share_staff_message(c["selected"], c["sender"], c["recipient"])
url = reverse("staff_tickets:shared_message", args=[grant.public_id])
client.force_login(c["recipient"])
StaffProfile.objects.filter(user=c["recipient"]).update(is_active=False)
assert client.get(url).status_code == 403
StaffProfile.objects.filter(user=c["recipient"]).update(is_active=True)
Message.objects.filter(pk=c["selected"].pk).update(deleted_at=timezone.now())
assert client.get(url).status_code == 403
assert "بررسی رسید" not in client.get(reverse("staff_tickets:shares")).content.decode()
with pytest.raises(ValidationError):
share_staff_message(c["selected"], c["sender"], c["recipient"])
def test_share_operations_validate_recipients_authority_method_and_csrf(client, sharing_case):
c = sharing_case
url = reverse("staff_tickets:share_message", args=[c["ticket"].public_id, c["selected"].public_id])
client.force_login(c["sender"])
assert client.get(url).status_code == 405
for target in (c["sender"], c["customer"]):
value = getattr(target, "staff_profile", None)
client.post(url, {"expert": value.pk if value else 999999, "note": "bad"})
assert MessageShare.objects.count() == 0
StaffProfile.objects.filter(user=c["recipient"]).update(is_active=False)
client.post(url, {"expert": c["recipient"].staff_profile.pk})
assert MessageShare.objects.count() == 0
StaffProfile.objects.filter(user=c["recipient"]).update(is_active=True)
csrf_client = Client(enforce_csrf_checks=True)
csrf_client.force_login(c["sender"])
assert csrf_client.post(url, {"expert": c["recipient"].staff_profile.pk}).status_code == 403
client.force_login(c["outsider"])
assert client.post(url, {"expert": c["recipient"].staff_profile.pk}).status_code == 403
client.force_login(c["customer"])
assert client.post(url, {"expert": c["recipient"].staff_profile.pk}).status_code == 403
assert MessageShare.objects.count() == 0
def test_duplicate_share_is_idempotent_and_regrant_preserves_history(client, sharing_case):
c = sharing_case
first = share_staff_message(c["selected"], c["sender"], c["recipient"], "رسید")
duplicate = share_staff_message(c["selected"], c["sender"], c["recipient"], "رسید")
assert duplicate.pk == first.pk
assert c["ticket"].events.filter(event_type=TicketEvent.Type.MESSAGE_SHARED).count() == 1
assert revoke_staff_share(first, c["sender"])
assert not revoke_staff_share(first, c["sender"])
new = share_staff_message(c["selected"], c["sender"], c["recipient"], "ارسال مجدد")
assert new.pk != first.pk and new.first_viewed_at is None
first.refresh_from_db()
assert first.revoked_at and first.note == "رسید"
client.force_login(c["sender"])
body = client.get(reverse("staff_tickets:detail", args=[c["ticket"].public_id])).content.decode()
assert "لغوشده" in body and "ارسال مجدد" in body and "هنوز دیده نشده" in body
def test_staff_message_sharing_controls_survive_polling_and_pagination(client, sharing_case):
c = sharing_case
for index in range(53):
Message.objects.create(ticket=c["ticket"], author=c["customer"], body=f"پیام شماره {index}")
client.force_login(c["sender"])
detail = client.get(reverse("staff_tickets:detail", args=[c["ticket"].public_id]))
assert "اشتراک فقط این پیام" in detail.content.decode()
before = detail.context["older_cursor"]
older = client.get(reverse("staff_tickets:messages", args=[c["ticket"].public_id]), {"before": before})
assert "اشتراک فقط این پیام" in older.content.decode()
poll = client.get(reverse("staff_tickets:poll", args=[c["ticket"].public_id])).json()
assert "اشتراک فقط این پیام" in poll["html"]
client.force_login(c["customer"])
customer_poll = client.get(reverse("tickets:poll", args=[c["ticket"].public_id])).json()
assert "اشتراک فقط این پیام" not in customer_poll["html"]
assert "staff-message-share" not in customer_poll["html"]
def test_message_share_cannot_be_used_under_another_ticket_or_by_another_user(client, sharing_case):
c = sharing_case
other = create_ticket(customer=c["customer"], category=c["ticket"].category, selected_expert=c["sender"], subject="تیکت دیگر", body="دیگر")
client.force_login(c["sender"])
assert client.post(reverse("staff_tickets:share_message", args=[other.public_id, c["selected"].public_id]), {"expert": c["recipient"].staff_profile.pk}).status_code == 404
grant = share_staff_message(c["selected"], c["sender"], c["recipient"])
assert client.post(reverse("staff_tickets:revoke_message_share", args=[other.public_id, grant.public_id])).status_code == 404
for user in (c["sender"], c["outsider"], c["customer"]):
client.force_login(user)
assert client.get(reverse("staff_tickets:shared_message", args=[grant.public_id])).status_code == 403
client.logout()
assert client.get(reverse("staff_tickets:shared_message", args=[grant.public_id])).status_code == 302