import re from datetime import timedelta from urllib.parse import parse_qs, urlsplit from unittest.mock import Mock import pytest from django.contrib import admin from django.contrib.auth.models import Permission from django.urls import reverse from django.utils import timezone from apps.accounts.models import TicketMagicLink, User from apps.integrations.sms.base import SMSResult from apps.notifications.management.commands.process_notifications import _send_sms from apps.notifications.models import NotificationJob, SMSTemplate from apps.staff.models import OrganizationalUnit, StaffProfile from apps.tickets.models import StaffTicketShortLink, TicketCategory from apps.tickets.services import create_ticket, delete_ticket from apps.tickets.short_links import append_supervisor_ticket_link @pytest.fixture def short_link_case(db): unit = OrganizationalUnit.objects.create( name="واحد لینک کوتاه", slug="short-link-unit", supervisor_mobile_1="09121112233", supervisor_mobile_2="09121112234", supervisor_mobile_3="09121112235", supervisor_mobile_4="09121112236", supervisor_mobile_5="09121112237", supervisor_sms_ticket_link_enabled=True, ) unit.permissions.add( Permission.objects.get(content_type__app_label="tickets", codename="view_ticket"), Permission.objects.get(content_type__app_label="messaging", codename="add_message"), ) category = TicketCategory.objects.create(unit=unit, title="درخواست لینک", slug="short-link-category") customer = User.objects.create_user(username="short-customer", mobile="09121112238", account_type="CUSTOMER") staff = User.objects.create_user( username="short-staff", password="Staff-password-123", mobile="09121112239", account_type="STAFF", ) StaffProfile.objects.create(user=staff, primary_unit=unit, display_name="کارشناس لینک") ticket = create_ticket(customer=customer, category=category, subject="موضوع لینک کوتاه", body="متن درخواست") return unit, customer, staff, ticket def issue_link(ticket): append_supervisor_ticket_link(ticket, "اعلان") link = StaffTicketShortLink.objects.get(ticket=ticket) return link, reverse("staff_ticket_short_link", args=[link.token]) @pytest.mark.django_db def test_all_five_supervisors_receive_same_short_link_without_magic_access(short_link_case, monkeypatch, settings): unit, _, _, ticket = short_link_case settings.PUBLIC_BASE_URL = "https://tickets.example.com/" provider = Mock() provider.send_message.return_value = SMSResult(True, "short-link-test") monkeypatch.setattr("apps.notifications.management.commands.process_notifications.get_sms_provider", lambda: provider) SMSTemplate.objects.create( unit=unit, event_type=SMSTemplate.Event.UNIT_SUPERVISOR_NEW_TICKET, title="الگوی لینک کوتاه", body="تیکت {ticket_number}", ) jobs = list(NotificationJob.objects.filter(ticket=ticket, event_type="UNIT_SUPERVISOR_NEW_TICKET").order_by("pk")) before = timezone.now() for job in jobs: result, magic_link = _send_sms(job) assert result.success and magic_link is None sent = [call.args for call in provider.send_message.call_args_list] link, path = issue_link(ticket) assert before + timedelta(days=7) <= link.expires_at <= timezone.now() + timedelta(days=7) assert re.fullmatch(r"/vt/[a-zA-Z0-9]{10}", path) assert [mobile for mobile, _ in sent] == list(unit.supervisor_mobiles) assert {message for _, message in sent} == { f"تیکت {ticket.ticket_number}\nمشاهده و پاسخ: https://tickets.example.com{path}" } _send_sms(jobs[0]) link.refresh_from_db() assert StaffTicketShortLink.objects.filter(ticket=ticket).count() == 1 assert not TicketMagicLink.objects.filter(ticket=ticket).exists() assert not ticket.access_grants.exists() @pytest.mark.django_db def test_unit_toggle_preserves_plain_sms_and_does_not_issue_link(short_link_case): unit, _, _, ticket = short_link_case unit.supervisor_sms_ticket_link_enabled = False unit.save() ticket.refresh_from_db() assert append_supervisor_ticket_link(ticket, "اعلان") == "اعلان" assert not StaffTicketShortLink.objects.filter(ticket=ticket).exists() assert OrganizationalUnit._meta.get_field("supervisor_sms_ticket_link_enabled").default is False @pytest.mark.django_db def test_anonymous_login_returns_to_real_ticket_and_can_reply(short_link_case, client): _, _, staff, ticket = short_link_case _, path = issue_link(ticket) response = client.get(path) assert response.status_code == 302 assert urlsplit(response.url).path == "/staff/login/" assert parse_qs(urlsplit(response.url).query)["next"] == [path] assert "no-store" in response["Cache-Control"] client.get(response.url) # Destination also survives a failed attempt and a POST without query params. response = client.post("/staff/login/", {"username": staff.username, "password": "incorrect"}) assert response.status_code == 200 response = client.post("/staff/login/", {"username": staff.username, "password": "Staff-password-123"}) assert response.url == path response = client.get(path) detail = reverse("staff_tickets:detail", args=[ticket.public_id]) assert response.url == detail assert client.get(detail).status_code == 200 response = client.post(reverse("staff_tickets:reply", args=[ticket.public_id]), {"body": "پاسخ از لینک کوتاه"}) assert response.status_code == 302 assert ticket.ticket_messages.filter(author=staff, body="پاسخ از لینک کوتاه").exists() assert "ticket_magic_link_id" not in client.session assert not ticket.access_grants.exists() @pytest.mark.django_db @pytest.mark.parametrize("expired", [False, True]) def test_authenticated_staff_redirect_or_expired_link(short_link_case, client, expired): _, _, staff, ticket = short_link_case link, path = issue_link(ticket) if expired: StaffTicketShortLink.objects.filter(pk=link.pk).update(expires_at=timezone.now() - timedelta(seconds=1)) client.force_login(staff) response = client.get(path) assert response.status_code == (404 if expired else 302) assert "no-store" in response["Cache-Control"] if not expired: assert response.url == reverse("staff_tickets:detail", args=[ticket.public_id]) else: assert append_supervisor_ticket_link(ticket, "اعلان") == "اعلان" link.refresh_from_db() assert link.expires_at <= timezone.now() @pytest.mark.django_db def test_expiry_is_rechecked_after_login(short_link_case, client): _, _, staff, ticket = short_link_case link, path = issue_link(ticket) client.get(client.get(path).url) StaffTicketShortLink.objects.filter(pk=link.pk).update(expires_at=timezone.now()) client.post("/staff/login/", {"username": staff.username, "password": "Staff-password-123"}) assert client.get(path).status_code == 404 @pytest.mark.django_db def test_short_link_cannot_bypass_customer_or_other_unit_permissions(short_link_case, client): _, customer, _, ticket = short_link_case _, path = issue_link(ticket) client.force_login(customer) assert client.get(path).status_code == 403 outsider = User.objects.create_user(username="outsider", mobile="09121112240", account_type="STAFF") other_unit = OrganizationalUnit.objects.create(name="واحد دیگر", slug="short-other-unit") StaffProfile.objects.create(user=outsider, primary_unit=other_unit, display_name="کارشناس دیگر") client.force_login(outsider) assert client.get(path).status_code == 403 assert not ticket.access_grants.exists() @pytest.mark.django_db def test_missing_link_deleted_ticket_and_post_are_rejected(short_link_case, client): _, _, staff, ticket = short_link_case _, path = issue_link(ticket) client.force_login(staff) assert client.get("/vt/unknown").status_code == 404 assert client.post(path).status_code == 405 staff.is_superuser = True staff.save() delete_ticket(ticket_id=ticket.pk, user=staff) assert client.get(path).status_code == 404 @pytest.mark.django_db @pytest.mark.parametrize("target", ["https://evil.example/vt/abc", "//evil.example/vt/abc", "/\\evil.example/vt/abc", "/login/", "/staff/login/"]) def test_staff_login_rejects_unsafe_or_unrelated_next(short_link_case, client, target): _, _, staff, _ = short_link_case response = client.post("/staff/login/", { "username": staff.username, "password": "Staff-password-123", "next": target, }) assert response.url == reverse("staff_tickets:list") @pytest.mark.django_db def test_unit_admin_exposes_toggle(short_link_case, client): unit, _, staff, _ = short_link_case staff.is_superuser = staff.is_staff = True staff.save() client.force_login(staff) response = client.get(reverse("admin:staff_organizationalunit_change", args=[unit.pk])) assert response.status_code == 200 assert b'name="supervisor_sms_ticket_link_enabled"' in response.content assert "supervisor_sms_ticket_link_enabled" in admin.site._registry[OrganizationalUnit].list_filter