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

+57
View File
@@ -0,0 +1,57 @@
from django.urls import reverse
from playwright.sync_api import expect
from tests.e2e.pages.base import BasePage
class StaffSharingPage(BasePage):
def share_message(self, ticket, message, recipient, note):
self.goto(reverse("staff_tickets:detail", args=[ticket.public_id]))
controls = self.page.locator(f'[data-share-message="{message.public_id}"]')
expect(controls.locator("form")).to_be_hidden()
controls.locator("summary").first.focus()
controls.locator("summary").first.press("Enter")
expect(controls.locator("form")).to_be_visible()
controls.get_by_label("کارشناس گیرنده").select_option(str(recipient.staff_profile.pk))
controls.locator(".staff-share-note-field > summary").click()
controls.get_by_label("توضیح برای گیرنده").fill(note)
controls.get_by_role("button", name="اشتراک پیام و پیوست‌هایش").click()
expect(self.page.get_by_text("فقط همین پیام و پیوست‌های آن در صندوق اشتراک‌های کارشناس قرار گرفت.")).to_be_visible()
def open_received_message(self, ticket):
self.goto(reverse("staff_tickets:shares"))
expect(self.page.locator('[data-share-unread-count]').first).to_contain_text("1")
self.page.locator("a.staff-ticket").first.click()
expect(self.page.get_by_role("heading", name=ticket.subject, exact=True)).to_be_visible()
expect(self.page.locator('.received-share-attribution')).to_contain_text("کارشناس sender")
expect(self.page.locator('[data-share-unread-count]').first).to_be_hidden()
expect(self.page.locator('[data-ticket-composer]')).to_have_count(0)
expect(self.page.get_by_role("button", name="عملیات تیکت", exact=True)).to_be_visible()
if self.page.viewport_size["width"] < 768:
self.page.get_by_role("button", name="اطلاعات مشتری", exact=True).click()
expect(self.page.locator('#customer-info')).to_contain_text(ticket.customer.mobile)
expect(self.page.locator('#customer-info')).to_be_visible()
if self.page.viewport_size["width"] < 768:
self.page.locator('#customer-info [data-close-panel]').click()
def expect_isolated_content(self, selected_text, forbidden_texts):
expect(self.page.get_by_text(selected_text, exact=True)).to_be_visible()
for text in forbidden_texts:
expect(self.page.get_by_text(text, exact=True)).to_have_count(0)
def expect_opened_and_revoke(self, ticket, recipient_name):
self.goto(reverse("staff_tickets:detail", args=[ticket.public_id]))
controls = self.page.locator("[data-share-message]").filter(has=self.page.locator(".share-audit-row").filter(has_text=recipient_name))
controls.locator("summary").first.click()
row = controls.locator(".share-audit-row").filter(has_text=recipient_name)
expect(row).to_contain_text("دیده‌شده")
expect(row.get_by_role("button", name="لغو اشتراک پیام")).to_be_hidden()
row.locator("summary").click()
expect(row).to_contain_text("اولین مشاهده")
controls.locator(".staff-inline-share-history > summary").click()
expect(controls.locator(".share-event-row").filter(has_text="مشاهده پیام اشتراکی")).to_be_visible()
row.get_by_role("button", name="لغو اشتراک پیام").click()
def expect_revoked_link_denied(self, url):
self.goto(url)
expect(self.page.get_by_text("403 Forbidden", exact=False)).to_be_visible()
+33
View File
@@ -0,0 +1,33 @@
import pytest
from tests.e2e.pages.auth import AuthenticationPage
from tests.e2e.pages.sharing import StaffSharingPage
from tests.test_staff_sharing import make_sharing_case
pytestmark = [pytest.mark.e2e, pytest.mark.django_db(transaction=True)]
@pytest.mark.parametrize("viewport", [{"width": 1280, "height": 900}, {"width": 390, "height": 844}])
def test_staff_can_share_one_message_track_opening_and_revoke(live_server, browser_page, viewport):
case = make_sharing_case()
password = "SharingTest123!"
with browser_page() as page:
page.set_viewport_size(viewport)
AuthenticationPage(page, live_server.url).login_staff(case["sender"], password)
StaffSharingPage(page, live_server.url).share_message(case["ticket"], case["selected"], case["recipient"], "بررسی رسید توسط مالی")
with browser_page() as page:
page.set_viewport_size(viewport)
AuthenticationPage(page, live_server.url).login_staff(case["recipient"], password)
sharing = StaffSharingPage(page, live_server.url)
sharing.open_received_message(case["ticket"])
sharing.expect_isolated_content(case["selected"].body, ["مکاتبه محرمانه قبل", "مکاتبه محرمانه بعد"])
shared_url = page.url
with browser_page() as page:
page.set_viewport_size(viewport)
AuthenticationPage(page, live_server.url).login_staff(case["sender"], password)
StaffSharingPage(page, live_server.url).expect_opened_and_revoke(case["ticket"], "کارشناس recipient")
with browser_page() as page:
page.set_viewport_size(viewport)
AuthenticationPage(page, live_server.url).login_staff(case["recipient"], password)
StaffSharingPage(page, live_server.url).expect_revoked_link_denied(shared_url)
+35
View File
@@ -0,0 +1,35 @@
import pytest
from django.urls import reverse
from apps.tickets.sharing import revoke_staff_share, share_staff_message, staff_message_context
from tests.test_staff_sharing import make_sharing_case
pytestmark = pytest.mark.django_db
def test_inline_sharing_keeps_each_messages_recipients_and_history_separate(client):
case = make_sharing_case()
other_message = case["ticket"].ticket_messages.get(body="مکاتبه محرمانه قبل")
selected_grant = share_staff_message(case["selected"], case["sender"], case["recipient"], "رسید منتخب")
other_grant = share_staff_message(other_message, case["sender"], case["outsider"], "مکاتبه دیگر")
client.force_login(case["recipient"])
assert client.get(reverse("staff_tickets:shared_message", args=[selected_grant.public_id]), follow=True).status_code == 200
assert client.post(reverse("staff_tickets:read_shares", args=[case["ticket"].public_id]), {"message_ids": [str(case["selected"].public_id)]}).status_code == 200
client.force_login(case["sender"])
response = client.get(reverse("staff_tickets:detail", args=[case["ticket"].public_id]))
selected = next(item for item in response.context["conversation"] if item.pk == case["selected"].pk)
before = next(item for item in response.context["conversation"] if item.pk == other_message.pk)
assert [grant.pk for grant in selected.staff_message_shares] == [selected_grant.pk]
assert selected.staff_message_shares[0].first_viewed_at is not None
assert [grant.pk for grant in before.staff_message_shares] == [other_grant.pk]
assert {event.metadata["action"] for event in selected.staff_share_events} == {"created", "opened"}
assert all(event.metadata["message_public_id"] == str(selected.public_id) for event in selected.staff_share_events)
assert all(event.metadata["user"] == case["recipient"].pk for event in selected.staff_share_events)
assert revoke_staff_share(selected_grant, case["sender"])
staff_message_context(case["ticket"], case["sender"], [case["selected"]])
assert case["selected"].staff_message_shares[0].revoked_at is not None
assert {event.metadata["action"] for event in case["selected"].staff_share_events} == {"created", "opened", "revoked"}
context = staff_message_context(case["ticket"], case["customer"], [other_message])
assert not context["can_share_message"]
assert not hasattr(other_message, "staff_message_shares")
+160
View File
@@ -0,0 +1,160 @@
import pytest
from django.urls import reverse
from django.utils import timezone
from apps.messaging.models import Message
from apps.staff.models import StaffProfile
from apps.tickets.models import MessageShare, TicketAccessGrant
from apps.tickets.policies import TicketPolicy
from apps.tickets.services import change_priority, change_status
from apps.tickets.sharing import share_staff_message, share_staff_ticket, revoke_staff_share
from tests.test_staff_sharing import make_sharing_case
pytestmark = pytest.mark.django_db
def test_shared_queue_groups_messages_and_searches_normal_ticket_metadata(client):
c = make_sharing_case()
second = Message.objects.create(ticket=c['ticket'], author=c['customer'], body='رسید دوم')
grants = [share_staff_message(m, c['sender'], c['recipient']) for m in (c['selected'], second)]
client.force_login(c['recipient'])
listing = reverse('staff_tickets:list')
response = client.get(listing, {'tab': 'shared'})
assert len(response.context['tickets']) == 1
assert c['ticket'].subject in response.content.decode()
assert len(client.get(listing, {'tab': 'shared', 'q': c['ticket'].subject}).context['tickets']) == 1
assert len(client.get(listing, {'tab': 'shared', 'q': second.body}).context['tickets']) == 1
assert len(client.get(listing, {'tab': 'shared', 'status': c['ticket'].status, 'category': c['ticket'].category_id}).context['tickets']) == 1
assert len(client.get(listing, {'tab': 'shared', 'q': c['customer'].mobile}).context['tickets']) == 1
assert client.get(listing, {'tab': 'shared', 'q': 'مکاتبه محرمانه قبل'}).context['tickets'] == []
assert client.get(reverse('staff_tickets:shares_count')).json()['unread_shares'] == 2
read = reverse('staff_tickets:read_shares', args=[c['ticket'].public_id])
assert client.post(read, {'message_ids': ['bad-uuid', str(c['ticket'].ticket_messages.first().public_id)]}).json()['unread_shares'] == 2
assert client.post(read, {'message_ids': [str(second.public_id)]}).json()['unread_shares'] == 1
grants[0].refresh_from_db()
assert grants[0].first_viewed_at is None
revoke_staff_share(grants[0], c['sender'])
assert client.get(reverse('staff_tickets:shares_count')).json()['unread_shares'] == 0
assert client.get(listing, {'tab': 'shared', 'unread': 1}).context['tickets'] == []
def test_limited_poll_tracks_revocation_without_leaking_hidden_activity(client):
c = make_sharing_case()
grant = share_staff_message(c['selected'], c['sender'], c['recipient'])
client.force_login(c['recipient'])
url = reverse('staff_tickets:poll', args=[c['ticket'].public_id])
baseline = client.get(url).json()
assert baseline['count'] == 1 and c['selected'].body in baseline['html']
assert 'مکاتبه محرمانه' not in baseline['html']
Message.objects.create(ticket=c['ticket'], author=c['sender'], body='hidden future')
same = client.get(url, {'version': baseline['version'], 'cursor': baseline['cursor']}).json()
assert same['changed'] is False
second = Message.objects.create(ticket=c['ticket'], author=c['customer'], body='second shared')
share_staff_message(second, c['sender'], c['recipient'])
revoke_staff_share(grant, c['sender'])
changed = client.get(url, {'version': baseline['version'], 'cursor': baseline['cursor']}).json()
assert changed['replace'] and changed['count'] == 1
assert second.body in changed['html'] and c['selected'].body not in changed['html']
older = client.get(reverse('staff_tickets:messages', args=[c['ticket'].public_id]), {'before': c['selected'].public_id})
assert 'hidden future' not in older.content.decode()
Message.objects.filter(pk=second.pk).update(deleted_at=timezone.now())
assert client.get(url).status_code == 403
assert client.get(reverse('staff_tickets:detail', args=[c['ticket'].public_id])).status_code == 403
@pytest.mark.parametrize('role', StaffProfile.Role.values)
def test_every_active_staff_role_can_operate_and_share_accessible_tickets(client, role):
c = make_sharing_case()
sender = c['sender']
sender.user_permissions.clear()
StaffProfile.objects.filter(user=sender).update(role=role)
sender.refresh_from_db()
assert TicketPolicy.can_share(sender, c['ticket'])
assert TicketPolicy.can_transfer(sender, c['ticket'])
change_status(c['ticket'], sender, 'OPEN')
change_priority(c['ticket'], sender, 'URGENT')
share_staff_message(c['selected'], sender, c['recipient'])
share_staff_ticket(c['ticket'], sender, c['outsider'], 'VIEW_ONLY')
client.force_login(sender)
body = client.get(reverse('staff_tickets:detail', args=[c['ticket'].public_id])).content.decode()
assert 'ثبت وضعیت' in body and 'ثبت اولویت' in body and 'انتقال تیکت' in body
assert 'اشتراک با کارشناس' in body
assert not TicketPolicy.can_operate(c['recipient'], c['ticket'])
assert TicketPolicy.can_operate(c['outsider'], c['ticket'])
assert not TicketPolicy.can_operate(c['customer'], c['ticket'])
StaffProfile.objects.filter(user=sender).update(is_active=False)
sender.refresh_from_db()
assert not TicketPolicy.can_share(sender, c['ticket'])
def test_old_shared_messages_stay_unread_until_displayed_and_poll_keeps_pagination(client):
c = make_sharing_case()
share_staff_message(c['selected'], c['sender'], c['recipient'])
for index in range(51):
message = Message.objects.create(ticket=c['ticket'], author=c['customer'], body=f'رسید مجاز {index}')
share_staff_message(message, c['sender'], c['recipient'])
client.force_login(c['recipient'])
response = client.get(reverse('staff_tickets:detail', args=[c['ticket'].public_id]))
assert len(response.context['conversation']) == 50 and response.context['has_older']
assert c['selected'].body not in response.content.decode()
read = reverse('staff_tickets:read_shares', args=[c['ticket'].public_id])
last = response.context['conversation'][-1]
assert client.post(read, {'message_ids': [str(last.public_id)]}).json()['unread_shares'] == 51
old = client.get(reverse('staff_tickets:messages', args=[c['ticket'].public_id]), {'before': response.context['older_cursor']})
assert c['selected'].body in old.content.decode() and 'مکاتبه محرمانه' not in old.content.decode()
poll = client.get(reverse('staff_tickets:poll', args=[c['ticket'].public_id])).json()
assert poll['staff_limited'] and 'data-load-older' in poll['html']
def test_full_and_message_shares_have_one_row_and_full_access_preserves_conversation(client):
c = make_sharing_case()
share_staff_message(c['selected'], c['sender'], c['recipient'])
share_staff_ticket(c['ticket'], c['sender'], c['recipient'], 'VIEW_ONLY')
client.force_login(c['recipient'])
queue = client.get(reverse('staff_tickets:list'), {'tab': 'shared'})
assert len(queue.context['tickets']) == 1
assert c['ticket'].subject in queue.content.decode()
body = client.get(reverse('staff_tickets:detail', args=[c['ticket'].public_id])).content.decode()
assert 'مکاتبه محرمانه قبل' in body and 'مکاتبه محرمانه بعد' in body
assert client.get(reverse('staff_tickets:shares_count')).json()['unread_shares'] == 1
response = client.post(reverse('staff_tickets:transfer', args=[c['ticket'].public_id]), {'expert': c['outsider'].staff_profile.pk})
assert response.status_code == 302
c['ticket'].refresh_from_db()
assert c['ticket'].owner == c['outsider']
def test_global_staff_access_does_not_duplicate_or_restrict_a_message_shared_ticket(client):
from django.contrib.auth.models import Permission
c = make_sharing_case()
share_staff_message(c['selected'], c['sender'], c['recipient'])
c['recipient'].user_permissions.add(Permission.objects.get(content_type__app_label='tickets', codename='view_all_tickets'))
client.force_login(c['recipient'])
queue = client.get(reverse('staff_tickets:list'), {'tab': 'shared'})
assert len(queue.context['tickets']) == 1
assert c['ticket'].subject in queue.content.decode()
assert not getattr(queue.context['tickets'][0], 'limited_share', False)
def test_message_recipient_has_normal_ticket_details_with_only_shared_message_content(client):
from apps.customers.models import CustomerProfile
from apps.tickets.models import Ticket
c = make_sharing_case()
CustomerProfile.objects.create(user=c['customer'], first_name='مشتری', last_name='اشتراک')
Ticket.objects.filter(pk=c['ticket'].pk).update(province='اصفهان', city='گلپایگان', priority='URGENT')
c['ticket'].refresh_from_db()
share_staff_message(c['selected'], c['sender'], c['recipient'])
client.force_login(c['recipient'])
listing = client.get(reverse('staff_tickets:list'), {'tab': 'shared'})
html = listing.content.decode()
assert c['ticket'].subject in html and 'مشتری اشتراک' in html
assert 'گلپایگان، اصفهان' in html and 'فوری' in html
detail = client.get(reverse('staff_tickets:detail', args=[c['ticket'].public_id]))
html = detail.content.decode()
for value in (c['ticket'].subject, c['customer'].mobile, 'مشتری اشتراک', 'گلپایگان، اصفهان', 'فوری', 'سابقه اخیر', 'id="customer-info"', 'id="ticket-actions"', 'data-ticket-status'):
assert value in html
assert 'limited-shared-conversation' not in html
assert c['selected'].body in html
assert 'مکاتبه محرمانه قبل' not in html and 'مکاتبه محرمانه بعد' not in html
assert not TicketAccessGrant.objects.filter(ticket=c['ticket'], user=c['recipient']).exists()
assert not TicketPolicy.can_view(c['recipient'], c['ticket'])
poll = client.get(reverse('staff_tickets:poll', args=[c['ticket'].public_id])).json()
assert poll['status'] == c['ticket'].get_status_display() and poll['count'] == 1
+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