194 lines
9.1 KiB
Python
194 lines
9.1 KiB
Python
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
|